@hominis/fireforge 0.44.2 → 0.44.4

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 (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/build.js +9 -4
  4. package/dist/src/commands/furnace/create-browser-test.d.ts +9 -0
  5. package/dist/src/commands/furnace/create-browser-test.js +6 -6
  6. package/dist/src/commands/furnace/create-dry-run.d.ts +10 -0
  7. package/dist/src/commands/furnace/create-dry-run.js +16 -11
  8. package/dist/src/commands/furnace/create-xpcshell.js +2 -2
  9. package/dist/src/commands/furnace/create.js +5 -0
  10. package/dist/src/commands/test-build-phase.js +1 -1
  11. package/dist/src/commands/token.js +3 -1
  12. package/dist/src/core/build-audit.d.ts +20 -0
  13. package/dist/src/core/build-audit.js +25 -1
  14. package/dist/src/core/build-baseline-types.d.ts +28 -0
  15. package/dist/src/core/build-baseline.d.ts +16 -1
  16. package/dist/src/core/build-baseline.js +38 -2
  17. package/dist/src/core/build-prepare.js +32 -3
  18. package/dist/src/core/engine-changes.d.ts +18 -0
  19. package/dist/src/core/engine-changes.js +34 -0
  20. package/dist/src/core/furnace-constants.d.ts +21 -0
  21. package/dist/src/core/furnace-constants.js +26 -1
  22. package/dist/src/core/license-headers.d.ts +9 -5
  23. package/dist/src/core/license-headers.js +33 -7
  24. package/dist/src/core/test-stale-check.js +2 -13
  25. package/dist/src/core/token-variant.d.ts +12 -2
  26. package/dist/src/core/token-variant.js +106 -17
  27. package/docs/README.md +35 -0
  28. package/docs/configuration.md +116 -0
  29. package/docs/exit-codes.md +63 -0
  30. package/docs/furnace.md +57 -0
  31. package/docs/lifecycle-invariants.md +309 -0
  32. package/docs/machine-output.md +94 -0
  33. package/docs/patch-workflow.md +119 -0
  34. package/docs/run-logs.md +60 -0
  35. package/docs/testing.md +123 -0
  36. package/docs/verification-trees.md +48 -0
  37. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -86,6 +86,8 @@
86
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
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
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
+ - **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.
90
+ 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
91
 
90
92
  ### Security
91
93
 
@@ -108,6 +110,10 @@
108
110
  ### Performance
109
111
 
110
112
  - **`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.
113
+ - **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.
114
+ 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.
115
+ `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.
116
+ **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
117
 
112
118
  ### Drift collapsed
113
119
 
@@ -149,6 +155,9 @@
149
155
  - **`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.
150
156
  - **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.
151
157
  - `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.
158
+ - **`--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.
159
+ 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.
160
+ `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.
152
161
  - `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`.
153
162
 
154
163
  ### More correctness
@@ -158,6 +167,7 @@
158
167
  - `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.
159
168
  - **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.
160
169
  - 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.
170
+ - **`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.
161
171
 
162
172
  ### Windows
163
173
 
@@ -180,6 +190,8 @@ On the test side: path expectations and mock keys are rendered through a new `na
180
190
  - **`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.
181
191
  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.
182
192
  - 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.
193
+ - **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.
194
+ - **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.
183
195
  - `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.
184
196
 
185
197
  ### Documentation
@@ -187,6 +199,7 @@ On the test side: path expectations and mock keys are rendered through a new `na
187
199
  - **README.md had become the reference manual.** Its "Regular Workflow" section had grown into fourteen paragraphs of flag semantics — scan manifests, shard behaviour, packaging-coverage anchors, tree CoW, shim configuration — read by nobody starting out and found by nobody looking for a specific rule, because prose in a getting-started page has no index. The reference material moves to `docs/`, split by the thing a reader is actually doing: `patch-workflow.md`, `testing.md`, `furnace.md`, `verification-trees.md`, `configuration.md`, plus `docs/README.md` as the index. README keeps installation, the loop's shape, rebasing, Furnace's premise, the roadmap and the licence — the parts that are project statement rather than manual — and links out for the rest.
188
200
  - **`docs/run-logs.md`** states the `.fireforge/logs/` contract that previously existed only as a table cell in `machine-output.md`: raw bytes rather than the echo filter's form, opened before any preflight, retained 20 deep per command kind, best-effort throughout, and the tee sitting at the two mach capture funnels rather than at the commands.
189
201
  - 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.
202
+ - **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.
190
203
  - `FIREFORGE_WAIT_LOCK` and the three other environment variables FireForge reads were documented only in help text and JSDoc. They are tabulated in `configuration.md` with their defaults and their behaviour on a malformed value.
191
204
 
192
205
  ## 0.43.0
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.2",
4
- "commit": "d653751d6c8a1be154647943dc346e81cc62b390",
5
- "shortCommit": "d653751d6c8a",
3
+ "version": "0.44.4",
4
+ "commit": "b8742ddc056124f81624d9c13d182d0b0c57c86c",
5
+ "shortCommit": "b8742ddc0561",
6
6
  "dirty": false,
7
7
  "dirtyHash": null,
8
- "buildTime": "2026-08-28T13:39:36.337Z"
8
+ "buildTime": "2026-08-28T23:48:06.918Z"
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}`);
@@ -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
  }
@@ -84,7 +84,7 @@ async function runPreTestBuild(projectRoot, paths, projectConfig, harnessRetries
84
84
  ? 'refresh'
85
85
  : extend !== undefined
86
86
  ? 'carry-forward'
87
- : 'auto');
87
+ : 'auto', buildKind);
88
88
  }
89
89
  catch (baselineError) {
90
90
  verbose(`Could not persist build baseline: ${toError(baselineError).message}`);
@@ -180,7 +180,9 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
180
180
  .option('--description <desc>', 'Comment description for the CSS file')
181
181
  .option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
182
182
  .option('--variant <selector>', 'Attribute selector fragment routing the declaration into a `:root<selector>` block ' +
183
- "(e.g. '[data-skin=precision]' or '[data-private]'); creates or updates the block. " +
183
+ "(e.g. '[data-skin=precision]', '[data-private]', a run such as " +
184
+ "'[data-skin=precision][data-theme=dark]', or one with a pseudo-class tail such as " +
185
+ "'[data-skin=precision]:not([data-private])'); creates or updates the block. " +
184
186
  'CSS-only — cannot be combined with --mode override or --create-category. To author ' +
185
187
  'a new category and its variants, add the BASE token first (with --create-category), ' +
186
188
  'then add each variant with --variant.')
@@ -1,4 +1,24 @@
1
1
  import type { BuildBaseline } from './build-baseline-types.js';
2
+ /**
3
+ * True for the build-input manifests the build consumes but never packages
4
+ * (`jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, `mozbuild.in`).
5
+ * Excluded from the packaging audit and from `packageableFingerprints`;
6
+ * fingerprinted separately as `buildInputFingerprints` so `build-prepare`
7
+ * can tell "dirty against HEAD" from "changed since the last successful
8
+ * build".
9
+ *
10
+ * @param sourcePath Engine-relative POSIX path.
11
+ */
12
+ export declare function isBuildInputPath(sourcePath: string): boolean;
13
+ /**
14
+ * True for a chrome packaging manifest (`jar.mn`). Its install-manifest
15
+ * destinations are the one build input whose change escalates a pre-test
16
+ * `mach build faster` to a full build, and whose fingerprint is therefore
17
+ * refreshed only by a full build.
18
+ *
19
+ * @param sourcePath Engine-relative POSIX path.
20
+ */
21
+ export declare function isJarManifestPath(sourcePath: string): boolean;
2
22
  /** Result of a single artifact lookup. */
3
23
  export interface AuditEntry {
4
24
  /** Engine-relative source file path (POSIX separators). */
@@ -74,6 +74,30 @@ const BUILD_INPUT_BASENAMES = new Set([
74
74
  'Makefile.in',
75
75
  'mozbuild.in',
76
76
  ]);
77
+ /**
78
+ * True for the build-input manifests the build consumes but never packages
79
+ * (`jar.mn`, `moz.build`, `moz.configure`, `Makefile.in`, `mozbuild.in`).
80
+ * Excluded from the packaging audit and from `packageableFingerprints`;
81
+ * fingerprinted separately as `buildInputFingerprints` so `build-prepare`
82
+ * can tell "dirty against HEAD" from "changed since the last successful
83
+ * build".
84
+ *
85
+ * @param sourcePath Engine-relative POSIX path.
86
+ */
87
+ export function isBuildInputPath(sourcePath) {
88
+ return BUILD_INPUT_BASENAMES.has(basename(sourcePath));
89
+ }
90
+ /**
91
+ * True for a chrome packaging manifest (`jar.mn`). Its install-manifest
92
+ * destinations are the one build input whose change escalates a pre-test
93
+ * `mach build faster` to a full build, and whose fingerprint is therefore
94
+ * refreshed only by a full build.
95
+ *
96
+ * @param sourcePath Engine-relative POSIX path.
97
+ */
98
+ export function isJarManifestPath(sourcePath) {
99
+ return sourcePath === 'jar.mn' || sourcePath.endsWith('/jar.mn');
100
+ }
77
101
  /**
78
102
  * Decides whether a source path should be packaged. Returns true for paths
79
103
  * whose extension or directory fragment matches a known-packaged pattern,
@@ -88,7 +112,7 @@ export function isPackageablePath(sourcePath) {
88
112
  if (sourcePath.includes(fragment))
89
113
  return false;
90
114
  }
91
- if (BUILD_INPUT_BASENAMES.has(basename(sourcePath)))
115
+ if (isBuildInputPath(sourcePath))
92
116
  return false;
93
117
  // `.inc.xhtml` fragments are consumed via `#include` from a registered
94
118
  // chrome document and resolved at packaging time — they never ship as a
@@ -35,6 +35,34 @@ export interface BuildBaseline {
35
35
  * a tracked deletion.
36
36
  */
37
37
  packageableFingerprints?: Record<string, string>;
38
+ /**
39
+ * Content hash per build-input manifest (`jar.mn`, `moz.build`,
40
+ * `moz.configure`, `Makefile.in`, `mozbuild.in`) that was dirty
41
+ * (modified-against-HEAD or untracked) when the recorded build succeeded.
42
+ * Same key/value shape as {@link BuildBaseline.packageableFingerprints}.
43
+ *
44
+ * Consumed by `build-prepare`: a dirty build input whose live hash still
45
+ * matches its entry was already consumed by the recorded build, so it
46
+ * neither re-runs `mach configure` nor escalates a pre-test build to a
47
+ * full `mach build`. Without this the persistent worktree diff every
48
+ * fork carries (imported patches + Furnace-applied components) made a
49
+ * patch-touched `jar.mn` "changed" on EVERY run, and each `test --build`
50
+ * after a full build paid the full build again.
51
+ *
52
+ * Refresh rules, per entry kind:
53
+ * - backend inputs (`moz.build` & co) are refreshed on every successful
54
+ * write — every baseline-writing path ran the auto-configure preflight
55
+ * against the previous baseline, so a changed one was reconfigured;
56
+ * - `jar.mn` entries are refreshed only by a FULL build and carried
57
+ * forward verbatim from the previous record on a `faster` write, since
58
+ * `fireforge build --ui` never escalates and recording a `jar.mn` no
59
+ * full build has installed would hide the next escalation.
60
+ *
61
+ * Absent on baselines written before the field existed; `build-prepare`
62
+ * then falls back to the path-only comparison (every dirty build input
63
+ * counts as changed).
64
+ */
65
+ buildInputFingerprints?: Record<string, string>;
38
66
  /**
39
67
  * What the packaged test runtime produced by the recorded build covers.
40
68
  *
@@ -16,6 +16,15 @@
16
16
  * subsequent run still audits against the last known-good tree.
17
17
  */
18
18
  import { type BuildBaseline, type TestPackagingCoverage } from './build-baseline-types.js';
19
+ /**
20
+ * Which mach build produced the baseline being written. Decides whether
21
+ * the `jar.mn` half of {@link BuildBaseline.buildInputFingerprints} is
22
+ * refreshed (`'full'`) or carried forward from the previous record
23
+ * (`'faster'`). `fireforge build --ui` and a non-escalated `test --build`
24
+ * are `'faster'`; `fireforge build` and an escalated `test --build` are
25
+ * `'full'`.
26
+ */
27
+ export type BaselineBuildKind = 'full' | 'faster';
19
28
  /** Name of the last-build marker file under `.fireforge/`. */
20
29
  export declare const BUILD_BASELINE_FILENAME = "last-build.json";
21
30
  /**
@@ -56,5 +65,11 @@ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBas
56
65
  * keeps the previous anchor: needed by `--extend-coverage`, whose union
57
66
  * can EVALUATE to `'full'` while the build that produced it was still a
58
67
  * scoped `mach build faster` that did not rebake the compiled table.
68
+ * @param buildKind - Which mach build produced this baseline (default
69
+ * `'full'`). A `'faster'` write carries the previous record's `jar.mn`
70
+ * fingerprints forward instead of refreshing them — see
71
+ * {@link BuildBaseline.buildInputFingerprints}. Every caller that ran
72
+ * `mach build faster` MUST say so, or the next pre-test build skips an
73
+ * escalation no full build has honoured.
59
74
  */
60
- export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string, staticComponentsHandling?: 'auto' | 'refresh' | 'carry-forward'): Promise<void>;
75
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string, testPackagingCoverage?: TestPackagingCoverage, previousBaseline?: BuildBaseline, recordedBy?: string, staticComponentsHandling?: 'auto' | 'refresh' | 'carry-forward', buildKind?: BaselineBuildKind): Promise<void>;
@@ -23,7 +23,7 @@ import { mapWithConcurrency } from '../utils/concurrency.js';
23
23
  import { getNodeErrorCode, toError } from '../utils/errors.js';
24
24
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
25
25
  import { verbose } from '../utils/logger.js';
26
- import { isPackageablePath, isXpcomManifestPath } from './build-audit.js';
26
+ import { isBuildInputPath, isJarManifestPath, isPackageablePath, isXpcomManifestPath, } from './build-audit.js';
27
27
  import { DELETED_FILE_FINGERPRINT, } from './build-baseline-types.js';
28
28
  import { FIREFORGE_DIR } from './config-paths.js';
29
29
  import { hashEngineFile } from './coverage-extend.js';
@@ -90,8 +90,14 @@ export async function readBuildBaseline(projectRoot) {
90
90
  * keeps the previous anchor: needed by `--extend-coverage`, whose union
91
91
  * can EVALUATE to `'full'` while the build that produced it was still a
92
92
  * scoped `mach build faster` that did not rebake the compiled table.
93
+ * @param buildKind - Which mach build produced this baseline (default
94
+ * `'full'`). A `'faster'` write carries the previous record's `jar.mn`
95
+ * fingerprints forward instead of refreshing them — see
96
+ * {@link BuildBaseline.buildInputFingerprints}. Every caller that ran
97
+ * `mach build faster` MUST say so, or the next pre-test build skips an
98
+ * escalation no full build has honoured.
93
99
  */
94
- export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy, staticComponentsHandling = 'auto') {
100
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, testPackagingCoverage, previousBaseline, recordedBy, staticComponentsHandling = 'auto', buildKind = 'full') {
95
101
  let engineHeadSha = '';
96
102
  try {
97
103
  engineHeadSha = await getHead(engineDir);
@@ -106,6 +112,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
106
112
  }
107
113
  }
108
114
  const packageableFingerprints = await collectPackageableFingerprints(engineDir);
115
+ const buildInputFingerprints = await collectBuildInputFingerprints(engineDir, previousBaseline, buildKind);
109
116
  // A full-coverage write (and the legacy no-claim `fireforge build` shape)
110
117
  // just recompiled the StaticComponents table, so it anchors the table to
111
118
  // the current engine state. A scoped write did not — carry the previous
@@ -121,6 +128,7 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
121
128
  builtAt: new Date().toISOString(),
122
129
  binaryName,
123
130
  ...(packageableFingerprints !== undefined ? { packageableFingerprints } : {}),
131
+ ...(buildInputFingerprints !== undefined ? { buildInputFingerprints } : {}),
124
132
  ...(testPackagingCoverage !== undefined ? { testPackagingCoverage } : {}),
125
133
  ...(mozconfigHash !== undefined ? { mozconfigHash } : {}),
126
134
  ...(staticComponentsBaseline !== undefined ? { staticComponentsBaseline } : {}),
@@ -143,6 +151,34 @@ export async function writeBuildBaseline(projectRoot, engineDir, binaryName, tes
143
151
  async function collectPackageableFingerprints(engineDir) {
144
152
  return collectDirtyFingerprints(engineDir, isPackageablePath, 'packageable fingerprint');
145
153
  }
154
+ /**
155
+ * Content fingerprints of the dirty build-input manifests
156
+ * ({@link isBuildInputPath}) this build consumed. Backend inputs
157
+ * (`moz.build` & co) are always taken from the live tree: the
158
+ * auto-configure preflight reconfigured against them before this build
159
+ * ran. `jar.mn` entries are taken from the live tree only after a FULL
160
+ * build; a `faster` write carries the previous record's `jar.mn` entries
161
+ * forward verbatim, because `mach build faster` is exactly the build the
162
+ * `jar.mn` escalation exists to bypass, and recording a `jar.mn` it did
163
+ * not install would silence the next escalation. Returns `undefined` on
164
+ * probe failure so the field is omitted rather than written as `{}`.
165
+ */
166
+ async function collectBuildInputFingerprints(engineDir, previousBaseline, buildKind) {
167
+ const live = await collectDirtyFingerprints(engineDir, isBuildInputPath, 'build-input fingerprint');
168
+ if (live === undefined || buildKind === 'full') {
169
+ return live;
170
+ }
171
+ const fingerprints = {};
172
+ for (const [path, hash] of Object.entries(live)) {
173
+ if (!isJarManifestPath(path))
174
+ fingerprints[path] = hash;
175
+ }
176
+ for (const [path, hash] of Object.entries(previousBaseline?.buildInputFingerprints ?? {})) {
177
+ if (isJarManifestPath(path))
178
+ fingerprints[path] = hash;
179
+ }
180
+ return fingerprints;
181
+ }
146
182
  /**
147
183
  * Anchor for the compiled StaticComponents table: the engine HEAD SHA of
148
184
  * this (full) build plus content fingerprints of the `components.conf`
@@ -11,8 +11,9 @@ import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { info, notice, spinner, verbose, warn } from '../utils/logger.js';
13
13
  import { isBrandingSetup, setupBranding } from './branding.js';
14
+ import { isBuildInputPath, isJarManifestPath } from './build-audit.js';
14
15
  import { findUnexportedDriftAtRisk, formatUnexportedDriftWarning, } from './build-overwrite-guard.js';
15
- import { collectChangedEnginePaths } from './engine-changes.js';
16
+ import { collectChangedEnginePaths, dropPathsMatchingFingerprints } from './engine-changes.js';
16
17
  import { applyAllComponents } from './furnace-apply.js';
17
18
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
18
19
  import { runFurnaceMutation } from './furnace-operation.js';
@@ -24,7 +25,35 @@ import { explainMachError } from './mach-error-hints.js';
24
25
  const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
25
26
  /** Packaging manifests whose graph/destination directories need a full build. */
26
27
  export function requiresFullBuildForIncrementalTest(path) {
27
- return path === 'jar.mn' || path.endsWith('/jar.mn');
28
+ return isJarManifestPath(path);
29
+ }
30
+ /**
31
+ * Build-input manifests changed since the last SUCCESSFUL build — not
32
+ * merely dirty against HEAD. `collectChangedEnginePaths` unions the
33
+ * baseline-to-HEAD diff with every worktree modification, and a fork's
34
+ * worktree is permanently dirty (imported patches, Furnace-applied
35
+ * components), so on its own it reports a patch-touched `jar.mn` or
36
+ * `moz.build` as changed on every run: `mach configure` re-ran and the
37
+ * pre-test build escalated to a full `mach build` after each full build
38
+ * that had already consumed exactly this content. The baseline's
39
+ * `buildInputFingerprints` say what the last successful build saw;
40
+ * anything byte-identical to that is dropped here. A baseline without the
41
+ * field (written before it existed) keeps every dirty input, which is the
42
+ * old behaviour.
43
+ *
44
+ * @param engineDir - Path to the engine directory
45
+ * @param baseline - Last successful build's baseline
46
+ * @returns Engine-relative build-input paths that still need acting on
47
+ */
48
+ async function collectChangedBuildInputs(engineDir, baseline) {
49
+ const changed = await collectChangedEnginePaths(engineDir, baseline, 'Auto-configure');
50
+ const dirtyInputs = changed.filter(isBuildInputPath);
51
+ const stillChanged = await dropPathsMatchingFingerprints(engineDir, dirtyInputs, baseline.buildInputFingerprints);
52
+ const unchangedSinceBuild = dirtyInputs.filter((path) => !stillChanged.includes(path));
53
+ if (unchangedSinceBuild.length > 0) {
54
+ verbose(`Auto-configure: ${unchangedSinceBuild.length} dirty build input(s) byte-identical to the last successful build, not treated as changed: ${unchangedSinceBuild.join(', ')}`);
55
+ }
56
+ return stillChanged;
28
57
  }
29
58
  /**
30
59
  * Extracts the tail of captured `mach configure` output so the underlying
@@ -149,7 +178,7 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
149
178
  let reconfigured = false;
150
179
  let fullBuildRequired = false;
151
180
  if (options.previousBaseline) {
152
- const changed = await collectChangedEnginePaths(paths.engine, options.previousBaseline, 'Auto-configure');
181
+ const changed = await collectChangedBuildInputs(paths.engine, options.previousBaseline);
153
182
  const invalidating = changed.filter(isBackendInvalidatingFile);
154
183
  fullBuildRequired = changed.some(requiresFullBuildForIncrementalTest);
155
184
  if (invalidating.length > 0) {