@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +18 -110
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/furnace/apply.js +2 -2
  6. package/dist/src/commands/furnace/deploy.js +2 -2
  7. package/dist/src/commands/furnace/index.js +12 -6
  8. package/dist/src/commands/furnace/init.js +5 -2
  9. package/dist/src/commands/furnace/sync.js +2 -2
  10. package/dist/src/commands/re-export-files.js +63 -23
  11. package/dist/src/commands/re-export-scan.js +18 -2
  12. package/dist/src/commands/re-export-single.js +19 -7
  13. package/dist/src/commands/test-register.js +5 -1
  14. package/dist/src/commands/test-run.d.ts +5 -0
  15. package/dist/src/commands/test-run.js +11 -4
  16. package/dist/src/commands/test-verdict.js +10 -1
  17. package/dist/src/commands/test.js +9 -0
  18. package/dist/src/commands/token.js +36 -12
  19. package/dist/src/core/build-audit.js +6 -2
  20. package/dist/src/core/furnace-jsconfig.js +5 -3
  21. package/dist/src/core/furnace-operation.d.ts +25 -2
  22. package/dist/src/core/furnace-operation.js +27 -2
  23. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  24. package/dist/src/core/mach-known-noise-filter.js +19 -0
  25. package/dist/src/core/mach.d.ts +8 -3
  26. package/dist/src/core/mach.js +47 -3
  27. package/dist/src/core/run-log.d.ts +60 -0
  28. package/dist/src/core/run-log.js +183 -0
  29. package/dist/src/core/test-harness-output.d.ts +24 -1
  30. package/dist/src/core/test-harness-output.js +46 -15
  31. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  32. package/dist/src/core/test-xpcshell-retry.js +6 -1
  33. package/dist/src/core/token-manager.d.ts +7 -2
  34. package/dist/src/core/token-manager.js +74 -20
  35. package/dist/src/core/token-variant.d.ts +46 -2
  36. package/dist/src/core/token-variant.js +190 -33
  37. package/dist/src/types/commands/options.d.ts +30 -1
  38. package/dist/src/utils/child-env.d.ts +30 -0
  39. package/dist/src/utils/child-env.js +30 -0
  40. package/dist/src/utils/options.d.ts +28 -0
  41. package/dist/src/utils/options.js +32 -1
  42. package/dist/src/utils/process.d.ts +22 -0
  43. package/dist/src/utils/process.js +8 -7
  44. package/docs/README.md +35 -0
  45. package/docs/configuration.md +116 -0
  46. package/docs/exit-codes.md +63 -0
  47. package/docs/furnace.md +57 -0
  48. package/docs/lifecycle-invariants.md +309 -0
  49. package/docs/machine-output.md +94 -0
  50. package/docs/patch-workflow.md +119 -0
  51. package/docs/run-logs.md +60 -0
  52. package/docs/testing.md +92 -0
  53. package/docs/verification-trees.md +48 -0
  54. 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
- For large generated asset batches, `re-export --scan --scan-files <manifest>` assigns files to
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
- Cross-widget CSS can be single-sourced as shared fragments: place the fragment in
194
- `components/shared/` and reference it from a widget stylesheet with a
195
- `/* @fireforge-include <fragment>.css */` comment — deploy expands it into the deployed
196
- copy only, and editing the fragment surfaces as component drift until the next deploy.
197
- For typed cross-module imports of multi-file components, set
198
- `furnace.json#typecheckJsconfig` to a consumer-owned jsconfig and deploy will maintain
199
- `compilerOptions.paths` entries mapping each deployed
200
- `chrome://global/content/elements/<file>.mjs` URL to its workspace source.
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
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.1",
4
- "commit": "b77a037425f19e19c43a64a5bb1ff79d949af92e",
5
- "shortCommit": "b77a037425f1",
3
+ "version": "0.44.3",
4
+ "commit": "760172104f6bfe523f63adfe08b8c6e63fd5d1f0",
5
+ "shortCommit": "760172104f6b",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-08-27T22:08:53.928Z"
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
- const parsed = pickDefined(rest);
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, resolveWaitLockSeconds(waitLock));
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 parsed = pickDefined(rest);
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, resolveWaitLockSeconds(waitLock));
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 parsed = pickDefined(rest);
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, resolveWaitLockSeconds(waitLock));
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, normalize } from 'node:path';
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
- const normalized = normalize(value.replace(/\\/g, '/'));
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, missingFiles, options, conflicts, } = args;
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 (missingFiles.length > 0) {
106
- summary.push(`missing on disk (will be dropped): ${missingFiles.join(', ')}`);
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
- yes: removed.length === 0 && missingFiles.length === 0 ? true : options.yes === true,
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
- // Filter out paths that no longer exist on disk; we cannot include
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
- const noDiffFiles = diffableFiles.filter((file) => !actualProjectedSet.has(file));
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
- missingFiles,
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
- missingFilesDropped: missingFiles,
286
+ deletionsCaptured: deletedFiles,
247
287
  },
248
288
  ...(options.yes === true ? { yes: true } : {}),
249
289
  ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),