@hominis/fireforge 0.36.0 → 0.38.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 +26 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +6 -6
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +5 -1
- package/dist/src/commands/export-flow.js +10 -7
- package/dist/src/commands/export-placement-gate.js +3 -5
- 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.js +12 -5
- package/dist/src/commands/furnace/index.js +23 -18
- 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.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +49 -4
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- 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 +25 -6
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test-register.js +6 -5
- 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 +40 -23
- 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 +58 -0
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +51 -7
- 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 +6 -1
- 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-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +76 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +25 -7
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- 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 +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +69 -1
- package/dist/src/core/test-stale-check.js +134 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +84 -7
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- 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,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
- **`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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **`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.
|
|
13
|
+
- **`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.
|
|
14
|
+
- **`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.
|
|
15
|
+
|
|
16
|
+
## 0.37.0
|
|
17
|
+
|
|
18
|
+
- **`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.
|
|
19
|
+
- **The stale-build gate no longer refuses a plain `test` right after a green `test --build`** (friction-log item 2, bug — "test --build must update last-build.json"): this was the same defect — `runPreTestBuild` ran the identical protected `mach build faster` as `build --ui` but never wrote the baseline, so the immediately following plain `fireforge test` was refused listing the very files the embedded build had just rebuilt (workaround was a redundant ~1-min `fireforge build --ui`). The write happens only on build exit 0, mirrors `fireforge build`'s ordering and failure tolerance exactly, and a failed pre-test build keeps the prior baseline.
|
|
20
|
+
- **Every non-`--build` test run now tracks packaged-runtime COVERAGE, not just tree state** (friction-log item 3, gotcha — file-scoped `--build` + `--allow-stale-build` over other manifests hangs on unpackaged fixtures): the baseline gains `testPackagingCoverage` (`'full'` from `fireforge build`/`build --ui`/path-less `test --build`; the normalized requested paths from a scoped `test --build`). Any non-`--build` run whose requested paths fall outside the recorded coverage is refused up front — with or without `--allow-stale-build` (the flag only accepts stale CONTENT; missing coverage is a hang risk either way), and regardless of whether the tree is stale, since the hang reproduces with an unchanged tree — with a message naming the uncovered manifests, the recorded coverage, and both remediations (`test --build <paths>` or `fireforge build`); a covered re-run still proceeds, with the usual stale warning under the flag. A path-less `test --doctor` is exempt from the coverage gate — it stops at the Marionette health check and dispatches no test, so it needs no packaging coverage (the stale-content refusal still applies to it, and `--doctor` combined with test paths is checked like any other run). Baselines written before 0.37.0 lack the field and are treated as full coverage, so existing flows are unchanged.
|
|
21
|
+
- **`export`/`re-export` refuse to capture a stale furnace deploy** (friction-log item 4, inefficiency — re-export after editing `components/custom/` without `furnace deploy` exports the old deployed copy): when a patch's exported files fall under a furnace component's deployed prefix (custom `targetPath` / override `basePath`, plus a localized component's exact deployed `<ftlDir>/<name>.ftl` in the shared FTL directory; `sharedFtl` bundles are not furnace-deployed and stay unattributed by design) and that component's `components/` source has changed since the last `furnace apply`/`deploy` (checksum comparison against `appliedChecksums` — mtime is deliberately not trusted), the export is refused with a message naming the component and suggesting `fireforge furnace deploy` first; the new `--allow-stale-furnace` flag on both commands downgrades the refusal to a warning. A deploy-then-re-export sequence is untouched, and a broken/absent furnace setup never blocks non-furnace patch work.
|
|
22
|
+
- **`stagedDependencies` gains a registration-kind entry shape** (friction-log item 5, missing-feature — jar.mn packaging lines flagged `staged-dependency-unused`): `PatchStagedDependencies.registrations` entries (`{file, line, creates, owner?, reason?}`) declare a packaging/registration LINE — a jar.mn entry, a customElements registration, an actor registration — that references a later-created file. Lint validates them by matching the declared line (whitespace-trimmed) against the patch's added content in the declaring file instead of looking for an import specifier — and, mirroring forward-import validation, by requiring `creates` to resolve to a file a later-ordered patch actually creates and `owner`, when set, to name that patch (each failure kind gets its own message) — so mid-queue buildability facts for registration files (e.g. patch 200's jar.mn line packaging `hominis-history-ui.js` owned by patch 248) are machine-recordable: a matching entry lints clean, an import-kind entry still requires a real import, and a registration entry whose line is absent still flags `staged-dependency-unused`. CLI: `fireforge patch staged-dependency --add/--remove --kind registration --file … --line … --creates …`; owner-rename rewriting covers the new array.
|
|
23
|
+
- **`furnace validate` accepts `kind: "library"` components** (friction-log item 6, missing-feature — define-less base-class modules failed `no-custom-element-define`): a custom component manifest may declare `kind: "library"` (base class + helpers, no element of its own — e.g. a shared `moz-hominis-shared` module), which skips the `no-custom-element-define` / `not-moz-lit-element` compatibility errors and the `missing-css` structural advisory while keeping headers, module shape, `relative-import`, FTL rules, and the CSS compatibility rules when a stylesheet exists. `kind: "library"` requires `register: false` (rejected at config parse otherwise); a default-kind component without a define still errors, so the inert-element workaround can be retired.
|
|
24
|
+
- **The failure summary echoes the failing TEST-UNEXPECTED lines with their assertion text** (friction-log item 7, papercut — "First real test failure: Unexpected results: 1" was undiagnosable after the fact): `test-failures` verdicts now carry the first 5 `TEST-UNEXPECTED-*` lines verbatim, each with its trailing assertion/diff context (`Got …`/`Expected …`/Assert message/stack head), echoed under the `First real test failure:` headline in single runs and prepended to shard diagnoses; more than 5 append a `…(+N more)` note, and the shutdown-reentry artifact is excluded. A one-off chrome-suite failure that does not reproduce is now diagnosable from the summary block alone.
|
|
25
|
+
- **The known mozsystemmonitor teardown traceback is collapsed to one labeled line in the output tail** (friction-log item 8, bug/upstream — every headless run ended with the raw `AttributeError: 'SystemResourceMonitor' object has no attribute 'stop_time'` traceback sitting where the summary is read): the test dispatchers now route their terminal echo through a line-buffered filter that recognizes the exact documented signature — an `AttributeError` naming `stop_time` or `poll_interval` on `SystemResourceMonitor` AND a `mozsystemmonitor/resourcemonitor.py` frame AND a previously-seen `SUITE_END` shutdown marker (the flag is shared across the run's stdout/stderr filter pair, since the marker and the traceback usually land on different streams), including the chained-exception pair — and replaces the block with `[FireForge] Known upstream mozsystemmonitor teardown noise … — not a test failure`. Unrecognized tracebacks always print in full, the captured output stays raw for the 0.36.0 classifier (real-failure precedence and green-summary override are untouched), and oversized or truncated blocks flush verbatim rather than being withheld.
|
|
26
|
+
- **Mach dispatches reap their whole process GROUP, and `doctor` detects pre-existing orphaned harness workers** (friction-log item 9, severe — a mach that died at harness startup stranded a Python `multiprocessing` spawn worker that busy-spun at 100% CPU for ~26 days): (a) every mach-wrapping dispatch (test suites, protected builds, bootstrap, watch, package, run) now spawns mach as a POSIX process-group leader, forwards parent SIGINT/SIGTERM to the group (with the existing grace→SIGKILL escalation), group-kills on abort, and sweeps the group after every exit — survivors are named (multiprocessing workers highlighted), group-SIGTERMed, and SIGKILLed after a 2s grace, so a startup death leaves zero surviving multiprocessing children; a healthy run costs one `pgrep`. Windows stays best-effort tree-kill via `taskkill /T /F`. (b) A new `doctor` check ("Orphaned harness workers") scans `ps` for the detection recipe that found the incident — PPID 1 + ≥10 min accumulated CPU time + a `multiprocessing.spawn`/`forkserver`/`resource_tracker` command line, parsing both linux `dd-hh:mm:ss` and darwin `mm:ss.cc` TIME dialects — and reports each match with its PID, CPU time, command line, and a `kill <pid>` suggestion; report-only by design (FireForge never kills pre-existing processes), warning severity so it cannot fail doctor, skipped on Windows.
|
|
27
|
+
- **Follow-up findings** (pre-release review of the items above; the behavioral corrections are folded into their bullets): the packaging-coverage refusal now guards EVERY non-`--build` run, not just `--allow-stale-build` ones, with a path-less `test --doctor` exempted since it dispatches no test (item 3 — a plain run over an unchanged tree after a scoped `test --build` sailed through the gate into the very fixture hang the feature targets, while the widened gate would otherwise have refused a doctor-only Marionette probe as an uncovered full-suite request); the stale-furnace export gate attributes a localized component's deployed `<ftlDir>/<name>.ftl` (item 4 — exporting only the deployed FTL file never probed its component source); registration-kind staged dependencies validate `creates`/`owner`/ordering like the import kind (item 5 — metadata naming a nonexistent file or wrong owner linted clean); the teardown-noise echo filter is narrowed to the exact `stop_time`/`poll_interval` signature with a resourcemonitor.py frame after a seen `SUITE_END` (item 8 — it previously collapsed ANY traceback mentioning resourcemonitor.py, hiding novel upstream defects from live output); and the process-group reaper's grace timer is no longer `unref()`'d (item 9a — the timer was awaited from a child `close` handler after the signal forwarder was disposed, so Node could exit mid-grace and skip the SIGKILL escalation; a regression test pins the timer's ref-ness).
|
|
28
|
+
|
|
3
29
|
## 0.36.0
|
|
4
30
|
|
|
5
31
|
- **Review-findings remediation for the 0.36.0 release gate**: `fireforge test` no longer has a pathless dead-end — no-path runs now require `--auto`, `--doctor`, or `--canary`, `--auto` forwards mach's own selection mode, stale packaged-engine drift fails fast unless `--build` or `--allow-stale-build` is used, `--kill-stale-marionette` can terminate a recognized stale browser listener, and `--canary [path]` / `test.canaryPath` provides a short green/hang/crash harness probe. Harness verdicts now let real failure lines (`FAIL`, `TEST-UNEXPECTED-*`, nonzero unexpected summaries, assertions) beat resource-monitor traceback classification, while still noting the traceback as secondary noise. Patch naming is normalized consistently for `export --name` and `patch rename --to` (bare, category-prefixed, and full stems converge), and `lint --per-patch --patches` accepts repeated flags, comma lists, filenames, stems, manifest names, category-prefixed slugs, and bare slugs. Furnace-managed jsconfig edits are JSONC-preserving via `jsonc-parser`, the Firefox globals shim now includes JSWindowActor bases and `ChromeUtils.registerWindowActor`, `doctor` can validate opt-in `externalToolchains`, and CLI engine-mutating commands take a `.fireforge/engine-session.lock` plus test-run tree-generation checks to catch concurrent engine mutation.
|
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
|
|
|
@@ -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);
|
|
@@ -327,7 +327,7 @@ export async function buildCommand(projectRoot, options) {
|
|
|
327
327
|
// against this build's HEAD. A failed build keeps the prior baseline so
|
|
328
328
|
// the next attempt still catches long-standing packaging drops.
|
|
329
329
|
try {
|
|
330
|
-
await writeBuildBaseline(projectRoot, paths.engine, config.binaryName);
|
|
330
|
+
await writeBuildBaseline(projectRoot, paths.engine, config.binaryName, 'full');
|
|
331
331
|
}
|
|
332
332
|
catch (baselineError) {
|
|
333
333
|
verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
|
|
@@ -336,7 +336,7 @@ export async function buildCommand(projectRoot, options) {
|
|
|
336
336
|
}
|
|
337
337
|
/** Registers the build command on the CLI program. */
|
|
338
338
|
export function registerBuild(program, { getProjectRoot, withErrorHandling }) {
|
|
339
|
-
program
|
|
339
|
+
const build = program
|
|
340
340
|
.command('build')
|
|
341
341
|
.description('Build the browser (auto-applies Furnace components first)')
|
|
342
342
|
.option('--ui', 'Fast UI-only rebuild')
|
|
@@ -360,9 +360,9 @@ export function registerBuild(program, { getProjectRoot, withErrorHandling }) {
|
|
|
360
360
|
'and runs mach configure — preserving up to ~20 GB of obj-* artefacts on',
|
|
361
361
|
'a relocation. The rewriter refuses any change that is not a pure prefix',
|
|
362
362
|
'move, in which case a clean rebuild is still required.',
|
|
363
|
-
].join('\n'))
|
|
364
|
-
|
|
363
|
+
].join('\n'));
|
|
364
|
+
addWaitLockOption(build).action(withErrorHandling(async (options) => {
|
|
365
365
|
const projectRoot = getProjectRoot();
|
|
366
|
-
await withEngineSessionLock(projectRoot, 'build', () => buildCommand(projectRoot, pickDefined(options)));
|
|
366
|
+
await withEngineSessionLock(projectRoot, 'build', () => buildCommand(projectRoot, pickDefined(options)), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
367
367
|
}));
|
|
368
368
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor check for PRE-EXISTING orphaned test-harness workers (0.37.0
|
|
3
|
+
* item 9b).
|
|
4
|
+
*
|
|
5
|
+
* Field incident: a mach invocation that died at harness startup stranded a
|
|
6
|
+
* Python 3.14 `multiprocessing` spawn worker plus its resource tracker; the
|
|
7
|
+
* worker reparented to launchd (PPID 1) and busy-spun at 100% CPU for ~26
|
|
8
|
+
* days (~415 CPU-hours) undetected. The dispatch-side fix is the
|
|
9
|
+
* process-group reaping in `utils/process.ts`; this check covers orphans
|
|
10
|
+
* that predate it (or leaked from non-FireForge invocations), using the
|
|
11
|
+
* detection recipe that found the incident process: PPID 1 + large
|
|
12
|
+
* accumulated CPU TIME + a `multiprocessing.spawn`/`resource_tracker`
|
|
13
|
+
* command line.
|
|
14
|
+
*
|
|
15
|
+
* Report-only by design: FireForge never kills pre-existing processes it
|
|
16
|
+
* did not spawn — the check names each candidate and suggests the kill.
|
|
17
|
+
*
|
|
18
|
+
* Canary integration was deliberately skipped: the friction log asked for
|
|
19
|
+
* doctor AND/OR `test --canary`, and adding a system-wide process scan to
|
|
20
|
+
* the canary hot path buys no extra coverage over the doctor check.
|
|
21
|
+
*/
|
|
22
|
+
import type { DoctorCheckDefinition } from './doctor-check-core.js';
|
|
23
|
+
/** One process matching the orphaned-harness-worker shape. */
|
|
24
|
+
export interface OrphanedHarnessWorker {
|
|
25
|
+
pid: number;
|
|
26
|
+
ppid: number;
|
|
27
|
+
/** Raw TIME column as `ps` printed it. */
|
|
28
|
+
cpuTime: string;
|
|
29
|
+
/** Parsed accumulated CPU seconds. */
|
|
30
|
+
cpuSeconds: number;
|
|
31
|
+
command: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parses a `ps` TIME value in either dialect:
|
|
35
|
+
* - linux `[dd-]hh:mm:ss` (e.g. `26-03:14:12`, `03:14:12`)
|
|
36
|
+
* - darwin/BSD `mm:ss.cc` with cumulative minutes (e.g. `38412:07.55`)
|
|
37
|
+
* Returns NaN for unrecognized shapes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseCpuTime(time: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Scans `ps -axo pid=,ppid=,time=,command=` output for orphaned harness
|
|
42
|
+
* workers: PPID 1 (reparented to init/launchd), a multiprocessing
|
|
43
|
+
* worker/tracker command line, and at least `minCpuSeconds` of accumulated
|
|
44
|
+
* CPU time. Pure — fixture-testable without spawning anything.
|
|
45
|
+
*/
|
|
46
|
+
export declare function findOrphanedHarnessWorkers(psOutput: string, minCpuSeconds?: number): OrphanedHarnessWorker[];
|
|
47
|
+
/**
|
|
48
|
+
* Doctor check reporting orphaned harness workers. Windows has no `ps`;
|
|
49
|
+
* the check is skipped there (best-effort platform gap, matching the
|
|
50
|
+
* process-group reaper's POSIX-only sweep).
|
|
51
|
+
*/
|
|
52
|
+
export declare const ORPHANED_HARNESS_DOCTOR_CHECK: DoctorCheckDefinition;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Doctor check for PRE-EXISTING orphaned test-harness workers (0.37.0
|
|
4
|
+
* item 9b).
|
|
5
|
+
*
|
|
6
|
+
* Field incident: a mach invocation that died at harness startup stranded a
|
|
7
|
+
* Python 3.14 `multiprocessing` spawn worker plus its resource tracker; the
|
|
8
|
+
* worker reparented to launchd (PPID 1) and busy-spun at 100% CPU for ~26
|
|
9
|
+
* days (~415 CPU-hours) undetected. The dispatch-side fix is the
|
|
10
|
+
* process-group reaping in `utils/process.ts`; this check covers orphans
|
|
11
|
+
* that predate it (or leaked from non-FireForge invocations), using the
|
|
12
|
+
* detection recipe that found the incident process: PPID 1 + large
|
|
13
|
+
* accumulated CPU TIME + a `multiprocessing.spawn`/`resource_tracker`
|
|
14
|
+
* command line.
|
|
15
|
+
*
|
|
16
|
+
* Report-only by design: FireForge never kills pre-existing processes it
|
|
17
|
+
* did not spawn — the check names each candidate and suggests the kill.
|
|
18
|
+
*
|
|
19
|
+
* Canary integration was deliberately skipped: the friction log asked for
|
|
20
|
+
* doctor AND/OR `test --canary`, and adding a system-wide process scan to
|
|
21
|
+
* the canary hot path buys no extra coverage over the doctor check.
|
|
22
|
+
*/
|
|
23
|
+
import { exec } from '../utils/process.js';
|
|
24
|
+
import { ok, warning } from './doctor-check-core.js';
|
|
25
|
+
/**
|
|
26
|
+
* Command-line shapes of Python multiprocessing helper processes: the
|
|
27
|
+
* spawn/forkserver worker bootstrap (`from multiprocessing.spawn import
|
|
28
|
+
* spawn_main; spawn_main(...)`) and the resource tracker.
|
|
29
|
+
*/
|
|
30
|
+
const HARNESS_WORKER_COMMAND_PATTERN = /multiprocessing\.(?:spawn|forkserver)\b|multiprocessing\.resource_tracker|from multiprocessing\.\w+ import/;
|
|
31
|
+
/** Minimum accumulated CPU time before a match is reported (10 minutes). */
|
|
32
|
+
const DEFAULT_MIN_CPU_SECONDS = 600;
|
|
33
|
+
/**
|
|
34
|
+
* Parses a `ps` TIME value in either dialect:
|
|
35
|
+
* - linux `[dd-]hh:mm:ss` (e.g. `26-03:14:12`, `03:14:12`)
|
|
36
|
+
* - darwin/BSD `mm:ss.cc` with cumulative minutes (e.g. `38412:07.55`)
|
|
37
|
+
* Returns NaN for unrecognized shapes.
|
|
38
|
+
*/
|
|
39
|
+
export function parseCpuTime(time) {
|
|
40
|
+
const trimmed = time.trim();
|
|
41
|
+
let days = 0;
|
|
42
|
+
let rest = trimmed;
|
|
43
|
+
const dayMatch = /^(\d+)-(.*)$/.exec(trimmed);
|
|
44
|
+
if (dayMatch) {
|
|
45
|
+
days = Number(dayMatch[1]);
|
|
46
|
+
rest = dayMatch[2] ?? '';
|
|
47
|
+
}
|
|
48
|
+
const parts = rest.split(':');
|
|
49
|
+
if (parts.length === 3) {
|
|
50
|
+
// hh:mm:ss (linux; seconds may not carry fractions but tolerate them)
|
|
51
|
+
const [h, m, s] = parts.map(Number);
|
|
52
|
+
if ([h, m, s].some((n) => Number.isNaN(n)))
|
|
53
|
+
return NaN;
|
|
54
|
+
return days * 86400 + (h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0);
|
|
55
|
+
}
|
|
56
|
+
if (parts.length === 2) {
|
|
57
|
+
// mm:ss.cc (darwin — the minutes field accumulates without wrapping)
|
|
58
|
+
const [m, s] = parts.map(Number);
|
|
59
|
+
if ([m, s].some((n) => Number.isNaN(n)))
|
|
60
|
+
return NaN;
|
|
61
|
+
return days * 86400 + (m ?? 0) * 60 + (s ?? 0);
|
|
62
|
+
}
|
|
63
|
+
return NaN;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Scans `ps -axo pid=,ppid=,time=,command=` output for orphaned harness
|
|
67
|
+
* workers: PPID 1 (reparented to init/launchd), a multiprocessing
|
|
68
|
+
* worker/tracker command line, and at least `minCpuSeconds` of accumulated
|
|
69
|
+
* CPU time. Pure — fixture-testable without spawning anything.
|
|
70
|
+
*/
|
|
71
|
+
export function findOrphanedHarnessWorkers(psOutput, minCpuSeconds = DEFAULT_MIN_CPU_SECONDS) {
|
|
72
|
+
const workers = [];
|
|
73
|
+
for (const line of psOutput.split('\n')) {
|
|
74
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
75
|
+
if (!match)
|
|
76
|
+
continue;
|
|
77
|
+
const pid = Number(match[1]);
|
|
78
|
+
const ppid = Number(match[2]);
|
|
79
|
+
const cpuTime = match[3] ?? '';
|
|
80
|
+
const command = (match[4] ?? '').trim();
|
|
81
|
+
if (ppid !== 1)
|
|
82
|
+
continue;
|
|
83
|
+
if (!HARNESS_WORKER_COMMAND_PATTERN.test(command))
|
|
84
|
+
continue;
|
|
85
|
+
const cpuSeconds = parseCpuTime(cpuTime);
|
|
86
|
+
if (Number.isNaN(cpuSeconds) || cpuSeconds < minCpuSeconds)
|
|
87
|
+
continue;
|
|
88
|
+
workers.push({ pid, ppid, cpuTime, cpuSeconds, command });
|
|
89
|
+
}
|
|
90
|
+
return workers;
|
|
91
|
+
}
|
|
92
|
+
/** Thin exec wrapper for the process listing (tests mock `exec` instead). */
|
|
93
|
+
async function listSystemProcesses() {
|
|
94
|
+
const result = await exec('ps', ['-axo', 'pid=,ppid=,time=,command='], { timeout: 10000 });
|
|
95
|
+
if (result.exitCode !== 0) {
|
|
96
|
+
throw new Error(`ps exited ${String(result.exitCode)}`);
|
|
97
|
+
}
|
|
98
|
+
return result.stdout;
|
|
99
|
+
}
|
|
100
|
+
const CHECK_NAME = 'Orphaned harness workers';
|
|
101
|
+
async function runOrphanedHarnessCheck() {
|
|
102
|
+
let psOutput;
|
|
103
|
+
try {
|
|
104
|
+
psOutput = await listSystemProcesses();
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return warning(CHECK_NAME, `Could not scan system processes (${error instanceof Error ? error.message : String(error)}); skipping the orphaned-worker check.`);
|
|
108
|
+
}
|
|
109
|
+
const orphans = findOrphanedHarnessWorkers(psOutput);
|
|
110
|
+
if (orphans.length === 0) {
|
|
111
|
+
return ok(CHECK_NAME);
|
|
112
|
+
}
|
|
113
|
+
const rows = orphans
|
|
114
|
+
.map((w) => `PID ${String(w.pid)} (CPU time ${w.cpuTime}): ${w.command.slice(0, 160)}`)
|
|
115
|
+
.join('; ');
|
|
116
|
+
const pids = orphans.map((w) => String(w.pid)).join(' ');
|
|
117
|
+
return warning(CHECK_NAME, `Found ${String(orphans.length)} orphaned Python multiprocessing worker(s) — PPID 1 with ` +
|
|
118
|
+
`high accumulated CPU time, the shape a test harness that died at startup leaves behind ` +
|
|
119
|
+
`(field incident: one such worker busy-spun for ~26 days). ${rows}`, `These look like workers orphaned by a crashed test harness. Verify each command line, ` +
|
|
120
|
+
`then terminate with: kill ${pids} (or kill -9 if a process survives). ` +
|
|
121
|
+
'FireForge never kills pre-existing processes automatically.');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Doctor check reporting orphaned harness workers. Windows has no `ps`;
|
|
125
|
+
* the check is skipped there (best-effort platform gap, matching the
|
|
126
|
+
* process-group reaper's POSIX-only sweep).
|
|
127
|
+
*/
|
|
128
|
+
export const ORPHANED_HARNESS_DOCTOR_CHECK = {
|
|
129
|
+
name: CHECK_NAME,
|
|
130
|
+
skipIf: () => process.platform === 'win32',
|
|
131
|
+
run: runOrphanedHarnessCheck,
|
|
132
|
+
};
|
|
@@ -16,6 +16,7 @@ import { POST_REBASE_AUDIT_CHECK } from './doctor/post-rebase-audit.js';
|
|
|
16
16
|
import { failure, ok, warning } from './doctor-check-core.js';
|
|
17
17
|
import { EXTERNAL_TOOLCHAIN_DOCTOR_CHECK } from './doctor-external-toolchains.js';
|
|
18
18
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
19
|
+
import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
|
|
19
20
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
20
21
|
import { collectPatchQueueHealth } from './verify.js';
|
|
21
22
|
/**
|
|
@@ -264,6 +265,7 @@ const DOCTOR_CHECKS = [
|
|
|
264
265
|
return warning('Watchman available', 'watchman is not installed or not on PATH. "fireforge watch" requires it.', 'Install watchman (brew install watchman / dnf install watchman / https://facebook.github.io/watchman/), then re-run doctor.');
|
|
265
266
|
},
|
|
266
267
|
},
|
|
268
|
+
ORPHANED_HARNESS_DOCTOR_CHECK,
|
|
267
269
|
{
|
|
268
270
|
name: 'Patches directory exists',
|
|
269
271
|
run: async (ctx) => {
|
|
@@ -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,8 +39,12 @@ 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
|
|
@@ -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
|
|
@@ -225,7 +231,7 @@ export function placementSummary(plan) {
|
|
|
225
231
|
*/
|
|
226
232
|
export async function commitPlacementExport(input) {
|
|
227
233
|
return withPatchDirectoryLock(input.patchesDir, async () => {
|
|
228
|
-
const currentPlan = await resolvePlacementPlan(input.patchesDir, input.options, input.category, input.name);
|
|
234
|
+
const currentPlan = await resolvePlacementPlan(input.patchesDir, input.options, input.category, input.name, input.config);
|
|
229
235
|
if (!placementPlansEqual(currentPlan, input.expectedPlan)) {
|
|
230
236
|
throw new InvalidArgumentError('Patch queue changed while waiting for export confirmation. Re-run the command to recompute placement.', 'export placement');
|
|
231
237
|
}
|
|
@@ -237,9 +243,6 @@ export async function commitPlacementExport(input) {
|
|
|
237
243
|
'do NOT bypass with --force-unsafe. Pass --force-unsafe only for a reviewed placement conflict.', '--force-unsafe');
|
|
238
244
|
}
|
|
239
245
|
const originalManifest = await loadPatchesManifestForWrite(input.patchesDir);
|
|
240
|
-
if (originalManifest !== null) {
|
|
241
|
-
assertPlacementPreservesReservedRanges(currentPlan, originalManifest.patches, input.config, input.category);
|
|
242
|
-
}
|
|
243
246
|
if (input.config !== undefined) {
|
|
244
247
|
const renamed = originalManifest !== null
|
|
245
248
|
? 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,11 +40,10 @@ 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
|
-
if (currentManifest !== null) {
|
|
47
|
-
assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
|
|
48
|
-
}
|
|
49
47
|
const conflicts = await projectPlacementForLint(patchesDir, placementPlan, diff);
|
|
50
48
|
const renamed = currentManifest !== null
|
|
51
49
|
? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
|
|
@@ -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;
|
|
@@ -10,44 +10,47 @@ function findReservedRange(config, order) {
|
|
|
10
10
|
return (config.patchPolicy?.reservedRanges?.find((range) => order >= range.from && order <= range.to) ??
|
|
11
11
|
null);
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Scans downward from just below the reserved block for the first order
|
|
15
|
+
* not occupied by an existing patch. Returns null when every positive
|
|
16
|
+
* order below the block is taken.
|
|
17
|
+
*/
|
|
18
|
+
function firstFreeOrderBelowReservedRange(patches, range) {
|
|
17
19
|
const occupied = new Set(patches.map((patch) => patch.order));
|
|
18
|
-
for (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return order;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
for (const range of ranges) {
|
|
25
|
-
for (let order = range.from; order <= range.to; order++) {
|
|
26
|
-
if (!occupied.has(order) && findReservedRange(config, order) === null)
|
|
27
|
-
return order;
|
|
28
|
-
}
|
|
20
|
+
for (let order = range.from - 1; order >= 1; order--) {
|
|
21
|
+
if (!occupied.has(order))
|
|
22
|
+
return order;
|
|
29
23
|
}
|
|
30
24
|
return null;
|
|
31
25
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Refuses positional placement plans whose renumber would touch a
|
|
28
|
+
* `patchPolicy.reservedRanges` block — either by moving a patch INTO a
|
|
29
|
+
* reserved range or by moving a patch that currently sits inside one.
|
|
30
|
+
* Throws a single up-front error for the first reserved range hit (the
|
|
31
|
+
* per-patch alternative surfaced one confusing finding per shifted
|
|
32
|
+
* patch), suggesting the first free `--order` below the block when one
|
|
33
|
+
* exists. Exact `--order` plans have an empty rename map and pass.
|
|
34
|
+
*/
|
|
35
|
+
export function assertPlacementAvoidsReservedRanges(plan, manifestPatches, config) {
|
|
34
36
|
if (config?.patchPolicy === undefined || plan.renameMap.size === 0)
|
|
35
37
|
return;
|
|
36
38
|
const byFilename = new Map(manifestPatches.map((patch) => [patch.filename, patch]));
|
|
37
39
|
for (const [filename, rename] of plan.renameMap) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const reserved = findReservedRange(config, patch.order);
|
|
40
|
+
const oldOrder = byFilename.get(filename)?.order;
|
|
41
|
+
const reserved = findReservedRange(config, rename.newOrder) ??
|
|
42
|
+
(oldOrder !== undefined ? findReservedRange(config, oldOrder) : null);
|
|
42
43
|
if (reserved === null)
|
|
43
44
|
continue;
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
const label = reservedRangeLabel(reserved);
|
|
46
|
+
const freeOrder = firstFreeOrderBelowReservedRange(manifestPatches, reserved);
|
|
47
|
+
if (freeOrder !== null) {
|
|
48
|
+
throw new InvalidArgumentError(`Positional insert would renumber the reserved range ${label}; ` +
|
|
49
|
+
`pass --order ${String(freeOrder).padStart(3, '0')} (first free order below the ` +
|
|
50
|
+
'reserved block) to place the new patch without renumbering reserved patches.', 'export placement');
|
|
51
|
+
}
|
|
52
|
+
throw new InvalidArgumentError(`Positional insert would renumber the reserved range ${label}; ` +
|
|
53
|
+
'no free order exists below the reserved block. Choose an unused --order outside ' +
|
|
54
|
+
'the reserved range or adjust patchPolicy.reservedRanges.', 'export placement');
|
|
52
55
|
}
|
|
53
56
|
}
|
|
@@ -6,6 +6,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
6
6
|
import { appendHistory } from '../core/destructive.js';
|
|
7
7
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
8
8
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
9
|
+
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
9
10
|
import { getStatusWithCodes, isGitRepository } from '../core/git.js';
|
|
10
11
|
import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff.js';
|
|
11
12
|
import { isBinaryFile } from '../core/git-file-ops.js';
|
|
@@ -19,7 +20,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
|
19
20
|
import { toError } from '../utils/errors.js';
|
|
20
21
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
21
22
|
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
22
|
-
import { commanderArgParser, pickDefined } from '../utils/options.js';
|
|
23
|
+
import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
|
|
23
24
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
24
25
|
import { parsePositiveIntegerFlag } from '../utils/validation.js';
|
|
25
26
|
import { commitPlacementExport, renderDryRunPreview } from './export-flow.js';
|
|
@@ -201,6 +202,11 @@ async function prepareExport(projectRoot, files, options) {
|
|
|
201
202
|
throw new GeneralError(`Paths "${pathList}" have no changes to export.\n\n` +
|
|
202
203
|
'Run "fireforge status" to see modified files.');
|
|
203
204
|
}
|
|
205
|
+
// Stale-furnace-source gate (0.37.0 item 4): the export captures deployed
|
|
206
|
+
// engine copies; refuse (or warn under --allow-stale-furnace) when a
|
|
207
|
+
// covered component's source changed since the last furnace apply. Runs
|
|
208
|
+
// after --exclude-furnace so excluded furnace files never trigger it.
|
|
209
|
+
await enforceFreshFurnaceSources(projectRoot, allFiles, options.allowStaleFurnace === true, 'export');
|
|
204
210
|
let diff = await generatePatchDiff(paths.engine, allFiles);
|
|
205
211
|
if (!diff.trim()) {
|
|
206
212
|
if (options.skipLint) {
|
|
@@ -394,7 +400,7 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
394
400
|
}
|
|
395
401
|
/** Registers the export command on the CLI program. */
|
|
396
402
|
export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
397
|
-
program
|
|
403
|
+
const exportCmd = program
|
|
398
404
|
.command('export <paths...>')
|
|
399
405
|
.description('Export new changes as a patch (use re-export to update existing patches)')
|
|
400
406
|
.option('-n, --name <name>', 'Name for the patch')
|
|
@@ -409,10 +415,11 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
409
415
|
.option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
|
|
410
416
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
|
|
411
417
|
.option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
|
|
418
|
+
.option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
|
|
412
419
|
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
|
|
413
420
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
|
|
414
|
-
.option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], [])
|
|
415
|
-
|
|
421
|
+
.option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], []);
|
|
422
|
+
addWaitLockOption(exportCmd).action(withErrorHandling(async (paths, options) => {
|
|
416
423
|
const { category, tier, lintIgnore, ...rest } = options;
|
|
417
424
|
const projectRoot = getProjectRoot();
|
|
418
425
|
await withEngineSessionLock(projectRoot, 'export', () => exportCommand(projectRoot, paths, {
|
|
@@ -420,6 +427,6 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
420
427
|
...(category !== undefined ? { category } : {}),
|
|
421
428
|
...(tier !== undefined ? { tier: tier } : {}),
|
|
422
429
|
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
423
|
-
}));
|
|
430
|
+
}), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
424
431
|
}));
|
|
425
432
|
}
|