@hominis/fireforge 0.17.0 → 0.18.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 (51) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +40 -20
  3. package/dist/src/commands/build.js +18 -4
  4. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  5. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  6. package/dist/src/commands/doctor-furnace.js +2 -0
  7. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  8. package/dist/src/commands/doctor-working-tree.js +93 -0
  9. package/dist/src/commands/doctor.js +9 -11
  10. package/dist/src/commands/export-all.js +11 -3
  11. package/dist/src/commands/export-shared.d.ts +7 -1
  12. package/dist/src/commands/export-shared.js +21 -3
  13. package/dist/src/commands/furnace/override.js +23 -13
  14. package/dist/src/commands/furnace/remove.js +8 -0
  15. package/dist/src/commands/furnace/rename.js +23 -4
  16. package/dist/src/commands/lint.js +19 -6
  17. package/dist/src/commands/patch/delete.js +4 -1
  18. package/dist/src/commands/patch/reorder.js +4 -1
  19. package/dist/src/commands/re-export-files.js +3 -1
  20. package/dist/src/commands/re-export.js +4 -1
  21. package/dist/src/commands/register.js +11 -0
  22. package/dist/src/commands/test.js +53 -12
  23. package/dist/src/commands/token-coverage.js +10 -3
  24. package/dist/src/commands/wire.js +16 -0
  25. package/dist/src/core/browser-wire.js +21 -4
  26. package/dist/src/core/build-audit.js +10 -0
  27. package/dist/src/core/git-diff.js +21 -2
  28. package/dist/src/core/mach.d.ts +12 -6
  29. package/dist/src/core/mach.js +12 -6
  30. package/dist/src/core/manifest-rules.js +10 -1
  31. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  32. package/dist/src/core/manifest-tokenizers.js +28 -0
  33. package/dist/src/core/patch-lint.d.ts +47 -2
  34. package/dist/src/core/patch-lint.js +89 -14
  35. package/dist/src/core/patch-manifest-consistency.js +15 -2
  36. package/dist/src/core/patch-manifest-io.js +10 -0
  37. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  38. package/dist/src/core/patch-manifest-resolve.js +29 -2
  39. package/dist/src/core/patch-manifest-validate.js +25 -1
  40. package/dist/src/core/token-coverage.js +24 -0
  41. package/dist/src/core/wire-destroy.d.ts +7 -3
  42. package/dist/src/core/wire-destroy.js +11 -6
  43. package/dist/src/core/wire-init.d.ts +9 -3
  44. package/dist/src/core/wire-init.js +18 -6
  45. package/dist/src/core/wire-subscript.d.ts +7 -3
  46. package/dist/src/core/wire-subscript.js +11 -4
  47. package/dist/src/types/commands/patches.d.ts +23 -0
  48. package/dist/src/types/furnace.d.ts +9 -0
  49. package/dist/src/utils/parse.d.ts +7 -0
  50. package/dist/src/utils/parse.js +15 -0
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,9 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Compatibility
6
+
7
+ - **No re-export required for existing 0.17.0 patches.** Every fix in this release is additive or defensive: the patch body format is unchanged, the `patches.json` schema only gains two optional fields (both already round-tripped through the validator in 0.17.0), and `fireforge import`/`verify` against a 0.17.0-exported queue continues to behave identically. Operators can upgrade in place.
8
+ - **No breaking API changes for downstream `import '@hominis/fireforge'` consumers.** `src/core/mach.ts`'s `build()` / `buildUI()` signatures changed from `Promise<number>` to `Promise<MachCommandResult>` so `fireforge build` can annotate the tail of mach's output (see below), but neither function is exported from `src/index.ts` — the public surface stays the same.
9
+ - **Minor cosmetic drift on wire-generated code and token coverage percentages.** `fireforge wire` now stamps `// <BINARY>: wire-<step> <name>` marker comments (see below). Patches exported before 0.18.0 that captured the pre-fix `// <name> init — must be first…` form continue to apply cleanly — the wire mutator's idempotency check is keyed on the call expression, not the comment, so re-running `wire` against a 0.17.0-wired tree is still a no-op and will not double up. Operators who want the marker in existing patches can either re-run `wire` after deleting the old block, or hand-edit the patch once. Token coverage percentages shift upward on any project that copies `--moz-*` CSS baselines (now counted as allowlisted platform vars instead of unknown) — CI thresholds pinned to 0.17.0 numbers may need a one-time adjustment.
10
+
11
+ ### Hardening
12
+
13
+ - **`fireforge lint` (aggregate) + `fireforge export-all` — EISDIR crashes on patches that introduce new directories.** `resolveLintDiff` in `src/commands/lint.ts` and `resolveFurnaceExclusionPolicy` / the `--exclude-furnace` branch in `src/commands/export-all.ts` both blended `getModifiedFiles()` + `getUntrackedFiles()` (or raw `getWorkingTreeStatus()`) before handing the path list to `getDiffForFilesAgainstHead`. `git status --porcelain=v1 -z` collapses untracked directories to `?? browser/modules/<fork>/` entries, so `generateNewFileDiff` inside the diff pass called `readText()` on a directory path and crashed with `EISDIR: illegal operation on a directory, read` — the eval's imported hominis queue and its fresh-project register-wire workflow both hit this. The fix plumbs `expandUntrackedDirectoryEntries` (already present for `status --ownership`) through both call sites so only file paths reach the diff pass, and `getDiffForFilesAgainstHead` in `src/core/git-diff.ts` now runs a belt-and-suspenders expansion via `getUntrackedFilesInDir` before reading any candidate as a file — a single bad call site cannot re-introduce the crash at that layer. `fireforge lint --per-patch` already worked; aggregate mode and `export-all` now do too.
14
+ - **`fireforge doctor` — ownership-aware working-tree classification.** `runEngineGitChecks` in `src/commands/doctor.ts` previously emitted a single generic `Engine working tree has <N> local changes` warning for any dirty count and told the operator to `export, discard, or reset them as appropriate`. That advice was destructive on a freshly-imported patch stack (eval: a correctly imported 126-file queue on `hominis` triggered the warning, and the suggested `discard` / `reset` recovery would have dropped the entire import) and spurious on a fresh project with tool-managed branding (eval: 63/64 dirty files after a successful build were classified as `branding` by `fireforge status`, yet `doctor` still told the operator to export / discard them). The new `inspectEngineWorkingTree` helper in `src/commands/doctor-working-tree.ts` (split out to keep `doctor.ts` under max-lines) reuses the shared `classifyFiles` pipeline — same partitioning as `fireforge status --ownership`: `branding` / `furnace` / `patch-backed` / `conflict` / `unmanaged`. Doctor now warns only on the `unmanaged` bucket (and raises a loud distinct warning on `conflict`, with a pointer at `status --ownership` + `verify`); tool-managed buckets pass with an ownership summary (`N tool-managed changes (X patch-backed, Y branding), 0 unmanaged`). A missing config falls back to the legacy count-only warning so a broken project still gets a useful row. Ordering dependency on `fireforge.json is valid` is declared explicitly via `dependsOn`.
15
+ - **`fireforge furnace remove` — drops the locale jar.mn chrome registration for localized custom components.** Before this fix, `furnaceRemoveCommand` in `src/commands/furnace/remove.ts` deleted the `.ftl` file and the component directory but never dropped the `locale/@AB_CD@/<chromeSubPath>/<tag>.ftl` entry that `applyCustomFtlFile` wrote during deploy. The eval's `furnace remove moz-eval-chip --yes` left `engine/browser/locales/jar.mn` pointing at the now-missing FTL; `furnace validate` still passed, and later builds/packaging would have tripped over the stale reference. The fix threads the existing `removeCustomFtlJarMnEntry` helper from `src/core/furnace-apply-ftl.ts` into the localized branch of `furnaceRemoveCommand`, snapshot-protected by the same rollback journal that guards the FTL-file removal so a mid-remove failure restores both together. `fireforge furnace validate` after remove now reports a clean state without a follow-up `apply --dry-run` cleanup plan.
16
+ - **`fireforge furnace rename` — rewires the locale jar.mn entry on localized renames.** `updateEngineRegistrations` in `src/commands/furnace/rename.ts` renamed the `.ftl` file on disk but left the locale jar.mn pointing at `locale/.../${oldName}.ftl`. `fireforge furnace validate` passed anyway because the validator only checked the toolkit `jar.mn` (for `.mjs`/`.css` entries), not the locale jar.mn — the eval reported "validate passed" for two full minutes while the engine carried a stale registration for a file that no longer existed. The fix, gated on `customConfig.localized`, calls `removeLocaleFtlJarMnEntry(oldName)` + `addLocaleFtlJarMnEntry(newName)` against `resolveFtlLocaleJarMnPath(config.ftlBasePath)` inside the same rollback journal as the FTL file rename, so both changes land together or roll back together. Non-localized renames are unaffected.
17
+ - **`fireforge furnace override` — concurrent invocations no longer silently drop each other's manifest entries.** The eval ran two `furnace override` commands in parallel against the same fresh project (one `moz-button`, one `moz-card`): both exited `0`, both override directories landed on disk, but `furnace.json` kept only one entry. Root cause: `performOverrideMutations` acquired the furnace lock via `runFurnaceMutation` but `saveOverrideConfig` wrote back the outer-snapshot config object — each invocation had loaded an empty-overrides state BEFORE entering the lock, and the second writer clobbered the first writer's addition. `saveOverrideConfig` now re-reads fresh furnace.json state inside the lock via `loadAuthoringFurnaceConfig`, splices the new entry plus the stock-bucket removal onto the fresh state, and writes that back — so a sibling writer's addition survives into the final manifest. A concurrent-writer regression test under `furnace-override.test.ts` pins the contract; other furnace mutations (`remove`, `rename`, `deploy`) already re-read inside the lock and need no equivalent fix.
18
+ - **`fireforge doctor --repair-furnace` — detects and recovers orphaned override directories.** New `Furnace manifest sync` check in the new `src/commands/doctor-furnace-manifest-sync.ts` module (spliced into `FURNACE_DOCTOR_CHECKS` so `doctor-furnace.ts` stays under max-lines). Without `--repair-furnace` the check warns when `components/overrides/<name>/` or `components/custom/<name>/` exists on disk but is absent from `furnace.json` — the exact residue left by the concurrent-override race above or by any interrupted mutation. With `--repair-furnace`, override orphans are re-registered by reading the `override.json` sidecar `furnace override` writes during the copy phase (`type`, `description`, `basePath`, `baseVersion`, optional `baseCommit`), folding the recovered entry into a fresh furnace.json read, and persisting. Custom orphans are listed for the operator to either `furnace create` against or delete manually, because custom components have no equivalent sidecar — fabricating `targetPath` / `register` / `localized` values is worse than the orphaned directory itself. Doctor now catches the residue from eval 2's concurrent-override reproduction and the symmetric case where an operator SIGINT's a mutation mid-flight.
19
+ - **`fireforge build` — annotates mach's trailing `config.status is out of date` notice instead of leaving it to read as build failure.** mach's post-build guard prints `config.status is out of date with respect to browser/moz.configure` when a tool-managed edit (most commonly `setupBranding`'s `patchMozConfigure` on first build) landed on `moz.configure` before the build. The guard runs AFTER `Your build was successful!` so operators reading the tail of a successful build saw contradictory signals — the eval recorded this on both a fresh project's first build and a pre-existing hominis build. `build()` and `buildUI()` in `src/core/mach.ts` now return the captured `MachCommandResult` (stdout/stderr tail + exit code) instead of just an exit code; `buildCommand` in `src/commands/build.ts` scans `result.stdout`/`stderr` for `/config\.status is out of date/i` and, on match, emits a one-line `info(...)` explaining the notice is a known side effect of tool-managed branding edits and does not require a rebuild. Test-command `buildUI` callers in `src/commands/test.ts` pick up the new return shape; no caller outside the CLI sees the signature change (neither function is exported from `src/index.ts`).
20
+ - **`fireforge test --doctor` — triple-path PASS footer survives non-TTY clack rendering.** The eval's `test --doctor` captures on both fresh and hominis projects showed `● Running marionette preflight...` and then exited 0 with nothing else — the 0.17.0 `outro(\`Marionette preflight: PASS (...)\`)`was dropped by clack somewhere in the non-TTY render pipeline (exact root cause could not be isolated from captures alone). Rather than chase the rendering bug, the fix triple-writes the result:`success(\`Marionette preflight: PASS (...)\`)`, then `outro('Test completed')`closes the intro frame, then`process.stdout.write(\`${summary}\n\`)`guarantees the line reaches stdout regardless of clack's buffering. A test in`test.test.ts` asserts all three emissions.
21
+ - **`fireforge test` — fork-owned module import failures beat the branding stale-build diagnosis.** The eval's hominis xpcshell test failed loading `resource:///modules/hominis/HominisStore.sys.mjs`; FireForge reported "stale build artifacts, run fireforge build --ui" because the harness teardown also printed a branding warning that matched the narrower stale-build pattern. Re-running the build did nothing — the real failure was that the new module wasn't registered in `browser/modules/hominis/moz.build`. `handleNonZeroTestExit` now checks for `Failed to load resource:///modules/<binaryName>/` BEFORE the branding stale-build branch and surfaces a fork-module-specific diagnostic pointing at `browser/modules/<binary>/moz.build` + `fireforge register`, so the operator sees the correct recovery path on the first try. Existing branding-stale diagnosis is untouched.
22
+ - **`fireforge token coverage` — `--moz-*` platform variables allowlisted by default; untracked CSS directories now scanned.** Two independent token-coverage fixes:
23
+ - **Platform allowlist** — `src/core/token-coverage.ts` seeds a default `platformPrefixes: ['--moz-']` and treats matches as `allowlisted` rather than `unknown`. A CSS-only Furnace override of `moz-button` with one fork token previously reported 1% coverage because the copied upstream baseline referenced 84 `--moz-*` platform vars; coverage now reports 100% on the same diff, and the one unknown was correctly declared by the fork. Forks can override through a new optional `platformPrefixes?: string[]` field on `furnace.json` — extending the list (e.g. `['--moz-', '--in-content-']`) for forks with additional platform prefixes, or setting an explicit empty array to restore the pre-0.18 strict contract. The field is added to the public `FurnaceConfig` type.
24
+ - **Untracked-directory expansion** — `src/commands/token-coverage.ts` now reads `getWorkingTreeStatus` + `expandUntrackedDirectoryEntries` instead of `getStatusWithCodes` so CSS files inside a `?? dir/` untracked directory (the common shape for a freshly-imported patch stack adding a new theme tree) show up in the scan. The eval's hominis run saw `?? browser/branding/hominis/content/aboutDialog.css` and `?? browser/themes/shared/hominis-tokens.css` collapse to directory entries that didn't end in `.css`; coverage reported "No modified CSS files". Both CSS files are now scanned.
25
+ - **`fireforge patch reorder` + `fireforge patch delete` — accept manifest `name` handle in addition to filename and ordinal.** `resolvePatchIdentifier` in `src/core/patch-manifest-resolve.ts` previously rejected the `name` field even though CLI help says `<name>` and the manifest stores a `name` on every entry. Operators had to copy the full filename from `patches.json` before every queue mutation. The resolver now matches, in order: ordinal number → exact filename → filename-with-`.patch`-appended → `name` field. Filename lookup still wins when a manifest happens to have a `name` identical to a different filename — legacy scripts passing filenames keep working. Error messages on both commands now list filenames AND their `name` counterparts so operators see both forms as valid identifiers.
26
+ - **`fireforge patch reorder` — postcondition assert on each Phase 2 rename.** The eval reported that a two-patch swap updated `patches.json` but left the files at their pre-rename names, and `fireforge verify` then failed ENOENT opening the manifest-renamed file. The code path in `renumberPatchesInManifest` (two-phase staging: stage → final + manifest write) appears correct against both the integration test in `patch-mutations.integration.test.ts` and the shipped 0.17.0 dist — but the eval's operation log shows the manifest was updated while the rename was missing, so a filesystem-specific failure mode exists that the existing integration test does not exercise. Defense-in-depth lands a postcondition `pathExists(newFilename)` assert after every `rename()` in Phase 2, so any silent rename failure aborts the reorder before the manifest write and triggers the Phase 2 rollback (which itself reverses completed final-name moves and unwinds the staging). A second integration test reproduces the exact eval scenario (`001-infra` + `002-ui` swap via `--to 1 --yes`) and asserts both filenames exist on disk after completion.
27
+ - **`fireforge register --dry-run` — honours the rule-level idempotency decision.** `registerFile` in `src/core/manifest-rules.ts` returned `{ skipped: true }` when the rule's `isRegistered(...)` check found the file already present in its manifest, but `registerCommand` in `src/commands/register.ts` ignored the flag on the dry-run branch and always printed `[dry-run] Would register <path>`. The next real invocation correctly reported `Already registered: <path>`. Automation that consumed the dry-run as a plan mis-predicted unnecessary work. Dry-run now prints `[dry-run] Already registered: <path> in <manifest>` when `result.skipped` is true — mirroring the real-run copy — and otherwise prints the registration plan as before.
28
+ - **`fireforge wire` — `// <BINARY>:` marker comments in wire-generated code.** `lintModificationComments` in `src/core/patch-lint.ts` checks JS/MJS additions for a `// <binaryName.toUpperCase()>:` marker (case-insensitive) and flags `missing-modification-comment` on upstream file edits that lack it. `fireforge wire` itself wrote comments like `// <name> init — must be first, before Firefox subsystem` — generic text that trips the patch-lint rule on the first export after wiring, so the eval saw `[missing-modification-comment] browser/base/content/browser-init.js` on content generated by FireForge's own command. Each wire mutator (`addInitAST`/`legacyAddInit`, `addDestroyAST`/`legacyAddDestroy`, `addSubscriptAST`/`legacyAddSubscript`) now accepts a `marker: string` parameter (default `'FIREFORGE:'` for back-compat with programmatic callers); `browser-wire.ts:wireSubscript` computes the project-scoped marker as `${config.binaryName.toUpperCase()}:` from `fireforge.json` and threads it through. Emitted blocks now carry `// FRESHFORGE: wire-init <name> — must be first, …` (or the caller's equivalent uppercased binaryName), satisfying the lint rule on the first export. Patches exported before 0.18.0 that captured the old comment format still apply cleanly — patch content is unchanged — they just won't have the marker; operators who want the marker in existing patches can re-run `wire` after deleting the old block (idempotency is keyed on the call expression, so re-running alone is a no-op). `.inc.xhtml` fragments inserted by `--dom` are preprocessor directives, not JS — `lintModificationComments` only checks JS, so no marker is needed there.
29
+ - **`fireforge build --ui` + `fireforge build` — `.inc.xhtml` fragments excluded from the packaging audit.** `isPackageablePath` in `src/core/build-audit.ts` treated `.inc.xhtml` as a `.xhtml` file, so a wire-introduced `browser/base/content/<name>.inc.xhtml` (consumed via `#include` from a registered chrome document and resolved at packaging time) got flagged as "missing packaged artifact" on the next UI build. `register --dry-run` already refuses to register these fragments with explicit guidance — the build audit now matches that contract with an early `sourcePath.endsWith('.inc.xhtml') → false` return. The two sibling checks now agree about this file type.
30
+ - **`fireforge register` — empty single-line `EXTRA_JS_MODULES += []` lists expand before insertion.** `tokenizeMozBuildList` in `src/core/manifest-tokenizers.ts` tracked opening `[` and closing `]` on separate lines. A freshly-scaffolded `browser/modules/<fork>/moz.build` that wrote `EXTRA_JS_MODULES += []` on one line returned `null` from the tokenizer because the scanner never saw a line STARTING with `]`, and `registerFireForgeModule` failed with `Could not find EXTRA_JS_MODULES in moz.build` — blocking the documented `browser/modules/<fork>/*.sys.mjs` workflow at the first module. The tokenizer now detects the single-line empty-list form `...+= []` at open time, rewrites `lines[i]` to `...+= [` and splices in a `]` at `i + 1`, and emits matched `list-open` / `list-close` tokens for the canonical multi-line shape. Downstream `findAlphabeticalMozBuildPosition` and the register helpers then place the new entry inside the brackets as normal.
31
+ - **`fireforge wire --dry-run` — notice when the subscript file is absent.** The dry-run branch in `src/commands/wire.ts` deliberately skips the subscript-file existence check to support the "wire first, create the file after" scaffolding workflow. Pre-0.18 that deliberate skip produced a plausible plan even when the subscript was genuinely missing, and the real `wire` invocation then refused with `Subscript file not found`. The skip stays (the workflow is legitimate) but dry-run now emits `Note: <subscriptDir>/<name>.js does not exist yet — the real wire command will require it before writing.` so the operator sees the absence in the preview and can create the file before re-running without `--dry-run`.
32
+ - **`fireforge status --unmanaged` + `fireforge register` — browser-chrome test files route to browser.toml, not jar.mn.** The browser-content pattern in `src/core/manifest-rules.ts` matched every `browser/base/content/**/*.{js,mjs,xhtml,css}` path, including test implementation files at `browser/base/content/test/<dir>/browser_*.js`. `status --unmanaged` therefore proposed `fireforge register browser/base/content/test/forgeqa/browser_forgeqa_qa_browser.js` which, if run, would cluttered jar.mn with a test-file chrome URI (the right place for those files is the sibling `browser.toml`). The pattern now excludes the `test/` subtree via a negative lookahead, so these paths fall through to `getUnregistrableAdvice`, which already returns the correct browser.toml-centric guidance for individual test files. `status`, `register`, and `register --dry-run` all now agree that test JS belongs in the test manifest.
33
+
34
+ ### Internal
35
+
36
+ - **Two small module extractions preserve the per-file LOC budget.** `doctor-working-tree.ts` hosts `inspectEngineWorkingTree` (the ownership-aware working-tree check), and `doctor-furnace-manifest-sync.ts` hosts the orphan override / custom detection + repair. Both are spliced into their respective registries from the hosting file, keeping `doctor.ts` and `doctor-furnace.ts` under the 500-line `max-lines` rule. The drift test in `manifest.test.ts` is extended to classify both new files as helpers so the top-level command drift check stays clean.
37
+ - **Expanded test coverage — 34 new assertions.** Every fix above ships with at least one new test exercising the regression path: `src/core/__tests__/git-diff.test.ts` gains a `?? dir/` expansion case; `src/commands/__tests__/doctor.test.ts` gains patch-backed / conflict / orphan-override / manifest-sync-repair cases; `furnace-remove.test.ts` + `furnace-rename.test.ts` pin the locale jar.mn re-wire / removal; `furnace-override.test.ts` pins the concurrent-writer read-modify-write; `build.test.ts` pins the stale-configure annotation; `test.test.ts` pins the fork-module diagnostic + triple-path PASS footer; `token-coverage.test.ts` pins the `--moz-*` allowlist + untracked-dir expansion; `patch-manifest-helpers.test.ts` pins name-field resolution; `patch-mutations.integration.test.ts` pins the two-patch swap postcondition; `register.test.ts` pins dry-run idempotency; `wire-init.test.ts` pins the marker; `manifest-tokenizers.test.ts` + `register-module.test.ts` pin the empty-list expansion; `wire.test.ts` pins the dry-run missing-subscript notice; `manifest-register.test.ts` pins the test-file pattern exclusion.
38
+
3
39
  ## 0.17.0
4
40
 
5
41
  ### Eval-driven hardening
6
42
 
43
+ - **`fireforge lint --per-patch` — branding-tier detection tolerates the one-line registration sibling + explicit `tier: "branding"` opt-in, and `lintIgnore` now round-trips through the manifest loader.** The 0.16.0 branding tier (notice 3000 / warning 8000 / error 20000) was gated on `isBrandingOnlyPatch` in `src/core/patch-lint.ts` returning true only when _every_ entry in `filesAffected` began with `browser/branding/`. Real-world branding patches follow the Firefox convention of also touching `browser/moz.configure` (to register the new branding flavor with the top-level configure), so the 2026-04-21 external audit reported that `lint --per-patch` still fired `ERROR [large-patch-lines] 001 (patch): Patch is 15665 lines (hard limit: 3000)` on a minimum branding diff — the 0.16.0 fix only helped forks with trivially scoped branding patches. Two mechanisms land together: (a) the auto-detector now accepts a narrow allowlist of branding-registration siblings (`browser/moz.configure`, `browser/confvars.sh`) in addition to the `browser/branding/` prefix, guarded by a "≥1 file under `browser/branding/`" requirement so a config-only patch cannot accidentally qualify; (b) a new optional `tier?: 'branding'` field on `PatchMetadata` forces the branding thresholds regardless of `filesAffected`, threaded through `runPatchLint`, `export`, `export-all`, `re-export`, `re-export --files`, and `lint --per-patch` — mirroring how `lintIgnore` expresses per-patch intent. Precedence stays `test > branding > general` (tests still win even with explicit `tier: "branding"`). A one-line `info()` surfaces the tier choice when branding applies, distinguishing "all files under browser/branding/ + registration siblings" from "operator declared tier: branding". Coupled with a pre-existing latent bug uncovered during the audit: `validatePatchMetadata` in `src/core/patch-manifest-validate.ts` returned a fresh object enumerating only the required fields, silently stripping `lintIgnore` on every manifest load — so the 0.16.0 escape hatch only round-tripped through tests that mocked `loadPatchesManifest` directly. Real operator edits to `patches.json` evaporated. The validator now preserves `lintIgnore` (via a new `optionalStringArray` helper in `src/utils/parse.ts`) and `tier`, and `rebuildPatchesManifest` in `src/core/patch-manifest-consistency.ts` preserves both fields on `doctor --repair-patches-manifest` so the repair path no longer strips them. Unknown `tier` values are rejected at load time with a clear message rather than silently stripped — a typo like `"Branding"` surfaces as a loader error, matching the strict validation already applied to `category` and `sourceEsrVersion`.
7
44
  - **`fireforge config` — serialised writes behind a sidecar lock.** The 2026-04-21 eval reproduced silent data loss by running two `fireforge config` invocations in parallel against the same `fireforge.json`: both commands exited `0`, but only one key survived. Atomic-rename writes (`writeJson`'s temp file + rename) prevented torn files but not lost updates: each writer read the pre-state, mutated its own copy, and the second rename clobbered the first writer's change. A new `withConfigFileLock(projectRoot, operation)` helper in `src/core/config.ts` wraps the read-modify-write cycle in `src/commands/config.ts:configCommand` — both the strict-validated and `--force` write branches now take the lock. Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: atomic rename means readers always see either the pre- or post-state, so only writers need to be serialised. Stale-lock recovery reuses the existing PID-alive probe from `withFileLock` so a crashed `fireforge config` does not wedge the next command.
8
45
  - **`fireforge token add --mode override` — dark values land inside the nested `:root { }`.** The previous `insertDarkModeOverride` in `src/core/token-manager.ts` found the outer `@media (prefers-color-scheme: dark) { }` block's closing `}` and spliced the dark-value declaration before that line — which is _after_ the nested `:root { }` had already closed, producing a declaration outside any rule block. The generated tokens CSS was syntactically malformed after every legitimate `token add --mode override` call, and `token coverage` no longer recognised the added tokens. The fix walks the comment-stripped line array to find the `:root {` opener _inside_ the `@media` block, then depth-counts to its own closing `}`, and inserts there. A fallback path (warn + synthesise a fresh nested `:root` before the outer close) handles malformed scaffolds where the nested `:root` was removed, so we never silently drop a dark value or emit a top-level declaration. The test coverage in `src/core/__tests__/token-manager.test.ts` now pins the invariant that the dark entry's line index must be _less_ than the inner `:root`'s closing `}`, so a future refactor cannot regress to the outer-block landing.
9
46
  - **`fireforge furnace rename` — cleans up deployed widgets, renames mochikit test + chrome.toml.** The previous `renameTestFiles` helper in `src/commands/furnace/rename.ts` handled the `browser/base/content/test/<binaryName>/` browser-chrome layout but not the `toolkit/content/tests/widgets/` mochikit layout, and `performRenameMutations` made no attempt to clear `engine/<oldTargetPath>/` after deployment. The 2026-04-21 eval renamed `ff-chip-row` → `ff-chip-stack` and ended up with `engine/toolkit/content/widgets/ff-chip-row/` still deployed, `test_ff-chip-row.html` still importing `chrome://global/content/elements/ff-chip-row.mjs`, and the `chrome.toml` entry still naming the old file. Two new helpers pick up the slack: `removeStaleDeployedComponentDir` snapshots + removes `engine/<oldTargetPath>/` so the next `furnace apply` is the sole writer of the new deployment; `renameMochikitTestFiles` snapshots the old scaffold, rewrites the chrome URI + class-test identifiers to the new name, and updates the widgets `chrome.toml` entry. Both run under the same rollback journal as the rest of the rename, so a later failure restores every touched path.
package/README.md CHANGED
@@ -193,8 +193,12 @@ This re-exports the fixed patch and clears the conflict state. The command is de
193
193
  "description": "Replaces default Firefox branding with custom logo",
194
194
  "createdAt": "2025-01-15T10:30:00Z",
195
195
  "sourceEsrVersion": "140.9.0esr",
196
- "filesAffected": ["browser/branding/official/logo.png"],
197
- "lintIgnore": ["large-patch-lines", "large-patch-files"]
196
+ "filesAffected": [
197
+ "browser/branding/official/logo.png",
198
+ "browser/themes/custom-shared/tokens.css"
199
+ ],
200
+ "lintIgnore": ["large-patch-lines", "large-patch-files"],
201
+ "tier": "branding"
198
202
  }
199
203
  ]
200
204
  }
@@ -202,6 +206,8 @@ This re-exports the fixed patch and clears the conflict state. The command is de
202
206
 
203
207
  The optional `lintIgnore` field lists lint check IDs to suppress for that patch specifically. Useful for the class of patch that is advisory-noisy by nature — a cohesive branding bundle, a localised-resource pack, an auto-generated manifest — where `--skip-lint` is too blunt and a per-line marker cannot exist (the `.patch` body is regenerated on every export). Threaded through `export`, `re-export`, `re-export --files`, and `lint --per-patch`. Unknown check IDs are a no-op.
204
208
 
209
+ The optional `tier` field (only `"branding"` recognised) forces the branding threshold tier for the `large-patch-lines` rule regardless of what `filesAffected` looks like. The automatic branding-tier detection already fires when every file is under `browser/branding/` plus a narrow allowlist of branding-registration siblings (`browser/moz.configure`, `browser/confvars.sh`) — covering the canonical Firefox fork shape. Declare `tier: "branding"` only when the patch legitimately also touches a non-allowlisted sibling the auto-detector cannot reach (a fork-specific theme override under `browser/themes/<name>/`, a vendor-specific icon resource, etc.). Precedence is `test > branding > general`: a patch of all-tests always gets the more permissive test-tier thresholds even if it declares `tier: "branding"`. Unknown tier values are rejected at load time rather than silently stripped, so a typo surfaces as a loader error. Prefer `tier` over `lintIgnore: ["large-patch-lines"]` when the patch is legitimately branding-shaped — `tier` keeps the rule running at the correct thresholds (so the warning still surfaces if the patch crosses them); `lintIgnore` drops the rule entirely.
210
+
205
211
  If the manifest drifts after an interrupted export or manual edits, `fireforge import` will stop rather then silently applying a stale stack. Use `fireforge doctor --repair-patches-manifest` to rebuild it from disk. Because the rebuild is deterministic, the result will always be consistent with what is actually on the filesystem.
206
212
 
207
213
  </details>
@@ -213,24 +219,24 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
213
219
 
214
220
  By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
215
221
 
216
- | Check | Scope | Severity |
217
- | ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
218
- | `missing-license-header` | New files (JS/CSS/FTL) | error |
219
- | `relative-import` | JS/MJS files | error |
220
- | `token-prefix-violation` | CSS files (with furnace) | error |
221
- | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
222
- | `duplicate-new-file-creation` | Same path created by multiple patches | error |
223
- | `forward-import` | Patch imports from a later-patch file | error |
224
- | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
225
- | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
226
- | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
227
- | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
228
- | `missing-modification-comment` | Modified upstream JS/MJS | warning |
229
- | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
230
- | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
231
- | `observer-topic-naming` | Observer topics with binaryName | warning |
232
- | `large-patch-files` | Patches affecting >5 files | warning |
233
- | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test) | notice / warning / error |
222
+ | Check | Scope | Severity |
223
+ | ------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------ |
224
+ | `missing-license-header` | New files (JS/CSS/FTL) | error |
225
+ | `relative-import` | JS/MJS files | error |
226
+ | `token-prefix-violation` | CSS files (with furnace) | error |
227
+ | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
228
+ | `duplicate-new-file-creation` | Same path created by multiple patches | error |
229
+ | `forward-import` | Patch imports from a later-patch file | error |
230
+ | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
231
+ | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
232
+ | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
233
+ | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
234
+ | `missing-modification-comment` | Modified upstream JS/MJS | warning |
235
+ | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
236
+ | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
237
+ | `observer-topic-naming` | Observer topics with binaryName | warning |
238
+ | `large-patch-files` | Patches affecting >5 files | warning |
239
+ | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test, 3000/8000/20000 branding) | notice / warning / error |
234
240
 
235
241
  **JSDoc validation** uses AST-based analysis (Acorn) to validate exported APIs in patch-owned `.sys.mjs` files. A file is "patch-owned" if it was newly created by the current diff or by an existing patch in the queue. Functions must document every `@param` (names must match) and include `@returns` when the function returns a value. Exported constants and classes require a JSDoc block.
236
242
 
@@ -427,6 +433,8 @@ fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
427
433
  fireforge patch compact
428
434
  ```
429
435
 
436
+ `delete` and `reorder` accept three identifier forms for their target: the ordinal number (`fireforge patch reorder 3 --to 1`), the full filename (`003-ui-sidebar-tweaks.patch`), or the manifest `name` handle the patch was exported with (`ui-sidebar-tweaks`). Anchors passed to `--before` / `--after` accept the same three forms.
437
+
430
438
  All subcommands support `--dry-run` and `--yes`.
431
439
 
432
440
  ### Additional workflow commands
@@ -551,6 +559,18 @@ Design tokens imported from the fork's palette are enforced by `tokenPrefix`, bu
551
559
 
552
560
  Both rules compose with the existing `tokenPrefix` / `tokenAllowlist` checks and apply to both component validation and patch-stack lint.
553
561
 
562
+ ### Platform variables in Furnace (token coverage)
563
+
564
+ `fireforge token coverage` partitions `var(--…)` usages into three buckets: fork-owned tokens (matching `tokenPrefix`), allowlisted exceptions (explicit `tokenAllowlist` entries, plus platform prefixes), and unknown. Platform prefixes default to `['--moz-']` so upstream Firefox variables in copied baselines (e.g. the CSS that lands under `toolkit/content/widgets/<name>/` after `furnace override <name> -t css-only`) don't drag the fork-owned coverage percentage down.
565
+
566
+ ```json
567
+ {
568
+ "platformPrefixes": ["--moz-", "--in-content-"]
569
+ }
570
+ ```
571
+
572
+ Set this in `furnace.json` to extend the list (forks with additional platform prefixes) or pass an empty array to restore the pre-0.18 strict contract where `--moz-*` counted as unknown. Allowlisted usages are reported separately and are NOT counted toward the coverage denominator, so a 100% fork-owned project with a copied upstream baseline reads as `100%` instead of `1%`.
573
+
554
574
  ### Test harness options
555
575
 
556
576
  `fireforge furnace create --with-tests` scaffolds a **browser-chrome mochitest**. Use this when the component renders UI that depends on the tab strip (`openLinkIn` → `URILoadingHelper`, `gBrowser`, etc.).
@@ -127,7 +127,7 @@ export async function buildCommand(projectRoot, options) {
127
127
  }
128
128
  info(''); // Empty line before build output
129
129
  const startTime = Date.now();
130
- let exitCode;
130
+ let result;
131
131
  try {
132
132
  // Hold the per-project build lock across the mach invocation so two
133
133
  // overlapping `fireforge build` / `fireforge build --ui` commands
@@ -138,7 +138,7 @@ export async function buildCommand(projectRoot, options) {
138
138
  // backend — not a clue that a concurrent build was the cause. The
139
139
  // lock turns the second invocation's failure into an explicit
140
140
  // refusal naming the holder PID.
141
- exitCode = await withBuildLock(projectRoot, async () => {
141
+ result = await withBuildLock(projectRoot, async () => {
142
142
  if (options.ui) {
143
143
  return buildUI(paths.engine);
144
144
  }
@@ -152,9 +152,23 @@ export async function buildCommand(projectRoot, options) {
152
152
  const minutes = Math.floor(duration / 60000);
153
153
  const seconds = Math.floor((duration % 60000) / 1000);
154
154
  const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
155
- if (exitCode !== 0) {
155
+ if (result.exitCode !== 0) {
156
156
  error(`Build failed after ${timeStr}`);
157
- throw new BuildError(`Build failed with exit code ${exitCode}`, options.ui ? 'mach build faster' : 'mach build');
157
+ throw new BuildError(`Build failed with exit code ${result.exitCode}`, options.ui ? 'mach build faster' : 'mach build');
158
+ }
159
+ // Tool-managed branding edits that land on `browser/moz.configure`
160
+ // before the build cause mach's post-build guard to print
161
+ // "config.status is out of date … Be sure to run |mach build|" even
162
+ // though the build itself completed cleanly. 2026-04-21 eval finding:
163
+ // operators read that as "your build is stale" and either rebuilt
164
+ // (wasting ~10 minutes) or doubted the Fireforge "Build completed"
165
+ // footer. Annotate the captured output so the operator knows the
166
+ // warning is expected and not actionable.
167
+ const staleConfigurePattern = /config\.status is out of date/i;
168
+ if (staleConfigurePattern.test(result.stdout) || staleConfigurePattern.test(result.stderr)) {
169
+ info('Note: mach reported "config.status is out of date" after this build. ' +
170
+ 'That notice is a known side effect of tool-managed branding edits applied before the build ' +
171
+ 'and does not require a rebuild — the Fireforge exit code is authoritative.');
158
172
  }
159
173
  // Warn-only post-build audit: surfaces silent packaging drops (files
160
174
  // edited in engine/ but never registered for packaging) against the
@@ -0,0 +1,18 @@
1
+ /**
2
+ * "Furnace manifest sync" doctor check.
3
+ *
4
+ * Surfaces orphaned component directories on disk whose names are missing
5
+ * from `furnace.json`. The motivating eval case is the concurrent-override
6
+ * race (eval 2) where two parallel `furnace override` commands both left
7
+ * their directory on disk but only the second reached
8
+ * `writeFurnaceConfig`. Under `--repair-furnace`, override orphans are
9
+ * re-registered from the `override.json` sidecar the command wrote during
10
+ * the copy phase; custom orphans are listed for the operator to either
11
+ * re-run `furnace create` against or delete manually, because custom
12
+ * components have no similar persisted metadata.
13
+ *
14
+ * Lives in a sibling module to keep `doctor-furnace.ts` under the
15
+ * per-file LOC budget.
16
+ */
17
+ import type { DoctorCheckDefinition } from './doctor.js';
18
+ export declare const furnaceManifestSyncCheck: DoctorCheckDefinition;
@@ -0,0 +1,159 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * "Furnace manifest sync" doctor check.
4
+ *
5
+ * Surfaces orphaned component directories on disk whose names are missing
6
+ * from `furnace.json`. The motivating eval case is the concurrent-override
7
+ * race (eval 2) where two parallel `furnace override` commands both left
8
+ * their directory on disk but only the second reached
9
+ * `writeFurnaceConfig`. Under `--repair-furnace`, override orphans are
10
+ * re-registered from the `override.json` sidecar the command wrote during
11
+ * the copy phase; custom orphans are listed for the operator to either
12
+ * re-run `furnace create` against or delete manually, because custom
13
+ * components have no similar persisted metadata.
14
+ *
15
+ * Lives in a sibling module to keep `doctor-furnace.ts` under the
16
+ * per-file LOC budget.
17
+ */
18
+ import { readdir } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig } from '../core/furnace-config.js';
21
+ import { toError } from '../utils/errors.js';
22
+ import { pathExists, readJson } from '../utils/fs.js';
23
+ import { failure, ok, warning } from './doctor.js';
24
+ async function listComponentDirs(dir) {
25
+ if (!(await pathExists(dir)))
26
+ return [];
27
+ try {
28
+ const entries = await readdir(dir, { withFileTypes: true });
29
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ async function recoverOverrideConfig(overrideDir, name) {
36
+ // `furnace override` writes `override.json` alongside the copied files.
37
+ // That sidecar is enough to reconstruct the furnace.json entry lost to
38
+ // a concurrent-write race (eval 2: second override wrote back the
39
+ // outer-snapshot config, dropping the sibling write's addition).
40
+ const sidecarPath = join(overrideDir, name, 'override.json');
41
+ if (!(await pathExists(sidecarPath)))
42
+ return undefined;
43
+ try {
44
+ const raw = await readJson(sidecarPath);
45
+ const type = raw.type;
46
+ const description = raw.description;
47
+ const basePath = raw.basePath;
48
+ const baseVersion = raw.baseVersion;
49
+ if ((type !== 'css-only' && type !== 'full') ||
50
+ typeof description !== 'string' ||
51
+ typeof basePath !== 'string' ||
52
+ typeof baseVersion !== 'string') {
53
+ return undefined;
54
+ }
55
+ const restored = {
56
+ type: type,
57
+ description,
58
+ basePath,
59
+ baseVersion,
60
+ };
61
+ if (typeof raw.baseCommit === 'string') {
62
+ restored.baseCommit = raw.baseCommit;
63
+ }
64
+ return restored;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ async function collectOrphans(projectRoot, config) {
71
+ const furnacePaths = getFurnacePaths(projectRoot);
72
+ const overrideDirs = await listComponentDirs(furnacePaths.overridesDir);
73
+ const overrideOrphans = [];
74
+ for (const name of overrideDirs) {
75
+ if (name in config.overrides)
76
+ continue;
77
+ const recoveredConfig = await recoverOverrideConfig(furnacePaths.overridesDir, name);
78
+ overrideOrphans.push({ name, recoveredConfig });
79
+ }
80
+ const customDirs = await listComponentDirs(furnacePaths.customDir);
81
+ const customOrphans = customDirs.filter((name) => !(name in config.custom));
82
+ return { overrides: overrideOrphans, customNames: customOrphans };
83
+ }
84
+ function formatOrphanSummary(orphans) {
85
+ const overrideCount = orphans.overrides.length;
86
+ const customCount = orphans.customNames.length;
87
+ const overrideLabel = overrideCount > 0
88
+ ? `${overrideCount} override${overrideCount === 1 ? '' : 's'} ` +
89
+ `(${orphans.overrides.map((o) => o.name).join(', ')})`
90
+ : '';
91
+ const customLabel = customCount > 0
92
+ ? `${customCount} custom ${customCount === 1 ? 'directory' : 'directories'} ` +
93
+ `(${orphans.customNames.join(', ')})`
94
+ : '';
95
+ const description = [overrideLabel, customLabel].filter(Boolean).join(' and ');
96
+ return `Found orphaned component ${overrideCount + customCount === 1 ? 'directory' : 'directories'} on disk: ${description}. furnace.json does not list these — a previous mutation may have lost a concurrent write.`;
97
+ }
98
+ async function repairOrphanOverrides(projectRoot, orphans) {
99
+ const restored = [];
100
+ const unrecoverable = [];
101
+ if (orphans.length === 0)
102
+ return { restored, unrecoverable };
103
+ const freshConfig = await loadFurnaceConfig(projectRoot);
104
+ for (const orphan of orphans) {
105
+ if (orphan.recoveredConfig) {
106
+ freshConfig.overrides[orphan.name] = orphan.recoveredConfig;
107
+ restored.push(orphan.name);
108
+ }
109
+ else {
110
+ unrecoverable.push(orphan.name);
111
+ }
112
+ }
113
+ if (restored.length > 0) {
114
+ try {
115
+ await writeFurnaceConfig(projectRoot, freshConfig);
116
+ }
117
+ catch (err) {
118
+ return { restored: [], unrecoverable, writeError: toError(err).message };
119
+ }
120
+ }
121
+ return { restored, unrecoverable };
122
+ }
123
+ export const furnaceManifestSyncCheck = {
124
+ name: 'Furnace manifest sync',
125
+ dependsOn: ['Furnace configuration'],
126
+ skipIf: (ctx) => !ctx.furnaceConfigExists || !ctx.furnaceConfig,
127
+ run: async (ctx) => {
128
+ const config = ctx.furnaceConfig;
129
+ if (!config)
130
+ return [];
131
+ const orphans = await collectOrphans(ctx.projectRoot, config);
132
+ const overrideCount = orphans.overrides.length;
133
+ const customCount = orphans.customNames.length;
134
+ if (overrideCount === 0 && customCount === 0) {
135
+ return ok('Furnace manifest sync');
136
+ }
137
+ const summary = formatOrphanSummary(orphans);
138
+ if (!ctx.options.repairFurnace) {
139
+ return warning('Furnace manifest sync', summary, 'Run "fireforge doctor --repair-furnace" to re-register override orphans from their override.json sidecars (custom orphans are listed for manual follow-up).');
140
+ }
141
+ const repairResult = await repairOrphanOverrides(ctx.projectRoot, orphans.overrides);
142
+ if (repairResult.writeError) {
143
+ return failure('Furnace manifest sync', `Repair failed while writing furnace.json: ${repairResult.writeError}`, 'Fix the underlying filesystem error and retry the doctor command.');
144
+ }
145
+ const { restored, unrecoverable } = repairResult;
146
+ const restoreDetail = restored.length > 0
147
+ ? `Re-registered ${restored.length} override${restored.length === 1 ? '' : 's'} (${restored.join(', ')}) from their override.json sidecars.`
148
+ : '';
149
+ const unrecoverableDetail = unrecoverable.length > 0
150
+ ? ` Could not recover ${unrecoverable.length} override${unrecoverable.length === 1 ? '' : 's'} without a valid override.json (${unrecoverable.join(', ')}) — delete components/overrides/<name> or re-run "fireforge furnace override" to restore the entry.`
151
+ : '';
152
+ const customDetail = customCount > 0
153
+ ? ` ${customCount} custom ${customCount === 1 ? 'directory requires' : 'directories require'} manual action: re-run "fireforge furnace create" or delete components/custom/<name>/ to reconcile.`
154
+ : '';
155
+ return warning('Furnace manifest sync', `${restoreDetail}${unrecoverableDetail}${customDetail}`.trim() ||
156
+ 'Nothing to repair (orphans surfaced but all were already recoverable).');
157
+ },
158
+ };
159
+ //# sourceMappingURL=doctor-furnace-manifest-sync.js.map
@@ -10,6 +10,7 @@ import { validateAllComponents } from '../core/furnace-validate.js';
10
10
  import { toError } from '../utils/errors.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
12
  import { failure, ok, warning } from './doctor.js';
13
+ import { furnaceManifestSyncCheck } from './doctor-furnace-manifest-sync.js';
13
14
  const ENGINE_REPAIRABLE_OPERATIONS = [
14
15
  'preview-teardown',
15
16
  'apply-rollback',
@@ -507,5 +508,6 @@ export const FURNACE_DOCTOR_CHECKS = [
507
508
  furnaceStaleLockCheck,
508
509
  furnaceEngineStateCheck,
509
510
  furnaceComponentValidationCheck,
511
+ furnaceManifestSyncCheck,
510
512
  ];
511
513
  //# sourceMappingURL=doctor-furnace.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Ownership-aware working-tree check for `fireforge doctor`.
3
+ *
4
+ * Partitions engine-tree dirtiness into `branding`, `patch-backed`,
5
+ * `furnace`, `conflict`, and `unmanaged` buckets, and only warns on the
6
+ * last two — everything else is tool-managed state that the operator
7
+ * did not author directly.
8
+ *
9
+ * Split out of `doctor.ts` so that file stays under the per-file LOC
10
+ * budget; see the call site in `runEngineGitChecks`.
11
+ */
12
+ import type { DoctorCheck } from '../types/commands/index.js';
13
+ import type { DoctorCheckContext } from './doctor.js';
14
+ /**
15
+ * Inspects the engine working tree and returns a single
16
+ * `DoctorCheck`. Ownership-aware: patch-backed / branding / furnace
17
+ * rows are reported as OK with an ownership summary; unmanaged drift
18
+ * warns; cross-patch conflicts warn loudly with a pointer at
19
+ * `fireforge status --ownership` + `fireforge verify`.
20
+ *
21
+ * Before 0.16.1 this check warned on every dirty row regardless of
22
+ * ownership and told the operator to export/discard/reset — advice
23
+ * that was actively destructive on a patch-backed import (eval
24
+ * Finding: a correctly imported 126-file patch stack was reported as
25
+ * unhealthy and the suggested fix would have dropped the entire
26
+ * import). Returns `undefined` when the worktree is clean so the
27
+ * caller can emit its own ok() row.
28
+ */
29
+ export declare function inspectEngineWorkingTree(ctx: DoctorCheckContext): Promise<DoctorCheck | undefined>;
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Ownership-aware working-tree check for `fireforge doctor`.
4
+ *
5
+ * Partitions engine-tree dirtiness into `branding`, `patch-backed`,
6
+ * `furnace`, `conflict`, and `unmanaged` buckets, and only warns on the
7
+ * last two — everything else is tool-managed state that the operator
8
+ * did not author directly.
9
+ *
10
+ * Split out of `doctor.ts` so that file stays under the per-file LOC
11
+ * budget; see the call site in `runEngineGitChecks`.
12
+ */
13
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
14
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
15
+ import { classifyFiles } from '../core/status-classify.js';
16
+ import { ok, warning } from './doctor.js';
17
+ function summarizeWorkingTreeChangeCount(changeCount) {
18
+ return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
19
+ }
20
+ function formatManagedDetail(counts) {
21
+ return [
22
+ counts.patchBacked > 0 ? `${counts.patchBacked} patch-backed` : null,
23
+ counts.branding > 0 ? `${counts.branding} branding` : null,
24
+ counts.furnace > 0 ? `${counts.furnace} furnace` : null,
25
+ ]
26
+ .filter((part) => part !== null)
27
+ .join(', ');
28
+ }
29
+ /**
30
+ * Inspects the engine working tree and returns a single
31
+ * `DoctorCheck`. Ownership-aware: patch-backed / branding / furnace
32
+ * rows are reported as OK with an ownership summary; unmanaged drift
33
+ * warns; cross-patch conflicts warn loudly with a pointer at
34
+ * `fireforge status --ownership` + `fireforge verify`.
35
+ *
36
+ * Before 0.16.1 this check warned on every dirty row regardless of
37
+ * ownership and told the operator to export/discard/reset — advice
38
+ * that was actively destructive on a patch-backed import (eval
39
+ * Finding: a correctly imported 126-file patch stack was reported as
40
+ * unhealthy and the suggested fix would have dropped the entire
41
+ * import). Returns `undefined` when the worktree is clean so the
42
+ * caller can emit its own ok() row.
43
+ */
44
+ export async function inspectEngineWorkingTree(ctx) {
45
+ const { paths } = ctx;
46
+ const rawStatus = await getWorkingTreeStatus(paths.engine);
47
+ const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
48
+ if (workingTreeStatus.length === 0) {
49
+ return ok('Engine working tree');
50
+ }
51
+ if (!ctx.config) {
52
+ return warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.');
53
+ }
54
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(ctx.projectRoot);
55
+ const classified = await classifyFiles(workingTreeStatus.map((entry) => ({ status: entry.status, file: entry.file })), paths.engine, paths.patches, ctx.config.binaryName, furnacePrefixes);
56
+ const counts = {
57
+ branding: 0,
58
+ furnace: 0,
59
+ patchBacked: 0,
60
+ conflict: 0,
61
+ unmanaged: 0,
62
+ };
63
+ for (const entry of classified) {
64
+ if (entry.classification === 'branding')
65
+ counts.branding++;
66
+ else if (entry.classification === 'furnace')
67
+ counts.furnace++;
68
+ else if (entry.classification === 'patch-backed')
69
+ counts.patchBacked++;
70
+ else if (entry.classification === 'conflict')
71
+ counts.conflict++;
72
+ else
73
+ counts.unmanaged++;
74
+ }
75
+ if (counts.conflict > 0) {
76
+ return warning('Engine working tree', `Engine working tree has ${counts.conflict} cross-patch ownership conflict${counts.conflict === 1 ? '' : 's'}. Multiple patches in patches.json claim the same file.`, 'Run "fireforge status --ownership" to see the conflicting patches, then run "fireforge verify" and resolve the overlap.');
77
+ }
78
+ const managedTotal = counts.branding + counts.furnace + counts.patchBacked;
79
+ if (counts.unmanaged === 0) {
80
+ const managedDetail = formatManagedDetail(counts);
81
+ return {
82
+ name: 'Engine working tree',
83
+ passed: true,
84
+ severity: 'ok',
85
+ message: `${managedTotal} tool-managed change${managedTotal === 1 ? '' : 's'} (${managedDetail}), 0 unmanaged. Use "fireforge status --ownership" for details.`,
86
+ };
87
+ }
88
+ const managedTail = managedTotal > 0
89
+ ? ` (${managedTotal} other change${managedTotal === 1 ? '' : 's'} are tool-managed: ${formatManagedDetail(counts)}).`
90
+ : '';
91
+ return warning('Engine working tree', `Engine working tree has ${counts.unmanaged} unmanaged change${counts.unmanaged === 1 ? '' : 's'}.${managedTail}`, 'Use "fireforge status --ownership" to separate patch-backed from unmanaged files, then export, discard, or reset only the unmanaged set.');
92
+ }
93
+ //# sourceMappingURL=doctor-working-tree.js.map
@@ -2,7 +2,6 @@ import { configExists, getProjectPaths, loadConfig, loadState } from '../core/co
2
2
  import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
3
3
  import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
4
4
  import { ensureGit } from '../core/git-base.js';
5
- import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
6
5
  import { ensureMach, ensurePython } from '../core/mach.js';
7
6
  import { countPatches } from '../core/patch-apply.js';
8
7
  import { rebuildPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
@@ -12,6 +11,7 @@ import { pathExists } from '../utils/fs.js';
12
11
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
13
12
  import { executableExists } from '../utils/process.js';
14
13
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
14
+ import { inspectEngineWorkingTree } from './doctor-working-tree.js';
15
15
  /**
16
16
  * Builds a DoctorCheck object representing a successful "OK" check.
17
17
  * Exported for sibling check modules that declare `DoctorCheckDefinition`
@@ -58,9 +58,6 @@ async function executeCheck(definition, ctx) {
58
58
  return [failure(definition.name, toError(err).message, definition.fix)];
59
59
  }
60
60
  }
61
- function summarizeWorkingTreeChangeCount(changeCount) {
62
- return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
63
- }
64
61
  /**
65
62
  * Runs the subset of engine checks that depend on a healthy git repository
66
63
  * and HEAD. This group shares mutable state (currentHead, canValidateBranch),
@@ -89,13 +86,9 @@ async function runEngineGitChecks(ctx) {
89
86
  rows.push(ok('Engine state consistency'));
90
87
  }
91
88
  }
92
- const rawStatus = await getWorkingTreeStatus(paths.engine);
93
- const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
94
- if (workingTreeStatus.length > 0) {
95
- rows.push(warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.'));
96
- }
97
- else {
98
- rows.push(ok('Engine working tree'));
89
+ const workingTreeRow = await inspectEngineWorkingTree(ctx);
90
+ if (workingTreeRow) {
91
+ rows.push(workingTreeRow);
99
92
  }
100
93
  let branch;
101
94
  if (canValidateBranch) {
@@ -227,6 +220,11 @@ const DOCTOR_CHECKS = [
227
220
  {
228
221
  name: 'Engine is git repository',
229
222
  skipIf: (ctx) => !ctx.engineExists,
223
+ // runEngineGitChecks consults ctx.config for ownership-aware
224
+ // working-tree classification; declare the dependency so a future
225
+ // reorder doesn't silently regress the doctor back to the
226
+ // count-only fallback.
227
+ dependsOn: ['fireforge.json is valid'],
230
228
  run: async (ctx) => {
231
229
  const isRepo = await isGitRepository(ctx.paths.engine);
232
230
  if (!isRepo) {