@hominis/fireforge 0.44.0 → 0.44.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -2
- package/README.md +20 -121
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/doctor-check-core.d.ts +9 -0
- package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
- package/dist/src/commands/doctor-patch-manifest.js +155 -0
- package/dist/src/commands/doctor.d.ts +1 -1
- package/dist/src/commands/doctor.js +48 -62
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/diff.js +6 -1
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/remove-mutations.js +5 -1
- package/dist/src/commands/furnace/sync.js +2 -2
- package/dist/src/commands/import.js +6 -2
- package/dist/src/commands/re-export-files.js +63 -23
- package/dist/src/commands/re-export-scan.js +18 -2
- package/dist/src/commands/re-export-single.js +19 -7
- package/dist/src/commands/test-register.js +5 -1
- package/dist/src/commands/test-run.d.ts +5 -0
- package/dist/src/commands/test-run.js +11 -4
- package/dist/src/commands/test-verdict.js +10 -1
- package/dist/src/commands/test.js +9 -0
- package/dist/src/commands/token.js +33 -11
- package/dist/src/core/build-audit-resolve.js +12 -3
- package/dist/src/core/build-audit.js +6 -2
- package/dist/src/core/config-mutate.js +20 -19
- package/dist/src/core/furnace-apply-ftl.js +4 -1
- package/dist/src/core/furnace-apply-helpers.js +11 -5
- package/dist/src/core/furnace-jsconfig.js +5 -3
- package/dist/src/core/furnace-operation.d.ts +25 -2
- package/dist/src/core/furnace-operation.js +27 -2
- package/dist/src/core/git-base.d.ts +14 -0
- package/dist/src/core/git-base.js +17 -0
- package/dist/src/core/git.js +3 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
- package/dist/src/core/mach-known-noise-filter.js +19 -0
- package/dist/src/core/mach.d.ts +8 -3
- package/dist/src/core/mach.js +47 -3
- package/dist/src/core/patch-lint-checkjs.js +25 -4
- package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
- package/dist/src/core/patch-manifest-consistency.js +96 -23
- package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
- package/dist/src/core/patch-manifest-files-affected.js +77 -0
- package/dist/src/core/patch-manifest.d.ts +2 -1
- package/dist/src/core/patch-manifest.js +2 -1
- package/dist/src/core/run-log.d.ts +60 -0
- package/dist/src/core/run-log.js +183 -0
- package/dist/src/core/test-harness-output.d.ts +24 -1
- package/dist/src/core/test-harness-output.js +46 -15
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
- package/dist/src/core/test-xpcshell-retry.js +6 -1
- package/dist/src/core/token-category.js +61 -7
- package/dist/src/core/token-dark-mode.js +20 -2
- package/dist/src/core/token-docs.js +1 -1
- package/dist/src/core/token-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +34 -0
- package/dist/src/core/token-variant.js +98 -8
- package/dist/src/core/tree-store.js +5 -1
- package/dist/src/core/typecheck.js +7 -1
- package/dist/src/types/commands/options.d.ts +53 -1
- package/dist/src/utils/child-env.d.ts +30 -0
- package/dist/src/utils/child-env.js +30 -0
- package/dist/src/utils/options.d.ts +28 -0
- package/dist/src/utils/options.js +32 -1
- package/dist/src/utils/process.d.ts +22 -0
- package/dist/src/utils/process.js +8 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
- **`release:check` ran twice per release**, because `npm publish` re-triggers it through `prepublishOnly`. The explicit step is gone; `prepublishOnly` is the single gate. The redundant third `npm pack` (`pack:dry-run`, already run by `pack:verify`) was dropped from the gate.
|
|
8
8
|
- Branch-protection contexts are stable: the smoke job is `smoke (${{ matrix.os }})` rather than embedding the Node version — the same trap already fixed for `quality (ubuntu)` — and `setup-rulesets.sh` reconciles (GET by name, then PUT or POST) instead of POSTing unconditionally, which is why the live ruleset had drifted to requiring nothing.
|
|
9
|
+
- **CI ran the lint toolchain on a Node version that toolchain excludes.** `eslint-plugin-jsdoc` and its two dependencies (`@es-joy/jsdoccomment`, `jsdoc-type-pratt-parser`) declare `node: ^22.22.2 || >=24.15.0`, and every workflow pinned **22.22.1** — one patch below the floor. `npm ci` only WARNS on an engine mismatch (`npm warn EBADENGINE`), so the gate stayed green while `lint:ci` ran on a combination the plugin's author had ruled out; the next symptom of that is an unexplained eslint crash rather than a version complaint. The pin moves to **22.23.2** (newest on the 22 line). The eight hardcoded `22.22.1` literals across four workflows are gone: all six setup-node steps now read `node-version-file: '.nvmrc'`, so the version is derived exactly the way the npm pin already is, and the drift that let the lockfile's floor climb past the pin cannot recur. `ci.yml`'s smoke matrix loses its now-uniform `node-version` column; the check contexts are unaffected, since `name:` pins them explicitly for the reason recorded above. `engines.node` deliberately moves only to `>=22.22.2`, NOT to the pin: the `^22.22.2` requirement comes from devDependencies, so a consumer of the published package must not be pushed to 22.23.2 for a lint plugin they never install. `.nvmrc` sitting above `engines` is the intended shape, not a number someone should reconcile.
|
|
10
|
+
- **A test repo committed without a committer name, so the gate passed on every developer machine and failed on the runner.** `apply-patch-idempotent.test.ts` was the only repo-creating test that configured `user.email` without `user.name` — the other twenty set both. Git falls back to the OS gecos field for a missing name, which is populated on a workstation and EMPTY for GitHub's `runner` user, so there `git commit -m baseline` died with `fatal: empty ident name` and left HEAD unborn. The helper used `exec`, which returns a non-zero `exitCode` rather than throwing, so the dead commit was discarded silently and the run continued against a repo with no commits; the failure surfaced six steps later inside `restoreFilesToHead` as `fatal: Not a valid object name HEAD`, wearing a `PatchApplyError` whose remedies are all about Firefox version drift. Six of the file's seven tests failed that way and pointed at the wrong subsystem. The helper now sets `user.name` and routes every git call through `runGit` (`src/test-utils/index.ts`), which is `execFile`-backed and REJECTS on non-zero — so a future setup command that fails is reported where it failed rather than as a corrupted repo later. An environment with no ambient git identity (`user.useConfigOnly`) reproduces the old failure exactly and passes on the new code.
|
|
9
11
|
|
|
10
12
|
### Multi-session contention
|
|
11
13
|
|
|
12
|
-
A downstream fork runs this checkout with up to five concurrent agent sessions, where a held lock is the normal state rather than an exceptional one. Four findings from that workload:
|
|
13
|
-
|
|
14
14
|
- **A `--wait-lock` budget expired one position from the head of the queue** — the worst possible outcome, because it pays the entire wait and gets nothing. `--wait-lock 300` died still-queued where `3600` was needed. The timeout is now a budget against a STALLED queue rather than against wall-clock: each time the observed queue position improves, the wait is granted a fresh budget, up to four times the requested one (ceiling one hour, matching the flag's own bound). A queue that stops moving still starves on exactly the budget you asked for, which is what the timeout is for. The refusal also names the position reached — "you were still 2 from the head of a queue of 3" — so raising the budget is an informed decision instead of a guess. Only `--wait-lock` runs extend; the no-flag ~1 s fail-fast path is untouched.
|
|
15
15
|
- **A wait budget could not be expressed once for a session.** `FIREFORGE_WAIT_LOCK=<seconds>` now supplies the budget for every lock-taking command that was not given an explicit `--wait-lock` (which always wins). An unattended loop threading the flag through every invocation was the shape most likely to be forgotten, and the omission only surfaced as a tail of refusals after the loop had already run. An unusable value is refused rather than silently falling back to fail-fast. Note that `test`, `export` and `re-export` never differed here: all three fail fast at ~1 s without the flag.
|
|
16
16
|
- **A killed run wrote no terminal line**, so a log tail could not distinguish "killed" from "still running" from "never started" — recovery meant checking `ps` and the lock file by hand, at exactly the moment (a mass kill under a usage limit, mid-drain) when the tree's state most needs to be readable. SIGTERM/SIGINT now emit `FIREFORGE-VERDICT: FAIL reason=killed signal=…` before the shutdown drain, so the line survives a drain that times out. It is a no-op unless a test run was actually in flight: a Ctrl+C during `status` must not claim a test run happened.
|
|
@@ -20,6 +20,10 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
20
20
|
`binaryStagingLock` never covered it — it is a module-level promise chain, so it serializes within one process while `--parallel-verify` lanes are separate processes. The staging now writes a per-call PRIVATE index (`mintDisposableGitIndex`) and throws it away, so the primary `.git/index` never moves, nothing is restored because nothing shared changed, and two concurrent callers get two indexes rather than contending. The 0.43.0 private-index work closed this for read-only commands; this path is the deliberate writer it did not cover, and it could not have been fixed by leaning on that scope — `export` and `re-export` install none, and `withPrivateGitIndex` is fail-open by design, which on a write path means silently mutating the primary checkout.
|
|
21
21
|
The seed is load-bearing rather than tidy: a tracked, unmodified binary reaches the same branch (its `diff HEAD` is empty), and against an empty index it would report as a fresh add. The shared-index path survives as a fallback for when no private index can be minted — refusing there would fail an export over an optimisation. A real-git test polls `git status` throughout the call and asserts the transient entry is never observable; it fails on the old implementation, which is what makes it worth having.
|
|
22
22
|
|
|
23
|
+
- **`--wait-lock` governed only ONE of the two locks a furnace mutation takes.** `furnace apply`/`deploy`/`sync` routed the resolved budget into the engine session lock and stopped there. `runFurnaceMutation` then acquired `.fireforge/furnace.lock` through `withFileLock`, passing `timeoutMs` only when `options.lockTimeoutMs` was set — and **no production caller ever set it**: the field's only other occurrences were its own declaration and one test, and its doc comment cited a watch-mode caller that does not exist. So the file lock always fell to `DEFAULT_LOCK_TIMEOUT_MS`, a fixed 30 s. A contended `deploy --wait-lock 1800` therefore died at 30 s while its operator believed a half-hour budget was in force, which is the worst shape a budget can have: it pays the entire advertised wait and still refuses. `FIREFORGE_WAIT_LOCK` did not help — it feeds the same resolver into the same single sink.
|
|
24
|
+
The resolved budget now reaches both. It is threaded explicitly into the three commands that declare the flag (`waitLockMutationOptions` does the seconds-to-milliseconds conversion once, rather than at three call sites where dropping the factor is a silent 1000× error), and `runFurnaceMutation` falls back to `sessionWaitLockMs()` — the environment budget — so the twelve furnace mutators with no flag to thread (`create`, `remove`, `rename`, `refresh`, `scan`, `override`, `chrome-doc`, …) are covered too. An explicit `lockTimeoutMs` still wins, and absent both the ~1 s fail-fast and the 30 s default are untouched.
|
|
25
|
+
The invisibility was the other half of the defect: the flag's help text said "the contended lock", singular, and the furnace refusal named neither the lock nor the flag — it advised removing a stale lock directory. The help now says the budget covers every lock the command takes, and the refusal names the FURNACE lock, says it is the second of two, and points at `--wait-lock`/`FIREFORGE_WAIT_LOCK`. A test asserts the budget reaches the command options, and a sibling asserts nothing is threaded without the flag.
|
|
26
|
+
|
|
23
27
|
### Exit codes
|
|
24
28
|
|
|
25
29
|
- **Red, never-ran and thrown-away were three facts sharing two exit codes.** `reason=inconclusive` — a suite whose verdict was DISCARDED because `engine/` moved under it — exited **1** beside real test failures' **5**, and a summary line reading `FAIL — exit 1` next to `FAIL — exit 5` invites treating opposites as the same kind of fact: one suite failed, the other's healthy result was thrown away. It now exits **14** (`InconclusiveVerdictError`). `LockContentionError` moves from 1 to **15**: a lock timeout means the run never started, so a script should re-queue it rather than report a failure of the work it asked for. That re-codes patch-directory, build and furnace lock timeouts too — deliberately, since the fact is the same whichever lock was contended. See `docs/exit-codes.md`.
|
|
@@ -39,6 +43,15 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
39
43
|
- **The packaging-coverage refusal never mentioned `--extend-coverage`.** The cumulative claim shipped in 0.43.0, but under concurrent sessions the operator meets it at the moment a peer's scoped build has replaced coverage they still hold — and the refusal pointed only at a full rebuild. It now offers the union, and states the anchor conditions (unchanged engine HEAD and `engine/mozconfig`) so the operator knows when it will refuse. Coverage still REPLACES by default, for the reason documented on `BuildBaseline.testPackagingCoverage`: a blind union whitewashes an earlier scope's edited fixtures while `obj-*/_tests/` still holds that scope's stale staging.
|
|
40
44
|
- **The no-output-stall census asserted a mechanism it had no evidence for, and its third cause carried no discriminating step.** The `chrome://`-image entry shipped in 0.43.0 stating that a broken image URI on the startup page "stalls first paint"; that mechanism was never established here — it was written on top of a single downstream report which explicitly did not root-cause it, and it is doubtful on its own terms, since a failed image load in Gecko fires `error` without gating the document load event. The entry now states the correlation it actually rests on (one consumer, reproduced and un-reproduced on one variable) and says the mechanism is open, so nobody inherits a guess as a fact. Separately, its remedy — run a known-good control — is the correct opening move for **all three** causes, so attaching it to one entry made reaching that entry informationally empty; the control step is hoisted above the list, and cause 3 gains a probe that actually separates it from the other two (`fireforge run --smoke-exit … --capture-console`, which starts the same browser outside the test harness). No detector is claimed: no log line is known to key on, and `mach-error-hints.ts` sets the bar at an established diagnosis rather than a plausible one. The census moved to `src/core/test-stall-triage.ts` — it is operator advice, not classification, and the rules for editing it are not the rules for editing a classifier.
|
|
41
45
|
|
|
46
|
+
- **A run's only copy of its own diagnosis died with the pipe.** `fireforge test … 2>&1 | tail -40` keeps the summary and discards the `TEST-UNEXPECTED-FAIL` lines that say WHAT broke; it also launders the exit code. Nothing else held the output — `runMachCapture` keeps a 2 MB in-memory tail that dies with the process, and the shard retry loop discards earlier attempts outright — so one downstream failure is now permanently undiagnosable and cost two further full runs to pin as "unreproduced". The operator rule ("never pipe a run") had been written down three times and broken after each writing, which is the tell that it is not an operator problem: piping is the ergonomic default when output is long, and the tool is the only party that can make the log survive the mistake.
|
|
47
|
+
`test` and `build` now write their own complete output to `.fireforge/logs/<command>-<timestamp>.log` as they stream, retained 20 deep per command kind (per-kind, so a busy `test` loop cannot evict the `build` log an operator needs to read beside it). The tee sits at the two mach capture funnels rather than at the commands, so it covers every dispatch — the three test dispatchers, `package`, and the stdio-inheriting path `mach build` uses, which rides the collectors' existing `mirror` hook rather than growing a second one. The log gets the RAW bytes, never the echo filter's collapsed form: the artifact exists to be re-read by whoever is diagnosing the run, so shortening it would reintroduce the loss it prevents.
|
|
48
|
+
The path rides the `FIREFORGE-VERDICT:` line as an additive `log=<path>` key. That is deliberate and not a free choice — the verdict must stay the run's LAST stdout write, so a separate announcement after it would break the contract and one before it is the first thing a `tail` cuts. `build`, which prints no verdict line, announces the path directly. Everything here is best-effort: a log that cannot be opened, written or pruned degrades to no log, because a run must never fail over a diagnostic. Opened before any preflight, so a refusal is logged too — those are exactly the runs whose only output a `tail` throws away.
|
|
49
|
+
- **Post-rebuild failure diagnosis promoted recognized teardown noise over the real assertion.** An export shard failed on its file-count assertion; the diagnosis named the known `AttributeError: 'SystemResourceMonitor' object has no attribute 'stop_time'` instead. The raw Error Summary had the assertion right, so the information was present and the SELECTION was wrong — the expensive kind, because the named cause is a real, documented, unrelated upstream defect and therefore reads as an answer.
|
|
50
|
+
0.44.0 already ships `mach-known-noise-filter.ts`, whose module doc says exactly the right thing about closing the presentation gap — but `findFirstUsefulFailureLine` never consulted it. It matched a bare `/AttributeError:\s+/` among its patterns and selected by first-matching-LINE, not first-matching-pattern, so that arm had precisely the same standing as `TEST-UNEXPECTED-FAIL` and whichever appeared earlier won. The selector now runs two passes: candidates excluding the recognized noise first, the full set only if that finds nothing. The narrowness is preserved exactly — the exclusion is gated on `hasKnownTeardownNoise` for the whole capture, so it still requires both the closed `stop_time`/`poll_interval` allowlist AND a `resourcemonitor.py` frame, and a novel attribute or a traceback from anywhere else stays a real failure. Three tests pin those escape hatches; the one that pins the fix fails on the old code.
|
|
51
|
+
Worth recording that the misdiagnosis was CONDITIONAL, not universal: the classifier reads stdout then stderr, and the traceback usually lands on stderr while the suite output lands on stdout, so a real `TEST-UNEXPECTED-*` normally won on ordering alone. It bites when the traceback shares or precedes the stream, or when the real failure matches none of the other patterns. The reporter could not have known that, and it does not change the fix — but a defect described as always-firing gets re-opened as "not reproducible" by the next reader.
|
|
52
|
+
- **mozbuild's AI-agent output limiter quieted test runs, not just builds.** `is_running_under_coding_agent()` keys on `CLAUDECODE`, which FireForge inherited into every mach dispatch verbatim — it had no handling of the variable anywhere. The build half of that quieting is genuinely useful; the test half removes `TEST_START` and console INFO, exactly the lines a hang diagnosis needs, and the ones FireForge's own classifier reads (`Ran N checks`, `Unexpected results:`, `TEST-UNEXPECTED-*`), so suppression pushes a run toward `reason=no-tests`. Unsetting the variable for the whole process gives up the build half too.
|
|
53
|
+
`fireforge test --full-output` now unsets the marker for TEST dispatches only; the build path is untouched. It is opt-in rather than automatic because it changes how much a third party prints, and an operator who wants the quieting should keep it. The exec layer gained an explicit `envUnset` for this: `env` can only add or overwrite, and a variable read for its PRESENCE is not unset by an empty value — and the removal is a real delete rather than a `Record<string, string | undefined>` widening, because relying on Node dropping `undefined` env entries would be a semantics gamble at the one layer that must not have any.
|
|
54
|
+
|
|
42
55
|
### Build output resolution
|
|
43
56
|
|
|
44
57
|
- **A second `obj-*` directory made FireForge refuse every command, even when the mozconfig said which one to use.** The refusal is correct where the choice is genuinely ambiguous — building a dev test against a release objdir is exactly the silent corruption it prevents — but `MOZ_OBJDIR` was never read, so an operator whose active configuration had already answered the question was sent to rename a directory to satisfy a scan. An ambiguous glob now consults `engine/mozconfig` (`mk_add_options MOZ_OBJDIR=…` or a bare `export`, `@TOPSRCDIR@` and relative forms resolved to a path segment) and proceeds when the declaration resolves to exactly one candidate — into the existing `mozinfo.json` verification, which is the real safety net. Everything else still refuses, and a declaration that named something the scan cannot see is reported, because that is itself the diagnosis.
|
|
@@ -63,6 +76,35 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
63
76
|
- The signal-time furnace lock sweeper removed lock directories without checking ownership, so it could delete a lock another process had acquired in the window between our operation dying and the sweep running. It now uses `releaseLockIfOwned`.
|
|
64
77
|
- `killProcessTree`'s POSIX branch swallowed `ESRCH`/`EPERM` from the group kill without falling through to a direct `child.kill`, so a failed group kill left the child running.
|
|
65
78
|
|
|
79
|
+
- **A DELETION of an upstream file could not be made durable through `re-export`, and the failure was silent.** With eighteen retired files, `rm` left `fireforge status --json` reporting `"status": "D"` with `"classification": "unmanaged"` — correct as a description, fatal as a state, because `check-engine-clean` refuses an unmanaged file precisely because it is not durable through a reset, and a reset would restore every deleted file. No re-export path captured it: `--scan` pruned the path from `filesAffected` and emitted no deletion hunk, `--scan-file` refused it outright, and **`--files` accepted the path and emitted no hunk** — the dangerous shape, because the run reported success while writing a patch that silently CLAIMED a file it did not restore. On a fresh clone that patch applies cleanly and resurrects exactly what the change meant to remove.
|
|
80
|
+
The cause was one conflation. All three paths filtered "absent from disk" out of the diff scope, on a comment asserting the diff "would fail" on a missing path — true only for a path that was NEVER TRACKED. For a tracked file deleted in the worktree, `git diff HEAD -- <path>` emits a proper `deleted file mode` section, and every downstream consumer already branches on `isDeletedFile`. The two cases are now split by `listTrackedInHead`: a tracked absent path stays in the diff scope and is captured, an untracked one is refused rather than warned about, since the patch cannot express it either way. A captured deletion KEEPS its `filesAffected` entry — the patch is what performs the deletion, so pruning it would leave the body deleting a file the manifest says the patch has nothing to do with — and it still requires the confirmation, because the patch will now remove those files wherever it is applied.
|
|
81
|
+
The report's priority-1 item — make the silent success LOUD — turned out to need two words, not a feature: the `--files` path already refused when a requested path produced no hunk, but its domain was the post-filter subset, so anything dropped upstream was invisible to the check by construction. It now runs against the full requested list. A real-git test asserts the emitted patch contains `deleted file mode`; it fails on the old implementation.
|
|
82
|
+
Recorded because the report says otherwise and the next reader should not chase it: **`export` was never affected.** It accepts a `D` status from `getStatusWithCodes` and routes a HEAD-tracked path to `git diff --binary HEAD`, which has always carried the deletion. Only the batched `re-export` paths dropped it.
|
|
83
|
+
- **`token add --mode override` silently skipped a QUALIFIED `[data-theme="light"]` block.** The override path mirrors into both `:root[data-theme="dark"]` and `:root[data-theme="light"]`, guarded by `variantBlockExists` — and that guard compared canonically against a fragment sliced to the LAST `]` before the opening brace. For `:root[data-theme="light"]:not([data-private]) { }` the last `]` is the one inside `:not(…)`, so the computed fragment was `:root[data-theme="light"]:not([data-private]` and the comparison could never match. The unqualified dark block beside it matched fine. That asymmetry was the whole bug: the override wrote dark, skipped light, and said nothing — so a Light choice under a dark OS inherited the new dark token, and the operator met the gap as a theme bug rather than as tool output. `THEME_ATTRIBUTE_VARIANTS`' own doc comment names the failure this feature exists to prevent — "a guaranteed half-finished edit" — and the half-finished edit is what shipped.
|
|
84
|
+
The last-`]` slice was deliberate, not sloppy: it avoids a `js/polynomial-redos` regex, and taking the last `]` rather than the first keeps a multi-attribute selector comparing as a whole instead of colliding with a single-attribute variant that is its prefix. Both properties survive. The selector is now parsed structurally — consume consecutive `[…]` groups after `:root`, stop at the first character that is not `[` — which is still linear and still matches multi-attribute blocks whole, but excludes a pseudo-class tail instead of swallowing it. A regression test uses a two-attribute block to pin that.
|
|
85
|
+
The qualifier is reported rather than applied silently. `:not([data-private])` is semantically load-bearing — writing through it narrows where the declaration applies, and that may not be what the operator meant — but matching it is still the right call, because silently skipping it is the worse failure and the one that shipped. So the write happens and a warning names the selector it went through. The pinning test fails on the old matcher.
|
|
86
|
+
- **`token add --variant` required a flag that described nothing, rejected its companion, and reported a no-op as success.** Three separate frictions, all in the variant path. `--category` was an unconditional `requiredOption` that had to name a REAL category even under `--variant`, where the declaration is routed into a `:root<selector>` block and never into a category section — the requirement existed so the argument would not be "silently discarded", a fair goal whose effect was a mandatory argument saying nothing about where the token lands. It is now optional there (still validated when supplied, so a typo is not accepted just because the flag is inert), and its absence on a BASE declaration is refused with the REASON stated rather than commander's bare "required option not specified".
|
|
87
|
+
`--create-category` is still rejected with `--variant` — the two really are incompatible — but the working create-then-variant order (author the category with the base token, then add each variant) was discoverable only by failing twice. It is now in both the error and `--variant`'s help text.
|
|
88
|
+
And a token already present in the target variant block was skipped with a bare `{ added: false }`, so a re-run intending to CHANGE a value exited 0 having changed nothing, while the base path had been reporting the category and line for the same situation all along. The variant path now returns the location too, and the skip reads `already exists in :root[…] (line N), unchanged` — the block being the location, since a variant declaration has no category to name.
|
|
89
|
+
|
|
90
|
+
### Security
|
|
91
|
+
|
|
92
|
+
- **Six scanners over `tokens.css` were super-linear in the length of a single line** (`js/polynomial-redos`). Worth stating plainly what the input is: `tokens.css` and `docs/design/SRC_TOKENS.md` are files FireForge reads out of a CONSUMER's engine tree, so "attacker-controlled" is the wrong frame — a minified, generated or merge-mangled line that happens to repeat `:root`, `:root[`, `var(` or `/*=` is enough to wedge `token add` with nobody doing anything hostile. Measured on the witnesses now pinned as tests: 4 000 `=` after a `/*` cost the category scanner **8.2 s**, and 20 000-40 000 repetitions cost the other five between **0.8 s and 3.6 s** each. Two distinct causes, and they want different remedies:
|
|
93
|
+
- _Ambiguous adjacent quantifiers_, where two atoms match the same character so the engine re-partitions the run once per length: `\s*=+\s*(.+?)\s*=+\s*` in the banner reader (`.` matches both `\s` and `=`), `\/\*\s*=.*=\s*\*\/` in the section-end scan, `(^|[\s,{])\s*:root\b` in the dark-mode scan (`[\s,{]` and `\s*` both match a tab), and `:root\[[^{]*\]` in the variant scan (`[^{]` also matches `]`). The dark-mode one is a one-token fix — `(?:^|[\s,{]):root\b` is exactly equivalent, since the last whitespace character of any run satisfies the class on its own. The other three became index arithmetic (`blockCommentBody`, `singleLineBannerName`, `rootAttributeSelector`), which is also where the duplicated "is this a banner comment?" spelling collapsed into one predicate.
|
|
94
|
+
- _An unanchored prefix that can start at many positions_, which is quadratic even when the pattern itself is unambiguous: `:root[^{}]*\{` restarts at every `:root` in the line, and `value.replace(/var\(([^)]+)\)/, …)` at every `var(`. The docs one is a one-character fix — the branch it sits on has already established `value.startsWith('var(')`, so anchoring is both linear and exact. The other became `rootOpensBraceOnLine`.
|
|
95
|
+
- The variant scanner's replacement deliberately spans to the LAST `]` before the opening brace rather than the first. Taking the first would have been the shorter fix and a silent behaviour change: `:root[data-skin=precision][data-private]` would then satisfy a request for the `[data-skin=precision]` variant it merely starts with, and the declaration would land in the wrong block. A test pins that, and it passes on both implementations — which is the point of it.
|
|
96
|
+
- The regression tests assert wall-clock, because the defect IS a running time; every one of these calls returns the same answer before and after. Each witness fails the old implementation by an order of magnitude or more, and the whole file now runs in ~26 ms.
|
|
97
|
+
- **The prototype-chain guard on `config <key> <value> --force` was correct and invisible** (`js/prototype-pollution-utility`). `mutateConfig` rejected `__proto__`, `constructor` and `prototype` — but once, up front, in a helper, against a `Set`; the descent and the leaf assignment two dozen lines below therefore read as unguarded to anything that is not interprocedural, CodeQL included. The check now happens at the point each segment is USED as a property name, spelled as explicit comparisons, with only the message factored out. That is not just to satisfy the query: a guard that lives at a distance from its write is one refactor of its caller away from being skipped, and the existing tests keep passing untouched because nothing about the behaviour moved.
|
|
98
|
+
- **Two `replace` calls read as incomplete escaping** (`js/incomplete-sanitization`), and both were in fact correct — which is exactly why they are rewritten rather than dismissed, since "correct, trust me" is not a property a scanner can re-derive next quarter. `patch-lint-checkjs` substituted a TypeScript `paths` wildcard with `target.replace('*', captured)`; a target carries at most one `*` and first-occurrence replacement IS the contract, now spelled as index arithmetic mirroring the prefix/suffix split already used on the pattern side, with a test pinning both the wildcard and wildcard-free target shapes. The pack smoke test hand-escaped a version string with `.replace(/\./g, '\\.')`; it now uses `escapeRegex` from `src/utils/regex.ts`, the helper ~40 other call sites already use.
|
|
99
|
+
|
|
100
|
+
### Manifest repair
|
|
101
|
+
|
|
102
|
+
- **The rebuild dropped `stagedDependencies` from every entry that had one.** `doctor --repair-patches-manifest` builds each row as an explicit object literal, field by field, from the existing entry — so it preserves exactly the fields someone remembered to list, and silently drops the rest. `stagedDependencies` was never listed, which on a queue that uses it means the `registrations` block that lets a branding `distribution/` directory land with a later patch is gone, and the next `lint` fires on a dependency the operator had declared. A patch body cannot express it, so nothing recomputes it either. The row is now built by spreading the existing entry and overriding only the two fields the rebuild OWNS — `filesAffected` and `order` — so a field a `.patch` cannot carry survives by construction, including whatever is added to `PatchMetadata` next. This is the same failure the `lintIgnore`/`tier` lines were added to fix, one field later; the enumeration was the defect, not the omission.
|
|
103
|
+
- **An unparseable `patches.json` was rebuilt into a wholly invented one, and the report did not see this because it is not what happened to it.** An existing-but-invalid manifest loads as `null`, which the merge cannot tell apart from "no manifest at all" — so every entry was treated as new, and every entry's `description`, `createdAt`, `category` and source version was reinvented from filename, mtime and fallback. `manifest-invalid` is one of the consistency issues that triggers the repair, so `doctor --repair-patches-manifest` reached it directly. It now refuses, naming the parse error and what would be lost, and the escape hatch is explicit (`--allow-metadata-loss`) rather than the default.
|
|
104
|
+
- **A repair that wrote was reported as a run where nothing happened.** The repair runs inside the check loop; the exit code is computed after every check. The repair itself returns a warning, so it never fails the run — but any later failing check makes doctor exit 1 with the manifest already rewritten, and a non-zero exit reads as "the command refused". Doctor now prints what it wrote, immediately before the summary, in every branch including the failing one. The write is not rolled back: the repairs are independent of the checks that follow them, and undoing a successful repair because something unrelated is also wrong would be the larger surprise.
|
|
105
|
+
- **`--repair-patches-manifest` was named as the fix for a one-field drift, by both `import` and doctor's own hint.** `files-affected-mismatch` is a derived list disagreeing with the diff that defines it, and a tool that names a whole-manifest rewrite as the remedy for that will have it run. There is now a narrow repair — `doctor --repair-files-affected` — which recomputes only the drifted lists through `mutatePatchRowsInManifest`, so every untouched row keeps its exact JSON, and which needs no project config, so unlike the rebuild it works on a project whose `fireforge.json` is also broken. The refusals now offer whichever repair fits the issue codes actually present. Both repairs gained `--dry-run`, which `patch lint-ignore` has had all along; neither gained a confirmation prompt, because `confirmDestructive` refuses a non-TTY run and these are run from scripts — the repair flag is itself the explicit opt-in. Manifest rows the rebuild drops because their patch file is gone are now named too.
|
|
106
|
+
- **Two of the report's claims do not hold against this code, and are recorded rather than "fixed".** `lintIgnore`, `tier`, `description`, `category`, `createdAt` and `sourceEsrVersion` were all already preserved on an existing entry — the rebuild has been a merge, not a from-scratch rebuild, since the branding tier landed. Most of the 1823 deletions were `filesAffected` lists being re-sorted (the rebuild normalises them; the narrow repair does too, but only on rows that were already wrong) plus the `stagedDependencies` blocks above. And the three binary-asset patches the report flagged as possible mismatches were an artifact of its own scan: `getAllTargetFilesFromPatch` reads `diff --git` headers, so `GIT binary patch` sections with no `+++ b/` line have been counted correctly since the fix that put them there. Naming what the code actually does is how the next occurrence gets attributed quickly instead of inheriting a guess.
|
|
107
|
+
|
|
66
108
|
### Performance
|
|
67
109
|
|
|
68
110
|
- **`getAllDiff` spawned one `git hash-object` per untracked file, uncapped.** Its sibling `getDiffForFilesAgainstHead` was reshaped into classify → one batched hash → render for exactly this reason; this path kept the per-file form, and it is the only path behind `tree create`, `tree list`, `tree remove --all`, `lint` and `export-all` — against a Firefox tree where "untracked" can mean a very large number of files. Now batched. Everything observable is deliberately unchanged (renames, `ls-files` ordering, tracked-block-first emission, the `'\n'` empty sentinel) because this output is SHA-256'd into every tree fingerprint and written verbatim into `.patch` files; a new real-git test pins those bytes, which nothing did before.
|
|
@@ -117,6 +159,20 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
117
159
|
- **A `patches.json` from a newer FireForge was reported as malformed.** `patches.json version must be 1` sent the operator looking for damage in a file that only needed a newer build to read it, and the manifest had no migration hook at all. It now refuses by name — naming the found and supported schema versions and telling the operator not to delete the file — while a version below 1 or non-integer keeps the plain malformed message, because that one really is malformed.
|
|
118
160
|
- The three JSDoc walkers in `patch-lint-jsdoc.ts` return their issues instead of pushing into an out-param, matching the ~30 other patch-lint entry points. The new `jsdoc/check-param-names` rule immediately caught the stale `@param issues` the change left behind — which is the point of enabling it.
|
|
119
161
|
|
|
162
|
+
### Windows
|
|
163
|
+
|
|
164
|
+
- **`fireforge lint`'s checkJs pass reported ZERO findings on Windows, silently.** `patch-lint-checkjs.ts` keys `ownedAbsolute` / `relByAbsolute` / `pathsResolved` on `resolve(repoDir, rel)` — backslashes — but TypeScript's `CompilerHost` hands `getSourceFile`/`fileExists` a `/`-normalized `fileName` on every platform. Every lookup missed, so the host served an empty synthetic source file for each owned file and the whole pass came back clean. "No diagnostics" is indistinguishable from "no problems", which is why nothing caught it. The maps are now keyed through one `toTsPath()` helper in the TypeScript spelling. `typecheck.ts` had the same defect on its synthetic shim: never served, and its own diagnostics never filtered out.
|
|
165
|
+
- **`furnace diff` reported every overridden file as a new file.** The engine-relative path it hands `git show <ref>:<path>` came from slicing a native path, and git's `ref:path` form only understands forward slashes — so every baseline read missed and the command showed nothing to diff.
|
|
166
|
+
- **Engine-relative paths were recorded in two spellings at once.** `furnace-apply-helpers.ts` and `furnace-apply-ftl.ts` pushed `relative(engineDir, …)` results — backslashes on Windows — into lists whose sibling entries are POSIX literals, and those lists are identifiers: they go to git, into furnace state, and are compared against the POSIX paths `furnace.json` and `jar.mn` already carry. Normalized at the five producing sites.
|
|
167
|
+
- **`build-audit` collapsed to one path segment**, so the same-basename collision heuristic and the stale/missing classification stopped discriminating; **`removeTree` refused every removal** as an escape from the trees directory, because it compared a `join`ed prefix against a `resolve`d root and only the latter carries a drive letter; and **`initRepository` never pinned `core.autocrlf`**, so on a host with git's Windows default the checkout rewrote LF to CRLF — shifting the blob hashes `hashObjectBatch` delegates to git, and with them the bytes patches are exported from and applied to.
|
|
168
|
+
- **`furnace init` accepted `--ftl-base-path ../escape/path` on Windows.** The traversal guard normalized the value and then tested it for a `../` prefix, but the platform `normalize` emits backslashes there, so `..\escape\path` failed the check and the escape was written into `furnace.json`. The guard normalizes with the POSIX rules regardless of host; the sibling absolute-path and null-byte checks were already separator-agnostic.
|
|
169
|
+
- **`furnace` jsconfig sync did nothing on Windows.** `isManagedEntry` decided whether a `compilerOptions.paths` entry pointed into the components workspace with `target.startsWith(resolve(customDir) + '/')`. The hard-coded `/` never matches what `resolve` produces there, so every Furnace-managed entry was classified as hand-written and preserved verbatim: entries whose helper was deleted were never pruned and entries whose target moved were never updated. Containment now goes through `isPathInsideRoot`, the same `relative`-based check the rest of the codebase uses. The failure was silent in the worst way — a clean `+0 added, ~0 updated, -0 pruned` summary reads as "already in sync".
|
|
170
|
+
- **The `build-audit` segment-splitting defect had a second site.** `pathSegments` was normalized above, but `isConfidentMatch` still split candidates on `/` alone, so the non-generic-segment bonus — the signal that keeps a cross-tree re-root like `branding/<name>/content/aboutDialog.css` → `chrome/<area>/content/branding/aboutDialog.css` classified as `stale` — could never fire on Windows, and real staleness was reported as `missing`.
|
|
171
|
+
|
|
172
|
+
On the test side: path expectations and mock keys are rendered through a new `nativePath()` test helper rather than hard-coded POSIX literals; fixture repos pin `core.autocrlf=false` / `core.eol=lf`; the three modules that branch on `process.platform` directly now have that branch forced rather than inherited from the runner; and the doctor summary count is derived rather than pinned at 16, since the orphaned-harness check is `skipIf` win32. The genuinely POSIX-only suites — `tree`, `tree exec`, `run --smoke-exit`, and the `chmod`-as-refusal tests — are skipped explicitly, and each skip is paired with a new platform-stubbed test that pins the win32 refusal itself, which nothing did before.
|
|
173
|
+
|
|
174
|
+
`nativePath()` turned out to be the wrong tool for part of that job, and applying it uniformly produced failures of its own. It swaps separators only, which is correct for a value the code built with `join` but wrong for one it passed through `resolve`: on Windows `resolve` also prefixes the current drive, so the expectation has to be `D:\project\x`, not `\project\x`. A sibling `nativeAbsPath()` covers that case, and the assertions that were wrapped despite never touching a separator at all — pure suffix concatenation, a pass-through argument, and a suite whose `getProjectPaths` mock returns POSIX literals on every platform — go back to comparing the literal. Four doctor summary assertions had also kept their POSIX counts inline while the clean-run count next to them was already derived, so the derivation now covers every summary line in the suite. Two more `chmod`-as-refusal tests joined the skip list: they were skipped for `root` but not for Windows, where mode bits are ignored outright, so the "unreadable" setup was a no-op and the code correctly reported success.
|
|
175
|
+
|
|
120
176
|
### Test infrastructure
|
|
121
177
|
|
|
122
178
|
- **`createLoggerMock()` and `createFsMock()`** in `src/test-utils/module-mocks.ts` replace hand-rolled `vi.mock` factories at 110 sites. `utils/logger.js` was mocked in 118 files by 113 full-replacement factories across 55 distinct key-sets, 34 appearing exactly once; `utils/fs.js` had 89 more. Each enumerated a SUBSET of the real module, so adding one export turned it `undefined` everywhere the factory omitted it — which happened twice while writing this release, 106 files each time.
|
|
@@ -126,6 +182,13 @@ A downstream fork runs this checkout with up to five concurrent agent sessions,
|
|
|
126
182
|
- Five rollback tests moved from asserting a module-level spy to asserting the persisted pending-repair marker. The spy stopped working when the sequence moved into `completeJournalRollback` (its call to the recorder is intra-module), and the outcome is the better assertion anyway.
|
|
127
183
|
- `git-readonly-index.test.ts` carried a latent racy-clean flake: it edited `const b = 2;` to `const b = 3;` — the same byte length, within the same second as the index entry — without invalidating the stat cache, so git could report the file unmodified. Its sibling test already guarded for this.
|
|
128
184
|
|
|
185
|
+
### Documentation
|
|
186
|
+
|
|
187
|
+
- **README.md had become the reference manual.** Its "Regular Workflow" section had grown into fourteen paragraphs of flag semantics — scan manifests, shard behaviour, packaging-coverage anchors, tree CoW, shim configuration — read by nobody starting out and found by nobody looking for a specific rule, because prose in a getting-started page has no index. The reference material moves to `docs/`, split by the thing a reader is actually doing: `patch-workflow.md`, `testing.md`, `furnace.md`, `verification-trees.md`, `configuration.md`, plus `docs/README.md` as the index. README keeps installation, the loop's shape, rebasing, Furnace's premise, the roadmap and the licence — the parts that are project statement rather than manual — and links out for the rest.
|
|
188
|
+
- **`docs/run-logs.md`** states the `.fireforge/logs/` contract that previously existed only as a table cell in `machine-output.md`: raw bytes rather than the echo filter's form, opened before any preflight, retained 20 deep per command kind, best-effort throughout, and the tee sitting at the two mach capture funnels rather than at the commands.
|
|
189
|
+
- Two documents had gone stale against the code. `machine-output.md`'s surface table omitted `status --raw`, which engages the same stdout discipline through the same argv check in `cli.ts` — a machine surface to which rules 3 and 4 apply and rules 1 and 2 do not — and omitted `build`'s direct log announcement, which exists precisely because `build` prints no verdict line to carry a `log=` key. `lifecycle-invariants.md` still described `--wait-lock` as the engine-session lock's budget alone, which stopped being true when the budget was threaded into the furnace lock as well; both lock rows now state the resolution order (`lockTimeoutMs` → `FIREFORGE_WAIT_LOCK` → the 30 s default) and which mutators each covers.
|
|
190
|
+
- `FIREFORGE_WAIT_LOCK` and the three other environment variables FireForge reads were documented only in help text and JSDoc. They are tabulated in `configuration.md` with their defaults and their behaviour on a malformed value.
|
|
191
|
+
|
|
129
192
|
## 0.43.0
|
|
130
193
|
|
|
131
194
|
### Verdict correctness
|
package/README.md
CHANGED
|
@@ -20,10 +20,10 @@ Inspired by [fern.js](https://github.com/ghostery/user-agent-desktop) and [Melon
|
|
|
20
20
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
23
|
-
- Node.js 22.22.
|
|
23
|
+
- Node.js 22.22.2+
|
|
24
24
|
- Python 3
|
|
25
25
|
- Git
|
|
26
|
-
- The normal Firefox platform build tools: Xcode command line tools on macOS, `build-essential`-style packages on Linux, Visual Studio Build Tools on Windows
|
|
26
|
+
- The normal Firefox platform build tools: Xcode command line tools on macOS, `build-essential`-style packages on Linux, Visual Studio Build Tools on Windows.
|
|
27
27
|
- Watchman, if you want `fireforge watch` (optional)
|
|
28
28
|
|
|
29
29
|
## Getting Started
|
|
@@ -55,122 +55,15 @@ After setup you should have a `fireforge.json`, an `engine/` directory containin
|
|
|
55
55
|
npx fireforge status
|
|
56
56
|
npx fireforge export browser/base/content/browser.js --name custom-toolbar --category ui
|
|
57
57
|
npx fireforge re-export custom-toolbar
|
|
58
|
-
npx fireforge re-export --scan --scan-files generated-assets.json --dry-run
|
|
59
58
|
npx fireforge lint --per-patch
|
|
60
|
-
npx fireforge lint --per-patch --max-warnings 0
|
|
61
59
|
npx fireforge verify
|
|
62
|
-
npm run whitespace:check
|
|
63
60
|
npx fireforge build
|
|
64
61
|
npx fireforge test browser/base/content/test/browser/
|
|
65
62
|
```
|
|
66
63
|
|
|
67
|
-
Use `fireforge --help` for the full set of commands
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
their owner patches without broad directory scanning. The manifest is JSON:
|
|
71
|
-
|
|
72
|
-
```json
|
|
73
|
-
{
|
|
74
|
-
"assignments": [
|
|
75
|
-
{ "patch": "002-branding-runtime-icons.patch", "files": ["browser/branding/hominis/icon.svg"] }
|
|
76
|
-
]
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
The command is dry-runnable, refuses ambiguous ownership, and reports each file-to-patch
|
|
81
|
-
assignment before refreshing the patch. For release whitespace checks, use
|
|
82
|
-
`npm run whitespace:check`; it still checks source diffs while excluding generated
|
|
83
|
-
`patches/*.patch` diff syntax.
|
|
84
|
-
|
|
85
|
-
Queue maintenance lives under `fireforge patch`: `patch compact` closes ordinal gaps
|
|
86
|
-
(range-aware when `patchPolicy.ranges` is configured), `patch reorder` moves a patch, and
|
|
87
|
-
`patch split <source> --files <paths...> --name <name>` carves files out of a patch into a
|
|
88
|
-
new one as a single transaction — including staged-dependency owner rewrites — with
|
|
89
|
-
`--dry-run` support. `patch rename` also supports `--category` and `--order`; every
|
|
90
|
-
queue-mutating patch command accepts `--wait-lock [seconds]`.
|
|
91
|
-
|
|
92
|
-
Under several concurrent sessions a held lock is the normal state, not an exceptional
|
|
93
|
-
one, so a wait budget is a property of the session rather than of the invocation: set
|
|
94
|
-
`FIREFORGE_WAIT_LOCK=<seconds>` and every lock-taking command that was not given an
|
|
95
|
-
explicit `--wait-lock` uses it. A wait whose queue position keeps improving is granted a
|
|
96
|
-
fresh budget on each advance (up to four times the requested one, capped at an hour), so
|
|
97
|
-
a long-but-moving queue no longer expires one position from the head; a queue that stops
|
|
98
|
-
moving still starves on the budget you asked for, and the refusal names the position you
|
|
99
|
-
reached.
|
|
100
|
-
|
|
101
|
-
`fireforge test <directory>` runs exactly that directory: FireForge enumerates the
|
|
102
|
-
directory's test files and passes the explicit file list to mach in one invocation, so
|
|
103
|
-
mach's prefix-based path matching cannot silently sweep in sibling directories sharing the
|
|
104
|
-
name prefix (excluded siblings are echoed with their test-file counts). Multiple path
|
|
105
|
-
arguments run as sequential shards by default — one browser instance per argument, with a
|
|
106
|
-
directory argument keeping its files together — announced with a notice, since isolated
|
|
107
|
-
instances do not exercise cross-argument state (`--no-shard` restores one combined
|
|
108
|
-
invocation). Recognized harness crashes retry up to `--harness-retries <n>` times (default
|
|
109
|
-
2), and `--perf-samples <path>` publishes a perf-sample artifact path to the harness
|
|
110
|
-
(exported as `<BINARYNAME>_PERF_SAMPLE_JSON`).
|
|
111
|
-
Every test run ends with one machine-readable `FIREFORGE-VERDICT: PASS|FAIL ...` line;
|
|
112
|
-
automation should use it instead of the raw process code and treat a missing verdict as
|
|
113
|
-
failure.
|
|
114
|
-
Pathless test runs must choose a mode: `--auto` forwards mach's own auto-selection,
|
|
115
|
-
`--doctor` runs the Marionette preflight only, and `--canary [path]` runs one short
|
|
116
|
-
browser-chrome canary (`test.canaryPath` / `test.canaryTimeoutSeconds` in `fireforge.json`
|
|
117
|
-
provide defaults). When packageable engine files changed since the last successful
|
|
118
|
-
FireForge build, `test` fails before launching stale artifacts; use `--build` to refresh
|
|
119
|
-
or `--allow-stale-build` only for intentional out-of-band rebuilds. `--build-only`
|
|
120
|
-
packages mixed-harness paths once; `--extend-coverage` safely retains prior scoped
|
|
121
|
-
coverage. `--kill-stale-marionette` terminates recognized stale browsers. In dev builds,
|
|
122
|
-
files under `obj-*/dist/bin`
|
|
123
|
-
may be symlinks back into the source tree (notably prefs), so edit source prefs directly
|
|
124
|
-
and keep a backup before bisection experiments.
|
|
125
|
-
|
|
126
|
-
Patch names are normalized on export/rename: `window-chrome-tests`,
|
|
127
|
-
`ui-window-chrome-tests`, and `235-ui-window-chrome-tests` all resolve to the same
|
|
128
|
-
`<order>-ui-window-chrome-tests.patch` shape. For focused queue checks,
|
|
129
|
-
`lint --per-patch --patches` accepts repeated flags, comma lists, full filenames/stems,
|
|
130
|
-
manifest names, category-prefixed slugs, and bare slugs.
|
|
131
|
-
|
|
132
|
-
Projects with generated asset prerequisites can declare opt-in `externalToolchains` in
|
|
133
|
-
`fireforge.json`; `fireforge doctor` reports missing required tools as errors and missing
|
|
134
|
-
optional tools as warnings. Seasonal branding on macOS, for example, can declare Apple's
|
|
135
|
-
Icon Composer tool at
|
|
136
|
-
`/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool`,
|
|
137
|
-
plus `actool` via `xcrun`, `sips`, and `iconutil`.
|
|
138
|
-
Design tokens are managed with `fireforge token add`; pass `--create-category` to declare a
|
|
139
|
-
new category banner and insert the token in one step.
|
|
140
|
-
|
|
141
|
-
For unattended smoke checks, `fireforge run --smoke-exit <s> --headless` launches the
|
|
142
|
-
browser headless — a headed smoke window on a shared desktop absorbs live input, which
|
|
143
|
-
contaminates the console capture (headed non-CI launches print a warning saying so).
|
|
144
|
-
|
|
145
|
-
Per-patch lint type-checks plain Firefox JS against a built-in Firefox-globals shim that
|
|
146
|
-
tracks upstream WebIDL additions per release; a project's `patchLint.checkJsExtraShim` can
|
|
147
|
-
add members to the structured shim globals via TypeScript interface merging (e.g.
|
|
148
|
-
`interface ChromeUtilsShim { newApi(): any }`). Opting into
|
|
149
|
-
`patchLint.checkJsTestFiles: true` extends the pass to patch-owned test `.js` files
|
|
150
|
-
(`browser_*`/`test_*`/`xpcshell_*` and `/test/` paths), each checked as its own small
|
|
151
|
-
script-scope program against a loose built-in harness shim; `patchLint.checkJsTestShim`
|
|
152
|
-
points at a project `.d.ts` whose typed declarations (e.g. a real `TestUtils` interface)
|
|
153
|
-
override the loose baseline so calls to nonexistent harness members fail at export time.
|
|
154
|
-
|
|
155
|
-
For CI enforcement, `fireforge status --check` exits non-zero when any unmanaged,
|
|
156
|
-
drifted, or conflicted file exists (`--fail-on <class,...>` tunes the policy set, and
|
|
157
|
-
`--json` composes — the JSON stays parseable and its `files[]` entries name the owning
|
|
158
|
-
`patch`). `--include-ownership` adds ownership rows to JSON status output. Scan-less
|
|
159
|
-
`re-export` previews drift; `--refuse-foreign-drift --expect <path>` restricts it to
|
|
160
|
-
intended files. `lint --per-patch --report <path>` writes a machine-readable JSON report with
|
|
161
|
-
each patch's line count, tier, active size thresholds, issues, and lintIgnore-suppressed
|
|
162
|
-
issues; the size metrics (`countNonBinaryDiffLines`, `resolvePatchSizeTier`,
|
|
163
|
-
`getPatchSizeThresholds`) are also exported on the programmatic API.
|
|
164
|
-
|
|
165
|
-
For concurrent read-mostly verification beside a busy primary checkout,
|
|
166
|
-
`fireforge tree create <name>` snapshots the project — applied engine tree included,
|
|
167
|
-
`obj-*` excluded — into `.fireforge/trees/<name>` using filesystem copy-on-write (APFS
|
|
168
|
-
clonefile / btrfs-XFS reflink; ~3 s for a 156 MB applied browser tree). Read-only commands
|
|
169
|
-
and export/re-export dry runs work inside a tree; mutations are refused. `--with-objdir`
|
|
170
|
-
adds a safely relocated build for build-less tests. `tree list` reports staleness,
|
|
171
|
-
`tree remove` deletes (refusing active locks), and `tree exec <name> -- <cmd>` runs a
|
|
172
|
-
command inside a tree. Filesystems without CoW support refuse unless `--force-copy`
|
|
173
|
-
explicitly accepts a full physical copy.
|
|
64
|
+
Use `fireforge --help` for the full set of commands, and
|
|
65
|
+
[`docs/patch-workflow.md`](docs/patch-workflow.md) for what the loop actually
|
|
66
|
+
guarantees.
|
|
174
67
|
|
|
175
68
|
## Rebasing Firefox Source
|
|
176
69
|
|
|
@@ -197,16 +90,22 @@ npx fireforge furnace status
|
|
|
197
90
|
npx fireforge furnace preview
|
|
198
91
|
```
|
|
199
92
|
|
|
200
|
-
Use `fireforge furnace --help` for the full set of component commands
|
|
93
|
+
Use `fireforge furnace --help` for the full set of component commands, and
|
|
94
|
+
[`docs/furnace.md`](docs/furnace.md) for shared CSS fragments and typed
|
|
95
|
+
cross-module imports.
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
[`docs/`](docs/README.md) holds the reference material:
|
|
201
100
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
101
|
+
- [Patch workflow](docs/patch-workflow.md) — export, re-export, queue maintenance, per-patch lint, CI gating
|
|
102
|
+
- [Testing](docs/testing.md) — `fireforge test` scope, modes, build freshness, the verdict line
|
|
103
|
+
- [Furnace](docs/furnace.md) — components, shared CSS fragments, typed imports
|
|
104
|
+
- [Verification trees](docs/verification-trees.md) — CoW snapshots for concurrent verification
|
|
105
|
+
- [Configuration](docs/configuration.md) — `fireforge.json`, design tokens, lock waits, environment variables
|
|
106
|
+
- [Exit codes](docs/exit-codes.md) and [machine-readable output](docs/machine-output.md) — the contract for scripts and CI
|
|
107
|
+
- [Run logs](docs/run-logs.md) — what `test` and `build` leave behind
|
|
108
|
+
- [Lifecycle invariants](docs/lifecycle-invariants.md) — locking, rollback and signal handling internals
|
|
210
109
|
|
|
211
110
|
## Roadmap
|
|
212
111
|
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.44.
|
|
4
|
-
"commit": "
|
|
5
|
-
"shortCommit": "
|
|
3
|
+
"version": "0.44.2",
|
|
4
|
+
"commit": "d653751d6c8a1be154647943dc346e81cc62b390",
|
|
5
|
+
"shortCommit": "d653751d6c8a",
|
|
6
6
|
"dirty": false,
|
|
7
7
|
"dirtyHash": null,
|
|
8
|
-
"buildTime": "2026-08-
|
|
8
|
+
"buildTime": "2026-08-28T13:39:36.337Z"
|
|
9
9
|
}
|
|
@@ -8,6 +8,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
|
8
8
|
import { assertEngineExists } from '../core/engine-precondition.js';
|
|
9
9
|
import { withEngineSessionLock } from '../core/engine-session-lock.js';
|
|
10
10
|
import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, runMach, withBuildLock, } from '../core/mach.js';
|
|
11
|
+
import { closeActiveRunLog, getActiveRunLogPath, openRunLog, setActiveRunLog, } from '../core/run-log.js';
|
|
11
12
|
import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
|
|
12
13
|
import { formatToolchainMismatchMessage, runToolchainPreflight, } from '../core/toolchain-preflight.js';
|
|
13
14
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
@@ -170,6 +171,22 @@ export async function buildCommand(projectRoot, options) {
|
|
|
170
171
|
const buildType = options.ui ? 'UI-only' : 'Full';
|
|
171
172
|
const brandInfo = options.brand ? ` [${options.brand}]` : '';
|
|
172
173
|
intro(`FireForge Build (${buildType}${brandInfo})`);
|
|
174
|
+
// A build's own complete output, kept independently of whatever the
|
|
175
|
+
// operator piped the run through. `build` prints no FIREFORGE-VERDICT
|
|
176
|
+
// line, so the path is announced directly at the end of the run.
|
|
177
|
+
setActiveRunLog(await openRunLog(projectRoot, 'build'));
|
|
178
|
+
try {
|
|
179
|
+
await runBuildCommandBody(projectRoot, options);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
const logPath = getActiveRunLogPath();
|
|
183
|
+
await closeActiveRunLog();
|
|
184
|
+
if (logPath !== undefined)
|
|
185
|
+
info(`Full build output: ${logPath}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function runBuildCommandBody(projectRoot, options) {
|
|
189
|
+
const buildType = options.ui ? 'UI-only' : 'Full';
|
|
173
190
|
// Load configuration
|
|
174
191
|
const config = await loadConfig(projectRoot);
|
|
175
192
|
const paths = getProjectPaths(projectRoot);
|
|
@@ -40,6 +40,15 @@ export interface DoctorCheckContext {
|
|
|
40
40
|
* re-parse the file; undefined when the config could not be loaded.
|
|
41
41
|
*/
|
|
42
42
|
furnaceConfig: FurnaceConfig | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* State this run actually mutated, one human-readable line per write.
|
|
45
|
+
* Repairs run inside the check loop while the exit code is computed only
|
|
46
|
+
* after every check, so a repair can land and the run still exit non-zero
|
|
47
|
+
* on an unrelated check — and a non-zero exit reads as "nothing happened".
|
|
48
|
+
* The runner prints these before the summary in every branch so a write is
|
|
49
|
+
* never invisible.
|
|
50
|
+
*/
|
|
51
|
+
mutations: string[];
|
|
43
52
|
}
|
|
44
53
|
/**
|
|
45
54
|
* Result a check may return. A single object is the common case; an array
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The patch-manifest consistency check and its two repair paths, split out of
|
|
3
|
+
* `doctor.ts` for the line budget. Exports a single `DoctorCheckDefinition`
|
|
4
|
+
* consumed by the doctor registry; no registrar is exported and none is wanted.
|
|
5
|
+
*/
|
|
6
|
+
import type { DoctorCheckDefinition } from './doctor-check-core.js';
|
|
7
|
+
/** Patch manifest consistency check, with its opt-in repair paths. */
|
|
8
|
+
export declare const PATCH_MANIFEST_CONSISTENCY_CHECK: DoctorCheckDefinition;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* The patch-manifest consistency check and its two repair paths, split out of
|
|
4
|
+
* `doctor.ts` for the line budget. Exports a single `DoctorCheckDefinition`
|
|
5
|
+
* consumed by the doctor registry; no registrar is exported and none is wanted.
|
|
6
|
+
*/
|
|
7
|
+
import { rebuildPatchesManifest, recommendManifestRepair, repairPatchesFilesAffected, validatePatchesManifestConsistency, } from '../core/patch-manifest.js';
|
|
8
|
+
import { FireForgeError } from '../errors/base.js';
|
|
9
|
+
import { toError } from '../utils/errors.js';
|
|
10
|
+
import { pathExists } from '../utils/fs.js';
|
|
11
|
+
import { info, warn } from '../utils/logger.js';
|
|
12
|
+
import { resolveWaitLockSeconds } from '../utils/options.js';
|
|
13
|
+
import { failure, ok, warning } from './doctor-check-core.js';
|
|
14
|
+
const CHECK_NAME = 'Patch manifest consistency';
|
|
15
|
+
/** Runs the narrow `filesAffected` repair over the drifted rows. */
|
|
16
|
+
async function runFilesAffectedRepair(ctx, issues) {
|
|
17
|
+
const drifted = issues.filter((issue) => issue.code === 'files-affected-mismatch');
|
|
18
|
+
const otherCodes = [
|
|
19
|
+
...new Set(issues.filter((issue) => issue.code !== 'files-affected-mismatch').map((issue) => issue.code)),
|
|
20
|
+
].sort((left, right) => left.localeCompare(right));
|
|
21
|
+
if (drifted.length === 0) {
|
|
22
|
+
return failure(CHECK_NAME, `--repair-files-affected has nothing to repair: no filesAffected drift, but ${issues.length} ` +
|
|
23
|
+
`other issue(s) remain (${otherCodes.join(', ')}).`, recommendManifestRepair(issues));
|
|
24
|
+
}
|
|
25
|
+
const dryRun = ctx.options.dryRun === true;
|
|
26
|
+
const result = await repairPatchesFilesAffected(ctx.paths.patches, drifted.map((issue) => issue.filename), {
|
|
27
|
+
waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
|
|
28
|
+
command: 'doctor --repair-files-affected',
|
|
29
|
+
dryRun,
|
|
30
|
+
});
|
|
31
|
+
for (const repair of result.repairs) {
|
|
32
|
+
info(` ${describeFilesAffectedRepair(repair, dryRun)}`);
|
|
33
|
+
}
|
|
34
|
+
for (const filename of result.skippedFilenames) {
|
|
35
|
+
warn(`Skipped ${filename}: it has no manifest row, or no patch file on disk. ` +
|
|
36
|
+
'That is a different consistency issue — re-run doctor without --repair-files-affected ' +
|
|
37
|
+
'to see it reported.');
|
|
38
|
+
}
|
|
39
|
+
if (result.written) {
|
|
40
|
+
ctx.mutations.push(`patches/patches.json — filesAffected corrected on ${describeCount(result.repairs.length)} (--repair-files-affected)`);
|
|
41
|
+
}
|
|
42
|
+
const remaining = otherCodes.length > 0
|
|
43
|
+
? ` ${otherCodes.length} other issue type(s) remain: ${otherCodes.join(', ')}.`
|
|
44
|
+
: '';
|
|
45
|
+
if (otherCodes.length > 0) {
|
|
46
|
+
return failure(CHECK_NAME, `${summarizeFilesAffected(result.repairs.length, dryRun)}${remaining}`, recommendManifestRepair(issues.filter((issue) => issue.code !== 'files-affected-mismatch')));
|
|
47
|
+
}
|
|
48
|
+
return warning(CHECK_NAME, summarizeFilesAffected(result.repairs.length, dryRun));
|
|
49
|
+
}
|
|
50
|
+
function describeFilesAffectedRepair(repair, dryRun) {
|
|
51
|
+
const verb = dryRun ? 'would set' : 'set';
|
|
52
|
+
return `${repair.filename}: ${verb} filesAffected to ${repair.after.length} file(s) (was ${repair.before.length}).`;
|
|
53
|
+
}
|
|
54
|
+
function summarizeFilesAffected(count, dryRun) {
|
|
55
|
+
if (count === 0)
|
|
56
|
+
return 'No filesAffected drift to repair.';
|
|
57
|
+
return dryRun
|
|
58
|
+
? `Dry run — would correct filesAffected on ${describeCount(count)}. Nothing was written.`
|
|
59
|
+
: `Corrected filesAffected on ${describeCount(count)}. No other manifest field was touched.`;
|
|
60
|
+
}
|
|
61
|
+
function describeCount(count) {
|
|
62
|
+
return `${count} patch${count === 1 ? '' : 'es'}`;
|
|
63
|
+
}
|
|
64
|
+
/** Runs the whole-manifest rebuild. */
|
|
65
|
+
async function runManifestRebuild(ctx) {
|
|
66
|
+
// Repair stamps sourceEsrVersion into every recovered entry. If the
|
|
67
|
+
// earlier "fireforge.json is valid" check failed, ctx.config is
|
|
68
|
+
// undefined and we must refuse rather than fabricate a fallback —
|
|
69
|
+
// persisting 'unknown' into manifest metadata is hard to reverse
|
|
70
|
+
// and would mislead every later command that reads it.
|
|
71
|
+
if (!ctx.config) {
|
|
72
|
+
return failure(CHECK_NAME, 'Cannot repair patches.json: fireforge.json could not be loaded, so the Firefox version to stamp into recovered manifest entries is unknown.', 'Fix the fireforge.json errors reported above and re-run "fireforge doctor --repair-patches-manifest". ' +
|
|
73
|
+
'If the drift is only in filesAffected, "fireforge doctor --repair-files-affected" needs no config.');
|
|
74
|
+
}
|
|
75
|
+
const dryRun = ctx.options.dryRun === true;
|
|
76
|
+
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version, {
|
|
77
|
+
waitLockSeconds: resolveWaitLockSeconds(ctx.options.waitLock),
|
|
78
|
+
command: 'doctor --repair-patches-manifest',
|
|
79
|
+
dryRun,
|
|
80
|
+
...(ctx.options.allowMetadataLoss === true ? { allowMetadataLoss: true } : {}),
|
|
81
|
+
});
|
|
82
|
+
// The repair path must not silently overwrite human-written
|
|
83
|
+
// descriptions on recovered entries, which leaves the queue less
|
|
84
|
+
// trustworthy as an audit trail. The rebuilder returns the list of
|
|
85
|
+
// filenames whose metadata was entirely invented, named explicitly
|
|
86
|
+
// here so the operator knows which patches to review. Entries that
|
|
87
|
+
// WERE preserved (only `filesAffected` / ordering drifted) are not
|
|
88
|
+
// flagged.
|
|
89
|
+
for (const filename of repaired.recoveredFilenames) {
|
|
90
|
+
// Telling the operator to hand-edit patches.json contradicts the
|
|
91
|
+
// README and downstream docs, which treat the manifest as
|
|
92
|
+
// FireForge-owned. Point at the existing `re-export` / `export`
|
|
93
|
+
// workflow so the fix stays inside the tool: re-exporting the
|
|
94
|
+
// same files with an explicit `--description` overwrites the
|
|
95
|
+
// recovered entry with operator-supplied metadata and supersedes
|
|
96
|
+
// the mtime-based createdAt stamp.
|
|
97
|
+
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
|
|
98
|
+
'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
|
|
99
|
+
'(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
|
|
100
|
+
'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
|
|
101
|
+
'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
|
|
102
|
+
}
|
|
103
|
+
// A row whose patch file is gone is dropped by the rebuild. That is
|
|
104
|
+
// correct — the patch file is the source of truth — but a manifest that
|
|
105
|
+
// quietly loses rows is exactly what makes a repair hard to audit
|
|
106
|
+
// afterwards, so each one is named.
|
|
107
|
+
for (const filename of repaired.droppedFilenames) {
|
|
108
|
+
warn(`Dropped the manifest entry for ${filename}: no such patch file on disk. ` +
|
|
109
|
+
'Restore the .patch file and re-run the repair if the entry should have survived.');
|
|
110
|
+
}
|
|
111
|
+
if (repaired.written) {
|
|
112
|
+
ctx.mutations.push('patches/patches.json — rebuilt from the on-disk patch files (--repair-patches-manifest)');
|
|
113
|
+
}
|
|
114
|
+
const patchCount = repaired.manifest.patches.length;
|
|
115
|
+
const recovered = repaired.recoveredFilenames.length > 0
|
|
116
|
+
? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)`
|
|
117
|
+
: '';
|
|
118
|
+
return warning(CHECK_NAME, dryRun
|
|
119
|
+
? `Dry run — would rebuild patches.json from ${describeCount(patchCount)}${recovered}. Nothing was written.`
|
|
120
|
+
: `Rebuilt patches.json from ${describeCount(patchCount)}${recovered}. Review recovered metadata before release.`);
|
|
121
|
+
}
|
|
122
|
+
/** Patch manifest consistency check, with its opt-in repair paths. */
|
|
123
|
+
export const PATCH_MANIFEST_CONSISTENCY_CHECK = {
|
|
124
|
+
name: CHECK_NAME,
|
|
125
|
+
dependsOn: ['fireforge.json is valid'],
|
|
126
|
+
run: async (ctx) => {
|
|
127
|
+
if (!(await pathExists(ctx.paths.patches))) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const issues = await validatePatchesManifestConsistency(ctx.paths.patches);
|
|
131
|
+
if (issues.length === 0) {
|
|
132
|
+
return ok(CHECK_NAME);
|
|
133
|
+
}
|
|
134
|
+
const wantsFilesAffected = ctx.options.repairFilesAffected === true;
|
|
135
|
+
const wantsRebuild = ctx.options.repairPatchesManifest === true;
|
|
136
|
+
if (!wantsFilesAffected && !wantsRebuild) {
|
|
137
|
+
return failure(CHECK_NAME, issues.map((issue) => issue.message).join(' '), recommendManifestRepair(issues));
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return wantsFilesAffected
|
|
141
|
+
? await runFilesAffectedRepair(ctx, issues)
|
|
142
|
+
: await runManifestRebuild(ctx);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
const error = toError(err);
|
|
146
|
+
// A refusal from the repair itself already carries the remedy that
|
|
147
|
+
// fits it (which flag, which file to fix). Appending the generic hint
|
|
148
|
+
// under it buries the specific one and reads as a second, vaguer
|
|
149
|
+
// instruction. Only an unexpected throw gets the generic hint.
|
|
150
|
+
return error instanceof FireForgeError
|
|
151
|
+
? failure(CHECK_NAME, error.message)
|
|
152
|
+
: failure(CHECK_NAME, error.message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
};
|
|
@@ -29,7 +29,7 @@ export declare const DOCTOR_CHECK_ORDER: readonly string[];
|
|
|
29
29
|
* @param checks - The check results to display
|
|
30
30
|
* @returns The exit code reflecting the overall result
|
|
31
31
|
*/
|
|
32
|
-
export declare function reportDoctorResults(checks: DoctorCheck[]): ExitCode;
|
|
32
|
+
export declare function reportDoctorResults(checks: DoctorCheck[], mutations?: readonly string[]): ExitCode;
|
|
33
33
|
/**
|
|
34
34
|
* Result of the doctor command, carrying the exit code so the caller
|
|
35
35
|
* (or test) can inspect it without relying on process.exitCode.
|