@hominis/fireforge 0.44.3 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/build.js +9 -4
  4. package/dist/src/commands/doctor-source-pin.d.ts +29 -0
  5. package/dist/src/commands/doctor-source-pin.js +81 -0
  6. package/dist/src/commands/doctor.js +2 -0
  7. package/dist/src/commands/export-flow.js +3 -18
  8. package/dist/src/commands/furnace/create-browser-test.d.ts +9 -0
  9. package/dist/src/commands/furnace/create-browser-test.js +6 -6
  10. package/dist/src/commands/furnace/create-dry-run.d.ts +10 -0
  11. package/dist/src/commands/furnace/create-dry-run.js +16 -11
  12. package/dist/src/commands/furnace/create-xpcshell.js +2 -2
  13. package/dist/src/commands/furnace/create.js +5 -0
  14. package/dist/src/commands/patch/move-files-into.js +3 -6
  15. package/dist/src/commands/patch/split-plan.js +3 -6
  16. package/dist/src/commands/re-export-adjacent.d.ts +8 -0
  17. package/dist/src/commands/re-export-adjacent.js +26 -1
  18. package/dist/src/commands/re-export-drift.d.ts +8 -0
  19. package/dist/src/commands/re-export-drift.js +61 -0
  20. package/dist/src/commands/re-export-files.js +3 -6
  21. package/dist/src/commands/re-export-options.js +19 -5
  22. package/dist/src/commands/re-export-register.js +5 -1
  23. package/dist/src/commands/re-export-scan.js +3 -6
  24. package/dist/src/commands/re-export.js +16 -16
  25. package/dist/src/commands/test-build-phase.js +3 -2
  26. package/dist/src/commands/test-diagnose.js +14 -5
  27. package/dist/src/commands/test-register.js +1 -0
  28. package/dist/src/commands/test-run.js +4 -3
  29. package/dist/src/commands/test-stale-gate.js +5 -2
  30. package/dist/src/commands/test.js +9 -2
  31. package/dist/src/core/build-audit.d.ts +20 -0
  32. package/dist/src/core/build-audit.js +69 -3
  33. package/dist/src/core/build-baseline-types.d.ts +28 -0
  34. package/dist/src/core/build-baseline.d.ts +16 -1
  35. package/dist/src/core/build-baseline.js +38 -2
  36. package/dist/src/core/build-jar-escalation.d.ts +64 -0
  37. package/dist/src/core/build-jar-escalation.js +127 -0
  38. package/dist/src/core/build-prepare.d.ts +12 -1
  39. package/dist/src/core/build-prepare.js +68 -6
  40. package/dist/src/core/config-validate-file-size.d.ts +12 -0
  41. package/dist/src/core/config-validate-file-size.js +65 -0
  42. package/dist/src/core/config-validate.js +6 -0
  43. package/dist/src/core/engine-changes.d.ts +18 -0
  44. package/dist/src/core/engine-changes.js +34 -0
  45. package/dist/src/core/furnace-constants.d.ts +21 -0
  46. package/dist/src/core/furnace-constants.js +26 -1
  47. package/dist/src/core/harness-orphans.d.ts +73 -0
  48. package/dist/src/core/harness-orphans.js +186 -0
  49. package/dist/src/core/license-headers.d.ts +9 -5
  50. package/dist/src/core/license-headers.js +33 -7
  51. package/dist/src/core/mach-known-noise-filter.d.ts +21 -0
  52. package/dist/src/core/mach-known-noise-filter.js +24 -1
  53. package/dist/src/core/mach.d.ts +7 -0
  54. package/dist/src/core/mach.js +18 -2
  55. package/dist/src/core/patch-apply.js +49 -20
  56. package/dist/src/core/patch-lint-checkjs.js +6 -1
  57. package/dist/src/core/patch-lint-file-size.d.ts +52 -0
  58. package/dist/src/core/patch-lint-file-size.js +53 -0
  59. package/dist/src/core/patch-lint-prettier.d.ts +36 -0
  60. package/dist/src/core/patch-lint-prettier.js +126 -0
  61. package/dist/src/core/patch-lint-unmanaged-companion.d.ts +106 -0
  62. package/dist/src/core/patch-lint-unmanaged-companion.js +214 -0
  63. package/dist/src/core/patch-lint.js +23 -24
  64. package/dist/src/core/patch-transform.d.ts +21 -0
  65. package/dist/src/core/patch-transform.js +38 -0
  66. package/dist/src/core/smoke-patterns.js +12 -0
  67. package/dist/src/core/test-harness-crash.js +61 -48
  68. package/dist/src/core/test-harness-verdict-notes.d.ts +55 -0
  69. package/dist/src/core/test-harness-verdict-notes.js +48 -0
  70. package/dist/src/core/test-stale-check.d.ts +23 -6
  71. package/dist/src/core/test-stale-check.js +77 -15
  72. package/dist/src/core/test-stall-triage.js +37 -19
  73. package/dist/src/errors/build.d.ts +28 -0
  74. package/dist/src/errors/build.js +49 -0
  75. package/dist/src/types/commands/options.d.ts +19 -0
  76. package/dist/src/types/commands/patches.d.ts +17 -6
  77. package/dist/src/types/config.d.ts +41 -0
  78. package/dist/src/utils/fs.d.ts +4 -2
  79. package/dist/src/utils/fs.js +3 -1
  80. package/docs/configuration.md +47 -0
  81. package/docs/exit-codes.md +4 -2
  82. package/docs/testing.md +83 -0
  83. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.45.0
4
+
5
+ ### Patch bodies
6
+
7
+ - **A NEW binary file could not be vendored into a patch at all.** Adopting eight WOFF2 faces through `re-export --scan --scan-file` failed, after a clean lint pass, with `Cannot extract text content from binary patch section for …` — and the faces stayed unmanaged, so a `fireforge reset` left the patch's `@font-face` and `jar.inc.mn` lines pointing at missing files. The export half was never at fault: `generateBinaryFilePatch` wrote a correct, replayable `GIT binary patch`. The refusal came from the FORWARD-IMPORT PROJECTION downstream of it. `detectNewFilesInDiff` keys purely on `new file mode`, so binaries entered the loop, and every one of the five projection sites then handed each detected new file to `extractNewFileContentFromDiff`, which refuses binary sections — correctly, since the base85 alphabet includes `+` and "extract the + lines" would write garbage over a binary target.
8
+ The projection never needed the bytes. It feeds one rule, which scans content for import statements, and a binary blob authors none — so skipping a binary contributes exactly what including it would. The asymmetry was the tell: `buildPatchQueueContext` already tolerated binary new files for REAL queue entries (a per-file try/catch), and only the projections did not. The five duplicated loops are now one `buildNewFileTextProjection`, which skips binary sections and parses the diff once instead of once per new file. `extractNewFileContentFromDiff` still refuses a binary section named explicitly: a caller that asked for THIS file's text must be told no, not silently handed nothing. Nothing is given up by skipping — binary bodies are covered independently by the `binary-body-not-reconstructable` queue lint.
9
+ Worth recording that the reported symptom was narrower than stated in one direction and wider in another. Plain `fireforge export` of a new binary always worked; the export half fails only behind a placement flag (`--order`/`--before`/`--after`), which is what routes it through `projectPlacementForLint`. But the same projection sits under `patch move-files-into` and `patch split`, so a binary could not be moved between patches or split out either.
10
+ - **The new-file conflict recovery could not handle a binary target, and restored text where it should have restored bytes.** When `git apply` fails because a patch creates a file that is already on disk, FireForge resolves the conflict by putting the patch's intended content in place and retrying. For a binary there is nothing to extract — but nothing needs to be: `git apply` decodes the payload itself, so the resolution is to REMOVE the blocking file and let the retry create it. A `Binary files … differ` body, which records that the bytes changed and carries none of them, is refused instead of deleted, because nothing could put the file back. The saved originals are now raw `Buffer`s rather than utf-8 text, which also closes a latent corruption on the pre-existing path: a binary file overwritten by this recovery was previously restored through a `readText`/`writeText` round-trip that mangles it.
11
+
12
+ ### Refusals
13
+
14
+ - **`--expect <path>` warned "showed no drift" over a path that did drift and was captured.** The notice was the only feedback that the belt looked at the right files, and none of the causes it named held; the reporter's workaround was `grep -c` on the written body. The measurement was never stated, and stating it is the diagnosis: the comparison is CONTENT, per patch, on the body about to be written and never against post-write state — and it is ONE-DIRECTIONAL, reporting only the payload lines the refreshed body carries that the old body did not. Removing a line from an engine file makes the refreshed body DROP that file's `+` line, so nothing is absorbed and by design there is no drift to see. The notice now says what it compares and when, leads with that one-directional cause, and adds the `--scan-file` adoption case as a named cause (adopting a file also captures that patch's other drifted files, so a scripted scan-less re-export afterwards legitimately sees nothing left — informational at exit 0, and a chain treating the warning as an error will false-red on it). Widening the comparison was considered and rejected: it would turn every ordinary line removal into a refusal, and the belt exists to catch a concurrent session's edits being swallowed INTO a body.
15
+ - **The coverage refusal left its own triage step to the reader.** Adding a test to `xpcshell.toml` while the packaged runtime came from a browser-chrome `test --build` was refused correctly, naming the covered paths, the requested paths, the rebuild command and `--extend-coverage`. What it did not say is WHY this run needed new coverage: a MANIFEST gained an entry, and a browser-chrome build claim never covers xpcshell. FireForge can see both facts. The refusal now names the test manifests changed since the recorded build that sit in an uncovered request path's directory. Behaviour is unchanged; the probe is best-effort and a failure renders exactly the previous text.
16
+ - **`--refuse-adjacent-unmanaged` was all-or-nothing against a recorded exception.** A project with a reviewed, deliberately unmanaged path beside a patch's ownership had no way to say so, so every re-export of every adjacent patch had to drop the flag entirely and substitute a hand-read of the notice — a weaker belt than the one being disarmed, recurring on every touch. `--expect-unmanaged <path>` (repeatable, requires `--refuse-adjacent-unmanaged`) names an approved exception: the belt stays armed for everything else. Approved paths are still LISTED, as an explicit note that they were admitted rather than dropped, because a carve-out nobody can see is how one quietly widens. A path that is never met is reported at the end of the run, for the same reason `--expect` reports its unseen entries: a typo'd carve-out silently leaves the refusal armed for the path it was meant to admit, while reading as an approval that is in force. Deliberately a flag and not a config list — a standing exception nobody re-reads is exactly what this refusal exists to prevent.
17
+
18
+ ### Diagnostics
19
+
20
+ - **`run --smoke-exit` gave a clean window over the one line that predicts a crash under automation.** The 0.44.1 discriminator (`run --smoke-exit 60 --headless --capture-console <file>`) was run against a build whose startpage pointed at a nonexistent `chrome://` image. It exited 0 with `Unallowed errors: 0` — the "startup is healthy" answer — for a build that hung EVERY browser-chrome run at "Waiting for browser…", while the capture it wrote held the discriminating line: `Missing chrome or resource URL: chrome://…`. That is Gecko's non-automation spelling (`printf_stderr` in `CheckForBrokenChromeURL`, `netwerk/base/nsNetUtil.cpp`); under `xpc::IsInAutomation()` the identical condition is `MOZ_CRASH_UNSAFE_PRINTF("Missing chrome or resource URLs: %s")`. So the probe did discriminate — but only for a reader who grepped the capture, and the exit code said the opposite. `^Missing chrome or resource URLs?:` is now a smoke error pattern, allowlistable through `--console-allow` like any other.
21
+ - **The no-output-stall triage told operators to read a log that structurally cannot carry the evidence.** Cause (3) was recorded as an un-root-caused correlation with a broken `chrome://` image URI. It is now root-caused, and the mechanism explains why the log is empty: under automation the `MOZ_CRASH` lands in whichever process opened the channel, and in the downstream reproduction that was a CONTENT process (`plugin-container`) with the crash reporter compiled out — so the harness log carried neither the message nor a crash line, only the 370 s timeout, `Ran 0 checks`, and mochitest's "Can't trigger Breakpad, just killing process". The only artefact was `~/Library/Logs/DiagnosticReports/plugin-container-*.ips`, faulting frame `CheckForBrokenChromeURL`, which is the symbolicated diagnosis the census bar asks for. The census now names that artefact and the smoke probe together, and states that the log naming nothing is the expected shape rather than an absence of evidence. Detection by log line is still deliberately not specified, and the module doc now records why.
22
+ - **The green-suite teardown belt could reject a run without saying which condition rejected it.** On a composed gate's tiles step: exit 1, 201 `TEST_END`, zero `Test FAIL`, zero `TEST-UNEXPECTED`, only the recognized `mozsystemmonitor` teardown traceback in the tail, FireForge's own "Known upstream …" note printed — and `FIREFORGE-VERDICT: FAIL reason=test-failures … unexpected=0`. The re-run was green and the log had rotated. Every condition in the belt is all-or-nothing, so from outside the failing verdict looks identical whichever one rejected it, and diagnosis reduced to guessing. A rejected run that DID carry the recognized teardown noise now names the first failing condition on the verdict line — `green-teardown override rejected: 2 matched failure line(s), first: …`. A run with no teardown noise was never a candidate for the belt and says nothing, because naming a rejecting condition there would be pure noise. Advisory only: the status and `reason` keys the verdict contract's consumers parse are unchanged.
23
+ - **The undefined-identifier hint named the shim when the cause was an unmanaged companion — and taking the hint would have masked a real bug.** Hoisting duplicated test helpers into a new `head_settings.js` that a managed `head.js` loads via `loadSubScript` leaves the companion owned by no patch until export, so the per-patch program reports every helper use as `Cannot find name` (157 warnings, in the reported case) with a hint offering to add the globals to the extra shim. That remedy makes all 157 disappear AND makes a genuinely missing helper undetectable forever after; the actual fix, `re-export <patch> --scan --scan-file <file>`, the hint never mentioned. When EVERY undefined name in a file is declared at the top level of an unmanaged file that a managed head loads, the hint now names that file, its loader and the adoption command, and rules the shim out explicitly. One unresolved name and the generic hint stays — the file may genuinely be missing a helper, which is the bug this detection exists not to hide.
24
+ - **A test-suite red rendered a build-shaped remedy list.** A failing assertion exited through `BuildError`, so the operator was told to "check the build output above", run `fireforge bootstrap`, and delete `obj-*` directories. None of the three applies to a failing test, and the first points at the wrong half of the log — the half a `tail` keeps. `TestFailureError` now carries remedies that name the failing tests (the `TEST-UNEXPECTED-*` blocks the verdict already collected and, until now, only logged), the `FIREFORGE-VERDICT:` line as ground truth, and the run log path, which is the one artifact that survives the pipe that caused the confusion. It subclasses `BuildError` and **keeps exit 5** deliberately: `docs/exit-codes.md` documents 5 as "a suite that FAILED" and consumers key CI on it, so only the message changes. The `--json` error code does move, from `build` to `test-failure`, because `machineErrorCode` derives it from the class name. `'Test process failed to start'` stays a `BuildError` — a harness that never spawned genuinely can be a build or environment problem, and the build remedies are apt there.
25
+ - **The known-teardown-noise filter covered the test path and not the build path — and could not simply be extended to it.** The `SystemResourceMonitor` `AttributeError` at teardown is recognized, documented upstream noise, and `mach build` surfaced it unannotated, where it reached one consumer as an unexplained build-log traceback during a 153.2.0esr drill. Threading the existing `annotateKnownTeardownNoise` option through the protected build dispatch was the obvious fix and is the wrong one, twice over. It would be a silent NO-OP: recognition requires a previously-seen `SUITE_END`, and a build never prints one. And relaxing that gate would be wrong on its own terms — the marker is what separates teardown from work still in progress, a build has no equivalent boundary, so FireForge cannot tell the known-cosmetic case from a real build-time traceback and must not withhold the block. Separately, the inherit path has no per-chunk hook, only a mirror that feeds the terminal and the run log the same string, so filtering there would strip the traceback from the raw log — the opposite of the "raw into the log, filtered into the echo" invariant the filter was built around.
26
+ So the build path RECOGNIZES rather than collapses: the traceback prints verbatim and one labeled line names it, using the same closed allowlist (two attributes, plus a `resourcemonitor.py` frame). A novel attribute stays unannotated, because it is a new upstream defect. The filter's module doc now states the test-only scope and both reasons for it, `MachOptions.annotateKnownTeardownNoise` documents that the inherit path ignores it (it type-checked and did nothing, which is how this reads as an oversight from outside), and the signature is documented in `docs/testing.md` — resolving the annotation's dangling "See FireForge docs.", which pointed at documentation that did not exist.
27
+ - **`fireforge source set` wrote a pin that a routine revert silently lost.** The pin lands in `fireforge.json` beside hand-maintained policy sections, so `git checkout -- fireforge.json` — undoing an accidental whole-file reformat, say — also reverts an uncommitted pin, and nothing afterwards reported that the pin and the engine checkout had diverged. The field tell was a gate flipping green with no change that should have made it green. `fireforge doctor` now reports the configured pin beside what the checkout actually is — the engine's own `browser/config/version.txt` and the version the last download recorded into state — and warns when they disagree. A report, not a lock and not a new refusal: a tree may legitimately be mid-migration, and the only thing missing was visibility. Scope is limited by what is actually recorded: `product`, `sha256` and `candidate` have no counterpart on disk (state holds only `downloadedVersion` and `baseCommit`), so the check says nothing about them rather than implying it verified them. A blank or whitespace-only `version.txt` — a truncated write, a partial extraction — reads as absent rather than as a mismatch against the empty string. The check reuses `getFirefoxVersion`, which had been sitting in `firefox-extract.ts` with no production caller.
28
+
29
+ ### Builds
30
+
31
+ - **The jar-only full-build escalation was over-broad.** `requiresFullBuildForIncrementalTest` was exactly `isJarManifestPath`, so ANY changed jar manifest escalated the pre-test build to a full `mach build` — ~10 minutes against ~20 s for a chrome-only slice, paid twice per green/no-change pair on a Furnace-touching slice. A downstream experiment (two clean runs against 0.44.4) settled the common case: a jar-only registration of a NEW content file added to an EXISTING `dist/bin` manifest was installed by a plain `fireforge build --ui` both times — `faster/install_dist_bin_browser` named the destination, the file landed in `dist/bin/…/content/browser/` and in the `.app` bundle, and a plain `fireforge test` fetched it over `chrome://` at runtime. Neither run escalated. The escalation is now narrowed to the two halves nobody has run: a NEW `jar.mn` (untracked in the engine repo — no install manifest exists for it yet) and a manifest whose jar declaration carries a bracketed base-directory prefix, which redirects the install destination away from the default chrome root. Every probe failure still escalates, so the error direction stays slow-but-correct, and the notice now names the manifest AND the reason instead of saying "a jar.mn registration changed".
32
+ - **Post-build packaging notices could not be triaged on a shared checkout.** The missing-artifact notice keys on "was touched", which on a checkout several sessions share means any of their dirty files — so an operator got a registration-shaped warning about a file they never edited and had to census ownership by hand to dismiss it. Each notice now carries the owning patch, or an explicit `unmanaged` mark. Resolution is best-effort and shares `status --ownership`'s source (the manifest's `filesAffected`); with no queue or an unreadable manifest the notice renders exactly as before, because "unmanaged" has to mean "checked and unowned" rather than "could not check".
33
+
34
+ ### Test harness
35
+
36
+ - **Nothing could see or reap orphaned harness processes.** Stopping a 45-minute tiles run mid-flight left `xpcshell` (the harness httpd, pegged at 100% CPU), `pywebsocket`, `ssltunnel` and `moz-http2` alive for an hour. Later runs crawled — a three-second suite took six minutes of wall clock, audio-start waits timed out — and nothing in the output connected the slowness to the survivors; the hour went into mis-attributing it to the change under test. Every `fireforge test` dispatch now runs a census of harness helper processes anchored to this project's objdir, naming each with its elapsed time and the `kill` command. It runs at PREFLIGHT, before this run spawns anything, so every hit is by construction a survivor and no age heuristic is needed. A match must be objdir-anchored: `xpcshell` and `server.js` are far too generic to report on their own, the same rule the mochitest server-port preflight already applies. Report-only by default; `--reap-orphans` opts into terminating them. It never refuses a run — a survivor degrades performance rather than making the run dispatch against the wrong server, so the right response is to say so loudly, not to stop.
37
+
38
+ ### Patch lint
39
+
40
+ - **Per-patch lint ran no Prettier on `.sys.mjs`, so module formatting drift was invisible to every gate** — which is how it survived to a human reviewer. `patchLint.prettier` (`'off'` by default) runs the project's configured Prettier over patch-owned `.sys.mjs` modules. It runs with `cwd` set to `engine/`, which is the half that matters: a fork's ROOT `.prettierignore` typically excludes `engine/`, so the same `prettier --check --config engine/.prettierrc.js <engine files>` run from the repo root reports "all files use Prettier code style" for files that fail the identical check from inside `engine/` — a root-level spot-check falsely refuted a correct reviewer finding on exactly this. Prettier is resolved from `engine/node_modules` first, then the project's, then `npx --no-install`. Exit 2 is reported as prettier failing rather than as formatting drift, since `--write` cannot fix it. Left off, formatting is explicitly out of scope for the per-patch tier — that is now the stated contract rather than an accident.
41
+ - **The `file-too-large` thresholds were module constants, and the soft tier was effectively hard.** Nothing in `fireforge.json` could move `500/750/900` (general) or `1200/1400/1600` (test), and because the recommended gate posture is `--max-warnings 0`, the 750 "soft limit" is a hard failure — a controller sitting at 751 lines failed a gate with a message calling 750 soft, at exactly the moment someone is deciding whether to restructure a file. `patchLint.fileSizeThresholds.{general,test}.{notice,warning,error}` now tunes them (each field optional, merged over the defaults, validated as positive integers ordered `notice <= warning <= error` against the MERGED triple). The message drops the soft/hard vocabulary for what the band actually is — a warning — and names the consequence: a gate running `--max-warnings 0` treats it as a failure. The patch-SIZE thresholds keep their existing wording; only the file-size rule was reported.
42
+
3
43
  ## 0.44.0
4
44
 
5
45
  ### Gates
@@ -86,6 +126,8 @@
86
126
  - **`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
127
  `--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
128
  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.
129
+ - **The CC0 public-domain dedication Mozilla puts on its test files was not a recognized license header.** `containsUpstreamLicenseText` knew MPL, Apache, MIT, GPL and a bare SPDX tag; nothing knew `Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/`. A modified upstream test whose notice was UNCHANGED therefore warned `modified-file-missing-header`, and the downstream workaround — two identifier-only edits, an `SPDX-License-Identifier: CC0-1.0` line above each untouched notice on patch 905 — was editing Mozilla's notice to satisfy FireForge's recognizer. Worse than filed: `hasThirdPartyPermissiveBanner` did not know it either, so a NEW file copied from the upstream test tree was neither "acceptable" nor "vendored", and `export -y` would have prepended the PROJECT's license header onto code Mozilla dedicated to the public domain — precisely the mislicensing the vendored carve-out (0.38.0) exists to prevent.
130
+ Both predicates now recognize the dedication in every spelling the tree carries — the prose, the `creativecommons.org/publicdomain/zero/1.0/` URL with either scheme, and the `CC0-1.0` SPDX identifier — through one shared matcher, so the two cannot drift. Deliberately narrow, with the negative controls pinned: a different Creative Commons license (`licenses/by/4.0/`, `CC-BY-4.0`), a made-up `publicdomain/zero/2.0/`, a bare "public domain" mention, and a dedication below the scanned head are all still refused.
89
131
 
90
132
  ### Security
91
133
 
@@ -108,6 +150,10 @@
108
150
  ### Performance
109
151
 
110
152
  - **`getAllDiff` spawned one `git hash-object` per untracked file, uncapped.** Its sibling `getDiffForFilesAgainstHead` was reshaped into classify → one batched hash → render for exactly this reason; this path kept the per-file form, and it is the only path behind `tree create`, `tree list`, `tree remove --all`, `lint` and `export-all` — against a Firefox tree where "untracked" can mean a very large number of files. Now batched. Everything observable is deliberately unchanged (renames, `ls-files` ordering, tracked-block-first emission, the `'\n'` empty sentinel) because this output is SHA-256'd into every tree fingerprint and written verbatim into `.patch` files; a new real-git test pins those bytes, which nothing did before.
153
+ - **A dirty `jar.mn` escalated EVERY `test --build` to a full `mach build`, and a dirty `moz.build` re-ran `mach configure` every time — including immediately after the full build that had consumed exactly that content.** A downstream session measured it: 15:00 for the first full build, 14:03 for the next `test --build` because "`jar.mn` changed relative to its own successful-build record", and 17 s for the CSS-only fix that followed. Two of those three builds were the same work twice.
154
+ The cause is what "changed" meant. `collectChangedEnginePaths` unions the baseline-to-HEAD diff with every WORKTREE modification, and a fork's worktree is permanently dirty — imported patches plus Furnace-applied components are a standing diff against HEAD — so a patch-touched `jar.mn` was "changed" on every run for as long as the patch existed. The baseline already solved this for packageable files (`packageableFingerprints`, 0.40.0) and for `components.conf` (the static-components anchor), and both consumers compare content, not dirtiness; the build inputs were the one family with no fingerprint, because `isPackageablePath` excludes them by design and nothing else recorded them.
155
+ `last-build.json` now carries `buildInputFingerprints` — a content hash per dirty `jar.mn` / `moz.build` / `moz.configure` / `Makefile.in` / `mozbuild.in` — and `build-prepare` drops any input whose live hash still matches before deciding either escalation. The two halves refresh on different rules, and the difference is load-bearing: backend inputs are refreshed on every successful write, because every baseline-writing path ran the auto-configure preflight against the previous record first; `jar.mn` entries are refreshed only by a FULL build and carried forward verbatim on a `faster` one, because `fireforge build --ui` is a `mach build faster` that never escalates, and recording a `jar.mn` no full build has installed would silence the next escalation. `build --ui` therefore now says which kind of build it was (`writeBuildBaseline`'s new `buildKind`), and passes the previous baseline it was already holding. An unreadable file, a missing entry, or a marker written before the field existed all count as changed — "cannot prove unchanged" never becomes "unchanged". The stale-build preflight's hash loop became the shared `dropPathsMatchingFingerprints` so both consumers compare the same way.
156
+ **On the escalation itself** — the downstream ask was a scoped install-manifest refresh for jar-only registrations, or a reason full compilation is necessary. Read against mozbuild: every `mach build`, `faster` included, first runs `backend_out_of_date` and regenerates the backend through `config.status` when any `backend.*Backend.in` dependency is newer, and `jar.mn` files are such dependencies; the FasterMake backend consumes `JARManifest` objects for `dist/bin` targets into its own install manifests. So for an entry added to an EXISTING `jar.mn` under `dist/bin` — the Furnace widget case — a `faster` build likely installs the destination, and the 0.41.0 rule ("full build for new `jar.mn` registrations") looks over-broad. It is NOT relaxed here: the rule came from an observed failure whose specifics were not recorded, and the claim needs a real objdir to prove, which a source reading is not. The seam is already available to prove it with: `fireforge build --ui` never escalates, so a jar-only registration followed by `build --ui`, a check that `obj-*/faster/install_dist_bin` names the new destination and the file landed under `dist/bin`, then a plain `fireforge test`, is the experiment; two clean runs of it make narrowing the escalation (to new `jar.mn` files and non-`dist/bin` targets) a safe change. Recorded in `docs/testing.md` so the next reader inherits the analysis rather than the guess. Also recorded, not fixed: `build --ui` ignores `fullBuildRequired` and `package` runs `prepareBuildEnvironment` with no baseline at all, so neither auto-configures nor escalates.
111
157
 
112
158
  ### Drift collapsed
113
159
 
@@ -161,6 +207,7 @@
161
207
  - `RebasePatchEntry` became a discriminated union on `status`, so a status flip can no longer strand the previous status's payload — which `rebase --continue` did, persisting `{ status: 'resolved', error, conflictingFiles }` to the session file. The on-disk validator is deliberately NOT tightened to match: the session file carries no schema version, so a stricter validator would refuse a 0.43.x file mid-rebase, where the only remedies discard the operator's conflict resolution. Entries are normalized on read instead, and two tests pin that an older session still loads.
162
208
  - **A `patches.json` from a newer FireForge was reported as malformed.** `patches.json version must be 1` sent the operator looking for damage in a file that only needed a newer build to read it, and the manifest had no migration hook at all. It now refuses by name — naming the found and supported schema versions and telling the operator not to delete the file — while a version below 1 or non-integer keeps the plain malformed message, because that one really is malformed.
163
209
  - The three JSDoc walkers in `patch-lint-jsdoc.ts` return their issues instead of pushing into an out-param, matching the ~30 other patch-lint entry points. The new `jsdoc/check-param-names` rule immediately caught the stale `@param issues` the change left behind — which is the point of enabling it.
210
+ - **`furnace create --test-dir` was honoured by the scaffolder and ignored by everything that told the operator where the files went.** `create.ts` resolved the override once and passed it to the scaffolders, but `formatDryRunPlan` and `formatSuccessNote` received only `binaryName` and recomputed the DEFAULT from it — so `--dry-run` planned `test/hominis/` plus `hominis/browser.toml` in `moz.build` for a command that would write to `test/hominis-tiles/`, and the success note after the real run named `hominis/` while the files sat in `hominis-tiles/`. The one message an operator gets as confirmation was the one thing that could not be trusted. Both formatters now take the resolved directory, and the default lives in one place: `resolveBrowserChromeTestDir` / `resolveXpcshellTestDir` in `furnace-constants.ts`, called by the scaffolders AND the formatters, so the two cannot disagree again. The moz.build registration line follows the override too (it is the path below `browser/base/content/test/`, which under `--test-dir` is not the binary name), and a test scaffolds for real and asserts the printed directory is the one on disk — default and overridden, browser-chrome and xpcshell.
164
211
 
165
212
  ### Windows
166
213
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.3",
4
- "commit": "760172104f6bfe523f63adfe08b8c6e63fd5d1f0",
5
- "shortCommit": "760172104f6b",
3
+ "version": "0.45.0",
4
+ "commit": "19d461eb43b89962970510f9a2e655704e9fb952",
5
+ "shortCommit": "19d461eb43b8",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-08-28T15:34:58.794Z"
8
+ "buildTime": "2026-09-01T10:47:47.465Z"
9
9
  }
@@ -342,13 +342,18 @@ async function runBuildCommandBody(projectRoot, options) {
342
342
  // Record a fresh baseline only on clean success so the next run audits
343
343
  // against this build's HEAD. A failed build keeps the prior baseline so
344
344
  // the next attempt still catches long-standing packaging drops.
345
- await recordFullBuildBaseline(projectRoot, paths.engine, config.binaryName, options.ui === true);
345
+ await recordFullBuildBaseline(projectRoot, paths.engine, config.binaryName, options.ui === true, previousBaseline);
346
346
  outro(`Build completed in ${timeStr}!`);
347
347
  }
348
- /** Persists the full-coverage baseline; a failed write never fails the build. */
349
- async function recordFullBuildBaseline(projectRoot, engineDir, binaryName, ui) {
348
+ /**
349
+ * Persists the full-coverage baseline; a failed write never fails the build.
350
+ * `--ui` is a `mach build faster`, so it is recorded as such: its `jar.mn`
351
+ * fingerprints are carried forward from the previous baseline rather than
352
+ * refreshed, which is why that baseline is threaded through.
353
+ */
354
+ async function recordFullBuildBaseline(projectRoot, engineDir, binaryName, ui, previousBaseline) {
350
355
  try {
351
- await writeBuildBaseline(projectRoot, engineDir, binaryName, 'full', undefined, ui ? 'fireforge build --ui' : 'fireforge build');
356
+ await writeBuildBaseline(projectRoot, engineDir, binaryName, 'full', previousBaseline, ui ? 'fireforge build --ui' : 'fireforge build', 'auto', ui ? 'faster' : 'full');
352
357
  }
353
358
  catch (baselineError) {
354
359
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Doctor check comparing the CONFIGURED Firefox source pin against what the
3
+ * engine checkout actually is.
4
+ *
5
+ * `fireforge source set` lands `firefox.version` inside `fireforge.json`,
6
+ * beside hand-maintained policy sections. That is convenient and it is also
7
+ * the whole problem: a routine `git checkout -- fireforge.json` — undoing an
8
+ * accidental reformat, say — silently reverts an uncommitted pin along with
9
+ * it, and nothing afterwards reports that the pin and the checkout have
10
+ * diverged. The tell in the field was a gate flipping green with no change
11
+ * that should have made it green.
12
+ *
13
+ * This is deliberately not a lock and not a refusal: a tree may legitimately
14
+ * be mid-migration between two versions. It only makes a mismatched tree
15
+ * VISIBLE, which is all that was missing.
16
+ *
17
+ * Scope is limited by what is actually recorded. `firefox.product`,
18
+ * `firefox.sha256` and `firefox.candidate` have NO counterpart anywhere on
19
+ * disk — `.fireforge/state.json` records only `downloadedVersion` and
20
+ * `baseCommit` — so there is nothing to compare them against and the check
21
+ * says nothing about them rather than implying it verified them.
22
+ */
23
+ import type { DoctorCheckDefinition } from './doctor-check-core.js';
24
+ /**
25
+ * Pin-vs-checkout reporting. Needs the loaded config, so it declares the
26
+ * dependency on the config check that populates `ctx.config`; skipped
27
+ * entirely without an engine, where there is nothing to compare.
28
+ */
29
+ export declare const SOURCE_PIN_DOCTOR_CHECK: DoctorCheckDefinition;
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Doctor check comparing the CONFIGURED Firefox source pin against what the
4
+ * engine checkout actually is.
5
+ *
6
+ * `fireforge source set` lands `firefox.version` inside `fireforge.json`,
7
+ * beside hand-maintained policy sections. That is convenient and it is also
8
+ * the whole problem: a routine `git checkout -- fireforge.json` — undoing an
9
+ * accidental reformat, say — silently reverts an uncommitted pin along with
10
+ * it, and nothing afterwards reports that the pin and the checkout have
11
+ * diverged. The tell in the field was a gate flipping green with no change
12
+ * that should have made it green.
13
+ *
14
+ * This is deliberately not a lock and not a refusal: a tree may legitimately
15
+ * be mid-migration between two versions. It only makes a mismatched tree
16
+ * VISIBLE, which is all that was missing.
17
+ *
18
+ * Scope is limited by what is actually recorded. `firefox.product`,
19
+ * `firefox.sha256` and `firefox.candidate` have NO counterpart anywhere on
20
+ * disk — `.fireforge/state.json` records only `downloadedVersion` and
21
+ * `baseCommit` — so there is nothing to compare them against and the check
22
+ * says nothing about them rather than implying it verified them.
23
+ */
24
+ import { getFirefoxVersion } from '../core/firefox.js';
25
+ import { toError } from '../utils/errors.js';
26
+ import { ok, warning } from './doctor-check-core.js';
27
+ const CHECK_NAME = 'Source pin matches engine';
28
+ /**
29
+ * Reports the configured pin beside the two facts that can contradict it:
30
+ * the engine's own `browser/config/version.txt`, and the version the last
31
+ * download recorded into state.
32
+ */
33
+ async function runSourcePinCheck(ctx) {
34
+ const pinned = ctx.config?.firefox.version;
35
+ if (pinned === undefined) {
36
+ // The config failed to load (its own check already reported that), so
37
+ // there is no pin to compare. Silence beats a second complaint.
38
+ return ok(CHECK_NAME, 'Skipped: no configured source version');
39
+ }
40
+ let actual;
41
+ try {
42
+ // A blank or whitespace-only version.txt (a truncated write, a partial
43
+ // extraction) carries no version to compare — treat it as absent rather
44
+ // than reporting the engine as being at version "".
45
+ actual = (await getFirefoxVersion(ctx.paths.engine))?.trim() || undefined;
46
+ }
47
+ catch (error) {
48
+ // Best-effort: an unreadable version.txt is not a pin mismatch, and a
49
+ // diagnostic must never turn into a failure of its own.
50
+ return ok(CHECK_NAME, `Configured ${pinned}; engine version could not be read (${toError(error).message})`);
51
+ }
52
+ const downloaded = ctx.state.downloadedVersion;
53
+ const mismatches = [];
54
+ if (actual !== undefined && actual !== pinned) {
55
+ mismatches.push(`engine/browser/config/version.txt reads ${actual}`);
56
+ }
57
+ if (downloaded !== undefined && downloaded !== pinned) {
58
+ mismatches.push(`the last download recorded ${downloaded}`);
59
+ }
60
+ if (mismatches.length === 0) {
61
+ const observed = actual ?? downloaded;
62
+ return ok(CHECK_NAME, observed === undefined
63
+ ? `Configured ${pinned}; no engine version to compare against yet`
64
+ : `Configured ${pinned}, engine ${observed}`);
65
+ }
66
+ return warning(CHECK_NAME, `fireforge.json pins Firefox ${pinned}, but ${mismatches.join(' and ')}.`, 'If the pin is right, run "fireforge download" to bring the checkout to it. If the ' +
67
+ 'CHECKOUT is right, run "fireforge source set --version <version>" to record it — a ' +
68
+ 'pin set but not committed is lost by any revert of fireforge.json, which is the most ' +
69
+ 'common way these diverge.');
70
+ }
71
+ /**
72
+ * Pin-vs-checkout reporting. Needs the loaded config, so it declares the
73
+ * dependency on the config check that populates `ctx.config`; skipped
74
+ * entirely without an engine, where there is nothing to compare.
75
+ */
76
+ export const SOURCE_PIN_DOCTOR_CHECK = {
77
+ name: CHECK_NAME,
78
+ skipIf: (ctx) => !ctx.engineExists,
79
+ dependsOn: ['fireforge.json is valid'],
80
+ run: runSourcePinCheck,
81
+ };
@@ -20,6 +20,7 @@ import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
20
20
  import { ORPHANED_HARNESS_DOCTOR_CHECK } from './doctor-orphaned-harness.js';
21
21
  import { PATCH_MANIFEST_CONSISTENCY_CHECK } from './doctor-patch-manifest.js';
22
22
  import { POST_REBASE_AUDIT_CHECK } from './doctor-post-rebase-audit.js';
23
+ import { SOURCE_PIN_DOCTOR_CHECK } from './doctor-source-pin.js';
23
24
  import { inspectEngineWorkingTree } from './doctor-working-tree.js';
24
25
  import { collectPatchQueueHealth } from './verify.js';
25
26
  /**
@@ -211,6 +212,7 @@ const DOCTOR_CHECKS = [
211
212
  return failure('Pending Resolution', `You are currently resolving a conflict for patch ${patchFilename}.`, 'Build and Export commands may behave unexpectedly until "fireforge resolve" is completed. If the queue now verifies cleanly, run "fireforge doctor --clear-resolution" to discard the stale marker.');
212
213
  },
213
214
  },
215
+ SOURCE_PIN_DOCTOR_CHECK,
214
216
  {
215
217
  name: 'Engine is git repository',
216
218
  skipIf: (ctx) => !ctx.engineExists,
@@ -12,12 +12,12 @@ import {} from '../core/destructive.js';
12
12
  import { detectMovedCodeOverlaps, formatAdoptThenSplitRemedy, } from '../core/export-moved-code-shape.js';
13
13
  import { normalizePatchArtifact } from '../core/patch-artifact-normalize.js';
14
14
  import { findAllPatchesForFilesWithDetails, patchNameSlug, planExport, } from '../core/patch-export.js';
15
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
15
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, lintPatchQueue, } from '../core/patch-lint.js';
16
16
  import { withPatchDirectoryLock } from '../core/patch-lock.js';
17
17
  import { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
18
18
  import { requirePatchOrder } from '../core/patch-parse.js';
19
19
  import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
20
- import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
20
+ import { buildNewFileTextProjection } from '../core/patch-transform.js';
21
21
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
22
22
  import { toError } from '../utils/errors.js';
23
23
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
@@ -166,21 +166,6 @@ export async function resolvePlacementPlan(patchesDir, options, category, name,
166
166
  assertPlacementAvoidsReservedRanges(plan, existingPatches, config);
167
167
  return plan;
168
168
  }
169
- /**
170
- * Extracts the newly-created files a diff would produce and builds the
171
- * `newFiles` map in the shape expected by {@link PatchQueueEntry}. Used
172
- * to build a faithful synthetic entry for the pending patch when
173
- * projecting through cross-patch lint — without this the forward-import
174
- * rule cannot see imports authored by the new patch itself.
175
- */
176
- function buildNewFilesFromDiff(diff) {
177
- const newFiles = new Map();
178
- const newFilePaths = detectNewFilesInDiff(diff);
179
- for (const path of newFilePaths) {
180
- newFiles.set(path, extractNewFileContentFromDiff(diff, path));
181
- }
182
- return newFiles;
183
- }
184
169
  /**
185
170
  * Projects the placement through cross-patch lint to detect forward-imports
186
171
  * the renumber would introduce *or* that the new patch itself would
@@ -206,7 +191,7 @@ export async function projectPlacementForLint(patchesDir, plan, diff, config) {
206
191
  order: plan.insertionOrder,
207
192
  metadata: null,
208
193
  diff,
209
- newFiles: buildNewFilesFromDiff(diff),
194
+ newFiles: buildNewFileTextProjection(diff),
210
195
  modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff),
211
196
  });
212
197
  projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
@@ -14,6 +14,15 @@ import type { ResolvedTestStyle } from '../../types/furnace.js';
14
14
  * tree, so a redirect makes no sense there.
15
15
  */
16
16
  export declare function resolveValidatedTestDir(rawTestDir: string | undefined, testStyle: ResolvedTestStyle): string | undefined;
17
+ /**
18
+ * Scaffolds browser mochitest files for a newly created custom component.
19
+ * @param componentName - Custom element tag name
20
+ * @param license - Project license used for generated headers
21
+ * @param forgeConfig - Project config fields needed for test naming
22
+ * @param paths - Resolved project paths used to place test files
23
+ * @param journal - Optional rollback journal that snapshots files before writes
24
+ * @returns Relative test filenames created or updated for the component
25
+ */
17
26
  /**
18
27
  * Normalizes and validates a `--test-dir` override: engine-relative,
19
28
  * under `browser/base/content/test/` (so manifest registration keeps
@@ -7,6 +7,7 @@
7
7
  * the per-file line budget.
8
8
  */
9
9
  import { join } from 'node:path';
10
+ import { BROWSER_TEST_SCAFFOLD_ROOT, resolveBrowserChromeTestDir, } from '../../core/furnace-constants.js';
10
11
  import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
11
12
  import { getLicenseHeader } from '../../core/license-headers.js';
12
13
  import { registerTestManifest } from '../../core/moz-manifest-register.js';
@@ -40,7 +41,6 @@ export function resolveValidatedTestDir(rawTestDir, testStyle) {
40
41
  * @param journal - Optional rollback journal that snapshots files before writes
41
42
  * @returns Relative test filenames created or updated for the component
42
43
  */
43
- const TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
44
44
  /**
45
45
  * Normalizes and validates a `--test-dir` override: engine-relative,
46
46
  * under `browser/base/content/test/` (so manifest registration keeps
@@ -48,9 +48,9 @@ const TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
48
48
  */
49
49
  export function resolveTestDirOverride(raw) {
50
50
  const normalized = stripEnginePrefix(raw).replace(/\/+$/, '');
51
- if (!normalized.startsWith(TEST_SCAFFOLD_ROOT) ||
52
- normalized === TEST_SCAFFOLD_ROOT.slice(0, -1)) {
53
- throw new InvalidArgumentError(`--test-dir must be an engine-relative directory under ${TEST_SCAFFOLD_ROOT} (got "${raw}").`, 'testDir');
51
+ if (!normalized.startsWith(BROWSER_TEST_SCAFFOLD_ROOT) ||
52
+ normalized === BROWSER_TEST_SCAFFOLD_ROOT.slice(0, -1)) {
53
+ throw new InvalidArgumentError(`--test-dir must be an engine-relative directory under ${BROWSER_TEST_SCAFFOLD_ROOT} (got "${raw}").`, 'testDir');
54
54
  }
55
55
  return normalized;
56
56
  }
@@ -78,8 +78,8 @@ export async function scaffoldTestFiles(componentName, license, forgeConfig, pat
78
78
  // `.../test/<binaryName>/` target can collide with a test suite owned by a
79
79
  // different patch. The manifest-registration name is the path below
80
80
  // browser/base/content/test/ (nested manifests are supported).
81
- const testDirRel = testDirOverride ?? `${TEST_SCAFFOLD_ROOT}${binaryName}`;
82
- const testDirName = testDirRel.slice(TEST_SCAFFOLD_ROOT.length);
81
+ const testDirRel = resolveBrowserChromeTestDir(binaryName, testDirOverride);
82
+ const testDirName = testDirRel.slice(BROWSER_TEST_SCAFFOLD_ROOT.length);
83
83
  const testDir = join(paths.engine, testDirRel);
84
84
  if (journal && !(await pathExists(testDir))) {
85
85
  recordCreatedDir(journal, testDir);
@@ -15,6 +15,14 @@ export interface DryRunPlanInput {
15
15
  testStyle: ResolvedTestStyle;
16
16
  description: string;
17
17
  binaryName: string;
18
+ /**
19
+ * Resolved `--test-dir` override (engine-relative, already validated by
20
+ * `resolveValidatedTestDir`). Omit for the default `<binaryName>`-derived
21
+ * scaffold directory. The formatters MUST receive the same value the
22
+ * scaffolders get — the plan used to recompute the default from
23
+ * `binaryName` and named a directory the real run never wrote to.
24
+ */
25
+ testDir?: string;
18
26
  }
19
27
  /**
20
28
  * Builds the success-note body printed after `furnace create` has applied
@@ -27,6 +35,8 @@ export declare function formatSuccessNote(args: {
27
35
  testFiles: string[];
28
36
  testStyle: ResolvedTestStyle;
29
37
  binaryName: string;
38
+ /** Resolved `--test-dir` override; see {@link DryRunPlanInput.testDir}. */
39
+ testDir?: string | undefined;
30
40
  }): string;
31
41
  /**
32
42
  * Builds the planned component + test file list for a dry-run preview.
@@ -7,6 +7,7 @@
7
7
  * resolved by the command's validation phase — so it can be exercised
8
8
  * independently of the mutation plumbing.
9
9
  */
10
+ import { BROWSER_TEST_SCAFFOLD_ROOT, resolveBrowserChromeTestDir, resolveXpcshellTestDir, } from '../../core/furnace-constants.js';
10
11
  /**
11
12
  * Builds the test-section fragment of the dry-run plan for a given
12
13
  * harness choice. Kept separate from the top-level formatter so the
@@ -14,7 +15,7 @@
14
15
  * complexity budget.
15
16
  */
16
17
  function formatTestSection(args) {
17
- const { testStyle, componentName, binaryName } = args;
18
+ const { testStyle, componentName, binaryName, testDir } = args;
18
19
  if (testStyle === 'none')
19
20
  return '';
20
21
  const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
@@ -23,14 +24,18 @@ function formatTestSection(args) {
23
24
  : strippedName;
24
25
  const underscored = withoutBinaryPrefix.replace(/-/g, '_');
25
26
  if (testStyle === 'browser-chrome') {
26
- const testRoot = `engine/browser/base/content/test/${binaryName}/`;
27
- return (`\n\nWould create test files in ${testRoot}:\n` +
27
+ // Same resolver as scaffoldTestFiles: the registration name is the
28
+ // path below browser/base/content/test/, which under --test-dir is
29
+ // NOT the binary name.
30
+ const testDirRel = resolveBrowserChromeTestDir(binaryName, testDir);
31
+ const manifestName = testDirRel.slice(BROWSER_TEST_SCAFFOLD_ROOT.length);
32
+ return (`\n\nWould create test files in engine/${testDirRel}/:\n` +
28
33
  ` browser.toml\n head.js\n browser_${binaryName}_${underscored}.js` +
29
- `\n\nWould register ${binaryName}/browser.toml in engine/browser/base/moz.build`);
34
+ `\n\nWould register ${manifestName}/browser.toml in engine/browser/base/moz.build`);
30
35
  }
31
36
  if (testStyle === 'xpcshell') {
32
- const testRoot = `engine/browser/base/content/test/${binaryName}-xpcshell/${componentName}/`;
33
- return `\n\nWould create xpcshell test files in ${testRoot}`;
37
+ const testDirRel = resolveXpcshellTestDir(binaryName, componentName, testDir);
38
+ return `\n\nWould create xpcshell test files in engine/${testDirRel}/`;
34
39
  }
35
40
  // testStyle === 'mochikit' (last remaining branch in ResolvedTestStyle).
36
41
  const testRoot = 'engine/toolkit/content/tests/widgets/';
@@ -42,19 +47,19 @@ function formatTestSection(args) {
42
47
  * stay in lock-step when the scaffolded layout changes.
43
48
  */
44
49
  export function formatSuccessNote(args) {
45
- const { componentName, files, testFiles, testStyle, binaryName } = args;
50
+ const { componentName, files, testFiles, testStyle, binaryName, testDir } = args;
46
51
  let note = `Files created in components/custom/${componentName}/:\n` +
47
52
  files.map((f) => ` ${f}`).join('\n');
48
53
  if (testFiles.length > 0) {
49
54
  let testRoot;
50
55
  if (testStyle === 'xpcshell') {
51
- testRoot = `engine/browser/base/content/test/${binaryName}-xpcshell/${componentName}/`;
56
+ testRoot = `engine/${resolveXpcshellTestDir(binaryName, componentName, testDir)}/`;
52
57
  }
53
58
  else if (testStyle === 'mochikit') {
54
59
  testRoot = 'engine/toolkit/content/tests/widgets/';
55
60
  }
56
61
  else {
57
- testRoot = `engine/browser/base/content/test/${binaryName}/`;
62
+ testRoot = `engine/${resolveBrowserChromeTestDir(binaryName, testDir)}/`;
58
63
  }
59
64
  note += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
60
65
  }
@@ -75,7 +80,7 @@ export function formatSuccessNote(args) {
75
80
  * `components/custom/` to match the wording of the real success note.
76
81
  */
77
82
  export function formatDryRunPlan(args) {
78
- const { componentName, localized, register, composes, stockAdditions, sharedFtl, testStyle, description, binaryName, } = args;
83
+ const { componentName, localized, register, composes, stockAdditions, sharedFtl, testStyle, description, binaryName, testDir, } = args;
79
84
  const componentFiles = [`${componentName}.mjs`, `${componentName}.css`];
80
85
  // A per-component .ftl is scaffolded only when the component does NOT
81
86
  // opt into a shared feature-scoped bundle. Mirrors writeComponentFiles.
@@ -83,7 +88,7 @@ export function formatDryRunPlan(args) {
83
88
  componentFiles.push(`${componentName}.ftl`);
84
89
  let plan = `Would create files in components/custom/${componentName}/:\n` +
85
90
  componentFiles.map((f) => ` ${f}`).join('\n');
86
- plan += formatTestSection({ testStyle, componentName, binaryName });
91
+ plan += formatTestSection({ testStyle, componentName, binaryName, testDir });
87
92
  plan += `\n\nWould add custom entry to furnace.json:`;
88
93
  plan += `\n name: ${componentName}`;
89
94
  plan += `\n description: ${description || '(empty)'}`;
@@ -5,7 +5,7 @@
5
5
  * per-file LOC budget and the scaffolder is unit-testable in isolation.
6
6
  */
7
7
  import { join } from 'node:path';
8
- import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
8
+ import { resolveXpcshellTestDir, xpcshellTestParentDir } from '../../core/furnace-constants.js';
9
9
  import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
10
10
  import { getLicenseHeader } from '../../core/license-headers.js';
11
11
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
@@ -31,7 +31,7 @@ export async function scaffoldXpcshellTestFiles(componentName, license, forgeCon
31
31
  const parentDirName = parentRelDir.split('/').slice(-1)[0] ?? `${forgeConfig.binaryName}-xpcshell`;
32
32
  // --test-dir names the FINAL directory (no per-component segment is
33
33
  // appended) so the operator controls the exact scaffold target.
34
- const testDirRel = testDirOverride ?? `${parentRelDir}/${componentName}`;
34
+ const testDirRel = resolveXpcshellTestDir(forgeConfig.binaryName, componentName, testDirOverride);
35
35
  const testDir = join(paths.engine, testDirRel);
36
36
  if (journal && !(await pathExists(testDir))) {
37
37
  recordCreatedDir(journal, testDir);
@@ -356,6 +356,10 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
356
356
  testStyle,
357
357
  description,
358
358
  binaryName: forgeConfig.binaryName,
359
+ // The plan must name the directory the scaffolder will actually use:
360
+ // an omitted override here is how the printed path came to disagree
361
+ // with the files a real run wrote.
362
+ ...(testDir !== undefined ? { testDir } : {}),
359
363
  });
360
364
  note(plan, componentName);
361
365
  outro('Dry run complete (no files modified)');
@@ -391,6 +395,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
391
395
  testFiles,
392
396
  testStyle,
393
397
  binaryName: forgeConfig.binaryName,
398
+ ...(testDir !== undefined ? { testDir } : {}),
394
399
  }), componentName);
395
400
  outro('Component created');
396
401
  }
@@ -17,11 +17,11 @@
17
17
  import { join } from 'node:path';
18
18
  import { appendHistory, assertConfirmationAvailable, confirmDestructive, } from '../../core/destructive.js';
19
19
  import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
20
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
20
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
21
21
  import { computeProjectedLintRegressions } from '../../core/patch-lint-projection.js';
22
22
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
23
23
  import { loadPatchesManifest, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
24
- import { extractNewFileContentFromDiff } from '../../core/patch-transform.js';
24
+ import { buildNewFileTextProjection } from '../../core/patch-transform.js';
25
25
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
26
26
  import { toError } from '../../utils/errors.js';
27
27
  import { readText, writeText } from '../../utils/fs.js';
@@ -30,10 +30,7 @@ import { resolveWaitLockSeconds } from '../../utils/options.js';
30
30
  import { runPatchLint } from '../export-shared.js';
31
31
  import { buildSplitDiff, findOwnerRewriteHolders, mergeStagedForwardImports, rewriteSplitOwners, } from './split-plan.js';
32
32
  function projectEntryBody(diff) {
33
- const newFiles = new Map();
34
- for (const path of detectNewFilesInDiff(diff)) {
35
- newFiles.set(path, extractNewFileContentFromDiff(diff, path));
36
- }
33
+ const newFiles = buildNewFileTextProjection(diff);
37
34
  return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
38
35
  }
39
36
  /**
@@ -10,12 +10,12 @@ import { join } from 'node:path';
10
10
  import {} from '../../core/destructive.js';
11
11
  import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
12
12
  import { extractAffectedFiles } from '../../core/patch-apply.js';
13
- import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, detectNewFilesInDiff, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
13
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, collectForwardImportEdges, formatPatchLintIssue, lintPatchQueue, } from '../../core/patch-lint.js';
14
14
  import { computeProjectedLintRegressions } from '../../core/patch-lint-projection.js';
15
15
  import { rewriteStagedDependencyOwners } from '../../core/patch-manifest.js';
16
16
  import { applyRenameMapToManifest, buildProjectedManifest } from '../../core/patch-policy.js';
17
17
  import { buildPatchSourceMetadata } from '../../core/patch-source-metadata.js';
18
- import { extractNewFileContentFromDiff } from '../../core/patch-transform.js';
18
+ import { buildNewFileTextProjection } from '../../core/patch-transform.js';
19
19
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
20
20
  import { pathExists } from '../../utils/fs.js';
21
21
  import { warn } from '../../utils/logger.js';
@@ -82,10 +82,7 @@ export function rewriteSplitOwners(patch, sourceFilename, movedSet, newFilename)
82
82
  };
83
83
  }
84
84
  function buildEntryProjection(diff) {
85
- const newFiles = new Map();
86
- for (const path of detectNewFilesInDiff(diff)) {
87
- newFiles.set(path, extractNewFileContentFromDiff(diff, path));
88
- }
85
+ const newFiles = buildNewFileTextProjection(diff);
89
86
  return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
90
87
  }
91
88
  /** Builds the projected post-split queue entries (renumber + shrunken source + new patch). */
@@ -10,6 +10,14 @@ export interface AdjacentUnmanagedContext {
10
10
  binaryName: string;
11
11
  furnacePrefixes: ReadonlySet<string>;
12
12
  refuseAdjacentUnmanaged: boolean;
13
+ /**
14
+ * Engine-relative paths named by `--expect-unmanaged`: reviewed, recorded
15
+ * exceptions that are reported but never refuse. Normalized once by the
16
+ * caller so the comparison matches git's engine-relative output.
17
+ */
18
+ approvedUnmanaged: ReadonlySet<string>;
19
+ /** Approved paths actually met this run, so unused ones can be surfaced. */
20
+ approvedSeen: Set<string>;
13
21
  refusals: {
14
22
  patchFilename: string;
15
23
  files: string[];