@hominis/fireforge 0.44.1 → 0.44.3
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 +42 -0
- package/README.md +18 -110
- package/dist/build-info.json +4 -4
- package/dist/src/commands/build.js +17 -0
- package/dist/src/commands/furnace/apply.js +2 -2
- package/dist/src/commands/furnace/deploy.js +2 -2
- package/dist/src/commands/furnace/index.js +12 -6
- package/dist/src/commands/furnace/init.js +5 -2
- package/dist/src/commands/furnace/sync.js +2 -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 +36 -12
- package/dist/src/core/build-audit.js +6 -2
- 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/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/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-manager.d.ts +7 -2
- package/dist/src/core/token-manager.js +74 -20
- package/dist/src/core/token-variant.d.ts +46 -2
- package/dist/src/core/token-variant.js +190 -33
- package/dist/src/types/commands/options.d.ts +30 -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/docs/README.md +35 -0
- package/docs/configuration.md +116 -0
- package/docs/exit-codes.md +63 -0
- package/docs/furnace.md +57 -0
- package/docs/lifecycle-invariants.md +309 -0
- package/docs/machine-output.md +94 -0
- package/docs/patch-workflow.md +119 -0
- package/docs/run-logs.md +60 -0
- package/docs/testing.md +92 -0
- package/docs/verification-trees.md +48 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
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 @@
|
|
|
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,17 @@
|
|
|
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
|
+
|
|
66
90
|
### Security
|
|
67
91
|
|
|
68
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:
|
|
@@ -125,6 +149,9 @@
|
|
|
125
149
|
- **`fireforge test --doctor` printed the same preflight line three times** on a terminal: a 2026-04-24 fix added an emission for non-TTY capture without removing the two it superseded. The raw writes are gated on non-TTY, which is where the capture requirement applies; the clack framing stays for terminals.
|
|
126
150
|
- **All seven** `--wait-lock` commands that take the patch-directory lock now honour it: `patch tier`, `patch lint-ignore`, `patch staged-dependency`, `export-all`, `resolve`, `doctor` and `rebase`. Each routes through a different lock entry point, so the budget is threaded through `updatePatchMetadata`, `mutatePatchMetadata`, `commitExportedPatch`, `updatePatchAndMetadata`, `rebuildPatchesManifest` and the rebase patch loop.
|
|
127
151
|
- `status --json` outside a project wrote a clack-styled block to **stdout** and exited 2, because the config error is raised before the command body engages machine mode. The boundary now engages it from the flag, so stdout carries exactly one JSON envelope and the human guidance goes to stderr — rule 3 of `docs/machine-output.md`, which was aspirational until now. A payload that already owns stdout (`status --json --fail-on`) suppresses the envelope, so the "exactly one document" half of the contract holds too.
|
|
152
|
+
- **`--variant` refused selectors the matcher had just learned to find.** 0.44.2 widened `rootAttributeSelector` to consume consecutive `[…]` groups and to stop at a pseudo-class tail, so `:root[data-skin="humanist"][data-theme="dark"]` and `:root[data-theme="light"]:not([data-private])` became blocks FireForge can locate and write through. The CLI validator did not move with it: its own regex still accepted exactly one `[attr]` fragment, so `token add --variant '[data-skin=humanist]:not([data-private])'` was refused as "must be a single attribute selector" — a block the tool can FIND was one it would not let you NAME, and the private-exclusion selectors the widening exists for are precisely that shape. Downstream the values were maintained by hand in the allowlisted block instead.
|
|
153
|
+
The validator now runs the MATCHER'S parse and requires it to consume the whole fragment, rather than carrying a second grammar that can drift from the first again. Character safety stays a separate check, because that parse finds bracket boundaries and validates no characters at all — it is applied one bracket group at a time rather than as a repeated pattern, which is the shape that backtracks (`js/polynomial-redos`), and a linearity test pins that alongside the matcher's own. Normalization to `="value"` now covers every fragment in a run and the `:not(…)` argument, not just a trailing one.
|
|
154
|
+
`findVariantBlock` had to move with it: it compared a request against a block's ATTRIBUTE run only, so a variant carrying its own qualifier would have matched nothing and spliced a fresh duplicate block on every add. Request and candidate go through the same parse and are compared tail-to-tail. An UNqualified request still matches a qualified block — that is the 0.44.2 behaviour `variantBlockQualifier` exists to report out loud, since silently skipping the block is the worse failure — but a request that names a qualifier means it.
|
|
128
155
|
- `tree list --json` gained the failure envelope `status --json` already emitted, so a scripted consumer no longer gets a parseable refusal from one and a bare non-zero exit from the other. The contract is written down in `docs/machine-output.md`.
|
|
129
156
|
|
|
130
157
|
### More correctness
|
|
@@ -141,9 +168,14 @@
|
|
|
141
168
|
- **`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.
|
|
142
169
|
- **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.
|
|
143
170
|
- **`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.
|
|
171
|
+
- **`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.
|
|
172
|
+
- **`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".
|
|
173
|
+
- **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`.
|
|
144
174
|
|
|
145
175
|
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.
|
|
146
176
|
|
|
177
|
+
`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.
|
|
178
|
+
|
|
147
179
|
### Test infrastructure
|
|
148
180
|
|
|
149
181
|
- **`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.
|
|
@@ -151,8 +183,18 @@ On the test side: path expectations and mock keys are rendered through a new `na
|
|
|
151
183
|
- **`test.test.ts` was 2,943 lines under one `describe`.** What made it unsplittable was its own 190-line `vi.mock` header: any split either duplicated the header into each new file or did not happen. The header moved to `test-command-mocks.ts` as one factory per mocked module, called from inside each file's own `vi.mock` callback — hoisting is unaffected and every suite still gets fresh `vi.fn()`s rather than shared call history. The file is now six, largest 952 lines, split on the seams the tests already formed: discovery and failure rewriting, staleness and packaging coverage, xpcshell/mochitest hints, Marionette forwarding, harness resilience, verdict contract.
|
|
152
184
|
The split exposed a real test-isolation bug: `keeps the Marionette preflight for xpcshell-only --doctor runs` had never set a preflight result, and passed only because an `it` roughly 1,000 lines earlier had left one on the shared mock with `mockResolvedValue`. It sets its own now. Coverage of `src/commands/test.ts` is byte-identical before and after the split (95.12 / 94.28 / 100 / 86.23), which is the check that the mock rewiring did not quietly stop exercising anything.
|
|
153
185
|
- 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.
|
|
186
|
+
- **Fourteen verdict assertions passed only because writing to a filesystem root fails.** The command suites pass `/project` as the project root and assert the `FIREFORGE-VERDICT:` line by exact string — which holds only while no run log is open, since the line carries an open log's path as a ` log=<path>` key. On POSIX `mkdir('/project/.fireforge/logs')` fails at the root, the best-effort open degrades to "no log", and the assertions passed for a reason none of them stated. On Windows the same path resolves against the CURRENT DRIVE, the mkdir succeeds, and all fourteen failed — while the run also created real `C:\project\.fireforge\logs` directories on the runner. `createRunLogMock()` joins the two factories in `module-mocks.ts` and the five suites state the precondition instead of inheriting it from a permission error. Because mocking the module away removes the suffix's only coverage, `test-verdict.test.ts` gains the opposite assertion: with a log open, the path rides the line, once, as the run's last stdout write.
|
|
187
|
+
- **A lock-wait test decided its result on a `readdir`.** `a wait whose queue position keeps improving > survives a drain longer than its budget` runs on real timers and a real filesystem, and gave each probe about 20 ms of slack inside a 100 ms budget; it also retired its simulated queue with un-awaited `rm` calls, so a retirement could land after the probe meant to observe it and a release could leave `mkdir` seeing `EEXIST` past the intended free. On a Windows runner the second probe missed the deadline, no extension was ever granted, and the wait expired still three from the head — the exact refusal the test exists to prove does NOT happen. The retirement and release are synchronous now (the callback returns `void` and cannot await), and the constants are scaled to ~100 ms of slack per probe while keeping the two invariants that fix them: the budget must span two probes, and the drain must outlast the budget or the sibling test would acquire instead of expiring. No production change — `withFileLock` checking its deadline before probing is correct, since a wait must not extend itself on a probe taken after it expired.
|
|
154
188
|
- `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.
|
|
155
189
|
|
|
190
|
+
### Documentation
|
|
191
|
+
|
|
192
|
+
- **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.
|
|
193
|
+
- **`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.
|
|
194
|
+
- 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.
|
|
195
|
+
- **The package did not ship the `docs/` tree its own README links to.** The `files` allowlist was `dist/`, `templates/` and the three root markdown files, so `node_modules/@hominis/fireforge/docs` did not exist and all eleven relative `docs/…` links dead-ended for a consumer — against a doctrine that names the INSTALLED package as the authority, precisely so an agent never has to leave its repo to answer a tool question. The links resolved on GitHub and on the registry page, which rewrites them, which is why nothing caught it. `docs/` is now published (~100 KB of markdown against a package that already ships `dist/`), and `wrapper-smoke.test.ts` asserts the tree is both packed and present after install, the way it already did for `templates/` — so a future `files` edit that drops it fails the gate rather than the reader.
|
|
196
|
+
- `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.
|
|
197
|
+
|
|
156
198
|
## 0.43.0
|
|
157
199
|
|
|
158
200
|
### Verdict correctness
|
package/README.md
CHANGED
|
@@ -55,113 +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
|
-
`fireforge test <directory>` runs exactly that directory: FireForge enumerates the
|
|
93
|
-
directory's test files and passes the explicit file list to mach in one invocation, so
|
|
94
|
-
mach's prefix-based path matching cannot silently sweep in sibling directories sharing the
|
|
95
|
-
name prefix (excluded siblings are echoed with their test-file counts). Multiple path
|
|
96
|
-
arguments run as sequential shards by default — one browser instance per argument, with a
|
|
97
|
-
directory argument keeping its files together — announced with a notice, since isolated
|
|
98
|
-
instances do not exercise cross-argument state (`--no-shard` restores one combined
|
|
99
|
-
invocation). Recognized harness crashes retry up to `--harness-retries <n>` times (default
|
|
100
|
-
2), and `--perf-samples <path>` publishes a perf-sample artifact path to the harness
|
|
101
|
-
(exported as `<BINARYNAME>_PERF_SAMPLE_JSON`).
|
|
102
|
-
Every test run ends with one machine-readable `FIREFORGE-VERDICT: PASS|FAIL ...` line;
|
|
103
|
-
automation should use it instead of the raw process code and treat a missing verdict as
|
|
104
|
-
failure.
|
|
105
|
-
Pathless test runs must choose a mode: `--auto` forwards mach's own auto-selection,
|
|
106
|
-
`--doctor` runs the Marionette preflight only, and `--canary [path]` runs one short
|
|
107
|
-
browser-chrome canary (`test.canaryPath` / `test.canaryTimeoutSeconds` in `fireforge.json`
|
|
108
|
-
provide defaults). When packageable engine files changed since the last successful
|
|
109
|
-
FireForge build, `test` fails before launching stale artifacts; use `--build` to refresh
|
|
110
|
-
or `--allow-stale-build` only for intentional out-of-band rebuilds. `--build-only`
|
|
111
|
-
packages mixed-harness paths once; `--extend-coverage` safely retains prior scoped
|
|
112
|
-
coverage. `--kill-stale-marionette` terminates recognized stale browsers. In dev builds,
|
|
113
|
-
files under `obj-*/dist/bin`
|
|
114
|
-
may be symlinks back into the source tree (notably prefs), so edit source prefs directly
|
|
115
|
-
and keep a backup before bisection experiments.
|
|
116
|
-
|
|
117
|
-
Patch names are normalized on export/rename: `window-chrome-tests`,
|
|
118
|
-
`ui-window-chrome-tests`, and `235-ui-window-chrome-tests` all resolve to the same
|
|
119
|
-
`<order>-ui-window-chrome-tests.patch` shape. For focused queue checks,
|
|
120
|
-
`lint --per-patch --patches` accepts repeated flags, comma lists, full filenames/stems,
|
|
121
|
-
manifest names, category-prefixed slugs, and bare slugs.
|
|
122
|
-
|
|
123
|
-
Projects with generated asset prerequisites can declare opt-in `externalToolchains` in
|
|
124
|
-
`fireforge.json`; `fireforge doctor` reports missing required tools as errors and missing
|
|
125
|
-
optional tools as warnings. Seasonal branding on macOS, for example, can declare Apple's
|
|
126
|
-
Icon Composer tool at
|
|
127
|
-
`/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool`,
|
|
128
|
-
plus `actool` via `xcrun`, `sips`, and `iconutil`.
|
|
129
|
-
Design tokens are managed with `fireforge token add`; pass `--create-category` to declare a
|
|
130
|
-
new category banner and insert the token in one step.
|
|
131
|
-
|
|
132
|
-
For unattended smoke checks, `fireforge run --smoke-exit <s> --headless` launches the
|
|
133
|
-
browser headless — a headed smoke window on a shared desktop absorbs live input, which
|
|
134
|
-
contaminates the console capture (headed non-CI launches print a warning saying so).
|
|
135
|
-
|
|
136
|
-
Per-patch lint type-checks plain Firefox JS against a built-in Firefox-globals shim that
|
|
137
|
-
tracks upstream WebIDL additions per release; a project's `patchLint.checkJsExtraShim` can
|
|
138
|
-
add members to the structured shim globals via TypeScript interface merging (e.g.
|
|
139
|
-
`interface ChromeUtilsShim { newApi(): any }`). Opting into
|
|
140
|
-
`patchLint.checkJsTestFiles: true` extends the pass to patch-owned test `.js` files
|
|
141
|
-
(`browser_*`/`test_*`/`xpcshell_*` and `/test/` paths), each checked as its own small
|
|
142
|
-
script-scope program against a loose built-in harness shim; `patchLint.checkJsTestShim`
|
|
143
|
-
points at a project `.d.ts` whose typed declarations (e.g. a real `TestUtils` interface)
|
|
144
|
-
override the loose baseline so calls to nonexistent harness members fail at export time.
|
|
145
|
-
|
|
146
|
-
For CI enforcement, `fireforge status --check` exits non-zero when any unmanaged,
|
|
147
|
-
drifted, or conflicted file exists (`--fail-on <class,...>` tunes the policy set, and
|
|
148
|
-
`--json` composes — the JSON stays parseable and its `files[]` entries name the owning
|
|
149
|
-
`patch`). `--include-ownership` adds ownership rows to JSON status output. Scan-less
|
|
150
|
-
`re-export` previews drift; `--refuse-foreign-drift --expect <path>` restricts it to
|
|
151
|
-
intended files. `lint --per-patch --report <path>` writes a machine-readable JSON report with
|
|
152
|
-
each patch's line count, tier, active size thresholds, issues, and lintIgnore-suppressed
|
|
153
|
-
issues; the size metrics (`countNonBinaryDiffLines`, `resolvePatchSizeTier`,
|
|
154
|
-
`getPatchSizeThresholds`) are also exported on the programmatic API.
|
|
155
|
-
|
|
156
|
-
For concurrent read-mostly verification beside a busy primary checkout,
|
|
157
|
-
`fireforge tree create <name>` snapshots the project — applied engine tree included,
|
|
158
|
-
`obj-*` excluded — into `.fireforge/trees/<name>` using filesystem copy-on-write (APFS
|
|
159
|
-
clonefile / btrfs-XFS reflink; ~3 s for a 156 MB applied browser tree). Read-only commands
|
|
160
|
-
and export/re-export dry runs work inside a tree; mutations are refused. `--with-objdir`
|
|
161
|
-
adds a safely relocated build for build-less tests. `tree list` reports staleness,
|
|
162
|
-
`tree remove` deletes (refusing active locks), and `tree exec <name> -- <cmd>` runs a
|
|
163
|
-
command inside a tree. Filesystems without CoW support refuse unless `--force-copy`
|
|
164
|
-
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.
|
|
165
67
|
|
|
166
68
|
## Rebasing Firefox Source
|
|
167
69
|
|
|
@@ -188,16 +90,22 @@ npx fireforge furnace status
|
|
|
188
90
|
npx fireforge furnace preview
|
|
189
91
|
```
|
|
190
92
|
|
|
191
|
-
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:
|
|
192
100
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
109
|
|
|
202
110
|
## Roadmap
|
|
203
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.3",
|
|
4
|
+
"commit": "760172104f6bfe523f63adfe08b8c6e63fd5d1f0",
|
|
5
|
+
"shortCommit": "760172104f6b",
|
|
6
6
|
"dirty": false,
|
|
7
7
|
"dirtyHash": null,
|
|
8
|
-
"buildTime": "2026-08-
|
|
8
|
+
"buildTime": "2026-08-28T15:34:58.794Z"
|
|
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);
|
|
@@ -5,7 +5,7 @@ import { applyAllComponents, computeComponentChecksums } from '../../core/furnac
|
|
|
5
5
|
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
6
6
|
import { getFurnacePaths } from '../../core/furnace-config.js';
|
|
7
7
|
import { isComponentSourceFile } from '../../core/furnace-constants.js';
|
|
8
|
-
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
8
|
+
import { runFurnaceMutation, waitLockMutationOptions } from '../../core/furnace-operation.js';
|
|
9
9
|
import { assertFurnaceReady } from '../../core/furnace-precondition.js';
|
|
10
10
|
import { getPersistableAppliedEntry, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
|
|
11
11
|
import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
|
|
@@ -294,7 +294,7 @@ export async function furnaceApplyCommand(projectRoot, name, options = {}) {
|
|
|
294
294
|
await persistSingleComponentState(projectRoot, getPersistableAppliedEntry('Apply', name, applyResult.applied[0]), getFurnacePaths(projectRoot));
|
|
295
295
|
}
|
|
296
296
|
return applyResult;
|
|
297
|
-
}, { dryRun });
|
|
297
|
+
}, { dryRun, ...waitLockMutationOptions(options.waitLockSeconds) });
|
|
298
298
|
if (applySpinner) {
|
|
299
299
|
applySpinner.stop('Components applied');
|
|
300
300
|
}
|
|
@@ -4,7 +4,7 @@ import { applyAllComponents } from '../../core/furnace-apply.js';
|
|
|
4
4
|
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
5
5
|
import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js';
|
|
6
6
|
import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
|
|
7
|
-
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
7
|
+
import { runFurnaceMutation, waitLockMutationOptions, } from '../../core/furnace-operation.js';
|
|
8
8
|
import { assertFurnaceReady } from '../../core/furnace-precondition.js';
|
|
9
9
|
import { getPersistableAppliedEntry, getStepFailureCount, persistSingleComponentState, shouldPersistSingleComponentState, } from '../../core/furnace-state-persist.js';
|
|
10
10
|
import { hasBlockingStepErrors } from '../../core/furnace-step-errors.js';
|
|
@@ -177,7 +177,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
|
|
|
177
177
|
operationContext: ctx,
|
|
178
178
|
});
|
|
179
179
|
return { kind: 'result', result: allResult };
|
|
180
|
-
}, { dryRun: isDryRun });
|
|
180
|
+
}, { dryRun: isDryRun, ...waitLockMutationOptions(options.waitLockSeconds) });
|
|
181
181
|
if (applyOutcome.kind === 'stock') {
|
|
182
182
|
applySpinner.stop('Apply skipped');
|
|
183
183
|
warn(`"${name}" is a stock component. Stock components are not applied locally.`);
|
|
@@ -35,13 +35,17 @@ function registerFurnaceApplyCommand(furnace, context) {
|
|
|
35
35
|
.option('-w, --watch', 'Watch component directories and re-apply on changes');
|
|
36
36
|
addWaitLockOption(apply).action(withErrorHandling(async (name, options) => {
|
|
37
37
|
const { waitLock, ...rest } = options ?? {};
|
|
38
|
-
|
|
38
|
+
// Resolved ONCE and used twice: the engine session lock below, and
|
|
39
|
+
// the furnace lock the mutation takes inside it. Resolving twice
|
|
40
|
+
// would be the same value today and a drift risk tomorrow.
|
|
41
|
+
const waitLockSeconds = resolveWaitLockSeconds(waitLock);
|
|
42
|
+
const parsed = pickDefined({ ...rest, waitLockSeconds });
|
|
39
43
|
const run = (projectRoot) => furnaceApplyCommand(projectRoot, name, parsed);
|
|
40
44
|
if (parsed.dryRun === true) {
|
|
41
45
|
await run(getProjectRoot());
|
|
42
46
|
return;
|
|
43
47
|
}
|
|
44
|
-
await runEngineLockedFurnaceCommand(context, 'furnace apply', run,
|
|
48
|
+
await runEngineLockedFurnaceCommand(context, 'furnace apply', run, waitLockSeconds);
|
|
45
49
|
}));
|
|
46
50
|
}
|
|
47
51
|
function registerFurnaceDeployCommand(furnace, context) {
|
|
@@ -54,13 +58,14 @@ function registerFurnaceDeployCommand(furnace, context) {
|
|
|
54
58
|
.option('--skip-validate', 'Skip the validation step (apply only)');
|
|
55
59
|
addWaitLockOption(deploy).action(withErrorHandling(async (name, options) => {
|
|
56
60
|
const { waitLock, ...rest } = options ?? {};
|
|
57
|
-
const
|
|
61
|
+
const waitLockSeconds = resolveWaitLockSeconds(waitLock);
|
|
62
|
+
const parsed = pickDefined({ ...rest, waitLockSeconds });
|
|
58
63
|
const run = (projectRoot) => furnaceDeployCommand(projectRoot, name, parsed);
|
|
59
64
|
if (parsed.dryRun === true) {
|
|
60
65
|
await run(getProjectRoot());
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
|
-
await runEngineLockedFurnaceCommand(context, 'furnace deploy', run,
|
|
68
|
+
await runEngineLockedFurnaceCommand(context, 'furnace deploy', run, waitLockSeconds);
|
|
64
69
|
}));
|
|
65
70
|
}
|
|
66
71
|
function registerFurnaceSyncCommand(furnace, context) {
|
|
@@ -72,13 +77,14 @@ function registerFurnaceSyncCommand(furnace, context) {
|
|
|
72
77
|
.addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']));
|
|
73
78
|
addWaitLockOption(sync).action(withErrorHandling(async (options) => {
|
|
74
79
|
const { waitLock, ...rest } = options;
|
|
75
|
-
const
|
|
80
|
+
const waitLockSeconds = resolveWaitLockSeconds(waitLock);
|
|
81
|
+
const parsed = pickDefined({ ...rest, waitLockSeconds });
|
|
76
82
|
const run = (projectRoot) => furnaceSyncCommand(projectRoot, parsed);
|
|
77
83
|
if (parsed.dryRun === true) {
|
|
78
84
|
await run(getProjectRoot());
|
|
79
85
|
return;
|
|
80
86
|
}
|
|
81
|
-
await runEngineLockedFurnaceCommand(context, 'furnace sync', run,
|
|
87
|
+
await runEngineLockedFurnaceCommand(context, 'furnace sync', run, waitLockSeconds);
|
|
82
88
|
}));
|
|
83
89
|
}
|
|
84
90
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { stat } from 'node:fs/promises';
|
|
3
|
-
import { basename, dirname, isAbsolute, join,
|
|
3
|
+
import { basename, dirname, isAbsolute, join, posix } from 'node:path';
|
|
4
4
|
import { text } from '@clack/prompts';
|
|
5
5
|
import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
|
|
6
6
|
import { stdioIsInteractive } from '../../core/destructive.js';
|
|
@@ -61,7 +61,10 @@ async function validateFtlBasePath(value, engineDir) {
|
|
|
61
61
|
if (isAbsolute(value) || /^[a-zA-Z]:[\\/]/.test(value)) {
|
|
62
62
|
throw new FurnaceError(`ftlBasePath "${value}" must be a relative path inside the engine checkout, not absolute.`);
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
// Normalize with the POSIX rules regardless of host: `normalize` on Windows
|
|
65
|
+
// emits backslashes, so a `../`-prefix test against the platform `normalize`
|
|
66
|
+
// silently passes every traversal attempt there.
|
|
67
|
+
const normalized = posix.normalize(value.replace(/\\/g, '/'));
|
|
65
68
|
if (normalized === '..' || normalized.startsWith('../')) {
|
|
66
69
|
throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
|
|
67
70
|
}
|
|
@@ -6,7 +6,7 @@ import { applyAllComponents } from '../../core/furnace-apply.js';
|
|
|
6
6
|
import { logApplyResult } from '../../core/furnace-apply-output.js';
|
|
7
7
|
import { getFurnacePaths, loadFurnaceConfig } from '../../core/furnace-config.js';
|
|
8
8
|
import { reportJsconfigPathsSync } from '../../core/furnace-jsconfig.js';
|
|
9
|
-
import { runFurnaceMutation } from '../../core/furnace-operation.js';
|
|
9
|
+
import { runFurnaceMutation, waitLockMutationOptions } from '../../core/furnace-operation.js';
|
|
10
10
|
import { assertFurnaceReady } from '../../core/furnace-precondition.js';
|
|
11
11
|
import { countEntriesWithBlockingStepErrors } from '../../core/furnace-step-errors.js';
|
|
12
12
|
import { containsMergeConflictMarkers } from '../../core/furnace-validate-structure.js';
|
|
@@ -127,7 +127,7 @@ export async function furnaceSyncCommand(projectRoot, options = {}) {
|
|
|
127
127
|
if (!options.dryRun) {
|
|
128
128
|
info('\nApplying all components to engine...');
|
|
129
129
|
const applySpinner = spinner('Applying components...');
|
|
130
|
-
const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false });
|
|
130
|
+
const result = await runFurnaceMutation(projectRoot, 'apply-rollback', (ctx) => applyAllComponents(projectRoot, false, { operationContext: ctx }), { dryRun: false, ...waitLockMutationOptions(options.waitLockSeconds) });
|
|
131
131
|
applySpinner.stop('Components applied');
|
|
132
132
|
logApplyResult(result, false);
|
|
133
133
|
const appliedWithStepErrorsCount = countEntriesWithBlockingStepErrors(result.applied);
|
|
@@ -4,6 +4,7 @@ import { getProjectPaths } from '../core/config.js';
|
|
|
4
4
|
import { appendHistory, confirmDestructive, } from '../core/destructive.js';
|
|
5
5
|
import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
|
|
6
6
|
import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
7
|
+
import { listTrackedInHead } from '../core/git-file-ops.js';
|
|
7
8
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
8
9
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
9
10
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../core/patch-lint.js';
|
|
@@ -88,7 +89,7 @@ function buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveL
|
|
|
88
89
|
return updates;
|
|
89
90
|
}
|
|
90
91
|
async function confirmFilesModeProjection(args) {
|
|
91
|
-
const { target, retained, removed, added, actualProjectedFiles,
|
|
92
|
+
const { target, retained, removed, added, actualProjectedFiles, deletedFiles, options, conflicts, } = args;
|
|
92
93
|
const isDryRun = options.dryRun === true;
|
|
93
94
|
const summary = [
|
|
94
95
|
`re-export ${target.filename} with --files scope`,
|
|
@@ -102,8 +103,8 @@ async function confirmFilesModeProjection(args) {
|
|
|
102
103
|
if (added.length > 0) {
|
|
103
104
|
summary.push(`newly included files (${added.length}): ${added.join(', ')}`);
|
|
104
105
|
}
|
|
105
|
-
if (
|
|
106
|
-
summary.push(`
|
|
106
|
+
if (deletedFiles.length > 0) {
|
|
107
|
+
summary.push(`deleted on disk (captured as deletions the patch will replay): ${deletedFiles.join(', ')}`);
|
|
107
108
|
}
|
|
108
109
|
if (!isDryRun && removed.length > 0 && options.allowShrink !== true) {
|
|
109
110
|
warn(`Refusing to shrink ${target.filename} without --allow-shrink.`);
|
|
@@ -118,12 +119,60 @@ async function confirmFilesModeProjection(args) {
|
|
|
118
119
|
operation: 're-export-files',
|
|
119
120
|
title: `Re-export ${target.filename} with --files`,
|
|
120
121
|
summary,
|
|
121
|
-
|
|
122
|
+
// A captured deletion still needs the confirmation: the patch will now
|
|
123
|
+
// REMOVE those files wherever it is applied, which is the same class of
|
|
124
|
+
// consequence as shrinking the scope.
|
|
125
|
+
yes: removed.length === 0 && deletedFiles.length === 0 ? true : options.yes === true,
|
|
122
126
|
dryRun: isDryRun,
|
|
123
127
|
unsafeOverride: options.forceUnsafe === true,
|
|
124
128
|
conflicts,
|
|
125
129
|
});
|
|
126
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Splits the requested paths into what can be diffed and what is a deletion.
|
|
133
|
+
*
|
|
134
|
+
* A path absent from disk is one of two very different things, and
|
|
135
|
+
* collapsing them is what made a DELETION impossible to capture.
|
|
136
|
+
*
|
|
137
|
+
* Tracked in HEAD: a deletion. `git diff HEAD -- <path>` renders it as a
|
|
138
|
+
* proper `deleted file mode` section, so it STAYS in the diff scope. (The
|
|
139
|
+
* comment this replaces claimed the diff "would fail" on any missing path —
|
|
140
|
+
* true only for the never-tracked case.) Dropping these produced the
|
|
141
|
+
* dangerous shape: a re-export that reported success while writing a patch
|
|
142
|
+
* whose own file list claimed a path it did not restore, so a fresh clone
|
|
143
|
+
* would apply it and resurrect exactly what the change meant to remove.
|
|
144
|
+
*
|
|
145
|
+
* Never tracked in HEAD: nothing on disk and nothing to diff against, so
|
|
146
|
+
* there is no hunk to be had. Refused rather than warned about, because the
|
|
147
|
+
* patch cannot express it either way.
|
|
148
|
+
*
|
|
149
|
+
* @param requested - The `--files` scope
|
|
150
|
+
* @param engineDir - Absolute path to the engine checkout
|
|
151
|
+
* @param patchFilename - Target patch, for the refusal message
|
|
152
|
+
* @returns The diffable scope and the subset captured as deletions
|
|
153
|
+
*/
|
|
154
|
+
async function classifyRequestedPaths(requested, engineDir, patchFilename) {
|
|
155
|
+
const absentFiles = [];
|
|
156
|
+
for (const file of requested) {
|
|
157
|
+
if (!(await pathExists(join(engineDir, file)))) {
|
|
158
|
+
absentFiles.push(file);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const trackedAbsent = await listTrackedInHead(engineDir, absentFiles);
|
|
162
|
+
const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
|
|
163
|
+
const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
|
|
164
|
+
if (untrackedAbsent.length > 0) {
|
|
165
|
+
throw new InvalidArgumentError(`Refusing to re-export ${patchFilename} with --files because ${untrackedAbsent.length} requested path${untrackedAbsent.length === 1 ? ' is' : 's are'} ` +
|
|
166
|
+
`neither on disk nor tracked in engine HEAD (${untrackedAbsent.join(', ')}). ` +
|
|
167
|
+
'There is no content to diff and no deletion to record, so the patch could not ' +
|
|
168
|
+
'honour the file list it would claim. Remove those paths from --files.', '--files');
|
|
169
|
+
}
|
|
170
|
+
for (const file of deletedFiles) {
|
|
171
|
+
info(`${patchFilename}: capturing deletion of ${file}`);
|
|
172
|
+
}
|
|
173
|
+
const untrackedSet = new Set(untrackedAbsent);
|
|
174
|
+
return { diffableFiles: requested.filter((f) => !untrackedSet.has(f)), deletedFiles };
|
|
175
|
+
}
|
|
127
176
|
/**
|
|
128
177
|
* Handles `re-export --files` end-to-end: computes the projected diff, runs
|
|
129
178
|
* the per-patch and cross-patch lint against a context in which the target
|
|
@@ -152,22 +201,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
152
201
|
const removed = target.filesAffected.filter((f) => !requested.includes(f));
|
|
153
202
|
const added = requested.filter((f) => !target.filesAffected.includes(f));
|
|
154
203
|
const retained = target.filesAffected.filter((f) => requested.includes(f));
|
|
155
|
-
|
|
156
|
-
// them in the new diff because getDiffForFilesAgainstHead would fail.
|
|
157
|
-
// Missing files are still dropped from the manifest so the resulting
|
|
158
|
-
// filesAffected reflects reality.
|
|
159
|
-
const missingFiles = [];
|
|
160
|
-
for (const file of requested) {
|
|
161
|
-
const filePath = join(paths.engine, file);
|
|
162
|
-
if (!(await pathExists(filePath))) {
|
|
163
|
-
missingFiles.push(file);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const missingSet = new Set(missingFiles);
|
|
167
|
-
const diffableFiles = requested.filter((f) => !missingSet.has(f));
|
|
168
|
-
for (const file of missingFiles) {
|
|
169
|
-
warn(`${target.filename}: requested file is missing on disk and will be dropped: ${file}`);
|
|
170
|
-
}
|
|
204
|
+
const { diffableFiles, deletedFiles } = await classifyRequestedPaths(requested, paths.engine, target.filename);
|
|
171
205
|
// Compute the projected diff up front. This is the same diff the real
|
|
172
206
|
// write would produce, so we get an exact preview through the lint
|
|
173
207
|
// gate and avoid computing it twice.
|
|
@@ -179,7 +213,13 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
179
213
|
}
|
|
180
214
|
const actualProjectedFiles = extractAffectedFiles(projectedDiff);
|
|
181
215
|
const actualProjectedSet = new Set(actualProjectedFiles);
|
|
182
|
-
|
|
216
|
+
// Domain is the FULL requested list, not the diffable subset. When the
|
|
217
|
+
// subset was used, any path filtered out upstream was invisible to this
|
|
218
|
+
// check by construction — which is exactly how a dropped deletion slipped
|
|
219
|
+
// through while the run reported success. A patch that does not do what
|
|
220
|
+
// its own file list claims is the one property a manifest must never get
|
|
221
|
+
// wrong, so an explicitly named path that produced no hunk is a refusal.
|
|
222
|
+
const noDiffFiles = requested.filter((file) => !actualProjectedSet.has(file));
|
|
183
223
|
if (noDiffFiles.length > 0) {
|
|
184
224
|
throw new InvalidArgumentError(`Refusing to re-export ${target.filename} with --files because ${noDiffFiles.length} requested path${noDiffFiles.length === 1 ? '' : 's'} produced no diff hunks (${noDiffFiles.join(', ')}). ` +
|
|
185
225
|
'Keeping them in filesAffected would desync patches.json from the patch body. ' +
|
|
@@ -216,7 +256,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
216
256
|
removed,
|
|
217
257
|
added,
|
|
218
258
|
actualProjectedFiles,
|
|
219
|
-
|
|
259
|
+
deletedFiles,
|
|
220
260
|
options,
|
|
221
261
|
conflicts,
|
|
222
262
|
});
|
|
@@ -243,7 +283,7 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
243
283
|
filename: target.filename,
|
|
244
284
|
files: actualProjectedFiles,
|
|
245
285
|
previousFiles: target.filesAffected,
|
|
246
|
-
|
|
286
|
+
deletionsCaptured: deletedFiles,
|
|
247
287
|
},
|
|
248
288
|
...(options.yes === true ? { yes: true } : {}),
|
|
249
289
|
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|