@hominis/fireforge 0.16.5 → 0.17.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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +5 -3
  3. package/dist/src/commands/build.js +16 -7
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor.js +14 -1
  6. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  7. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  8. package/dist/src/commands/furnace/create-templates.js +11 -2
  9. package/dist/src/commands/furnace/init.js +97 -9
  10. package/dist/src/commands/furnace/rename.js +110 -0
  11. package/dist/src/commands/lint.js +55 -4
  12. package/dist/src/commands/resolve.d.ts +25 -1
  13. package/dist/src/commands/resolve.js +25 -15
  14. package/dist/src/commands/status.js +100 -122
  15. package/dist/src/commands/test.js +15 -2
  16. package/dist/src/commands/wire.js +34 -8
  17. package/dist/src/core/config.d.ts +33 -0
  18. package/dist/src/core/config.js +43 -0
  19. package/dist/src/core/furnace-config.d.ts +23 -2
  20. package/dist/src/core/furnace-config.js +26 -3
  21. package/dist/src/core/mach.d.ts +31 -0
  22. package/dist/src/core/mach.js +45 -1
  23. package/dist/src/core/marionette-port.d.ts +50 -0
  24. package/dist/src/core/marionette-port.js +215 -0
  25. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  26. package/dist/src/core/patch-manifest-consistency.js +16 -1
  27. package/dist/src/core/status-classify.d.ts +54 -0
  28. package/dist/src/core/status-classify.js +134 -0
  29. package/dist/src/core/token-dark-mode.d.ts +49 -0
  30. package/dist/src/core/token-dark-mode.js +182 -0
  31. package/dist/src/core/token-manager.js +17 -33
  32. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  33. package/dist/src/core/wire-dom-fragment.js +40 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Eval-driven hardening
6
+
7
+ - **`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
+ - **`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
+ - **`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.
10
+ - **`fireforge furnace create --with-tests` — scaffolded mochikit test runs to completion.** `generateMochikitTestContent` in `src/commands/furnace/create-templates.ts` previously emitted `SimpleTest.waitForExplicitFinish()` alongside an `add_task(...)` and no explicit `SimpleTest.finish()`. The test harness waits forever: `waitForExplicitFinish()` tells the harness not to finish on script end, and the `add_task`-managed finish never fires because the scaffold has no explicit `finish()` body. The 2026-04-21 eval's `fireforge test --headless toolkit/content/tests/widgets/test_ff-chip-row.html` hung until the operator SIGINT'd it. The fix removes the `waitForExplicitFinish()` call — `add_task` already calls `SimpleTest.finish()` when every queued task resolves, matching the convention upstream widget tests (`toolkit/content/tests/widgets/test_moz-button.html` and siblings) use. A regression test in `src/commands/furnace/__tests__/create-mochikit.test.ts` pins the contract that the generated content must not contain `waitForExplicitFinish`.
11
+ - **`fireforge furnace chrome-doc create --with-tests` — packaging test probes the correct packaged CSS path.** `generateChromeDocPackagingTest` in `src/commands/furnace/chrome-doc-tests.ts` probed `<AppDir>/chrome/browser/skin/classic/browser/<name>-chrome.css`, but the `jar.inc.mn` entry in `chrome-doc-templates.ts:chromeDocJarIncMnCssEntry` registers the file at `content/browser/<name>-chrome.css` — so the packaged location is `.../chrome/browser/content/browser/<name>-chrome.css`, not the skin layout. The 2026-04-21 eval's `fireforge test --build` against a scaffolded chrome-doc failed with a spurious "missing" assertion even though the file was correctly packaged. Both the primary probe and the macOS `.app`-bundle fallback now name the `content/browser/` layout, and a negative-match test guards against anyone pinning it back to `skin/classic/browser/` in a future refactor.
12
+ - **`fireforge status --json` — cross-patch ownership conflicts surface as `conflict` with `claimedBy`.** `classifyFiles` in `src/commands/status.ts` tracked patch ownership as a `Set<string>` and collapsed multi-owner paths into the single-owner branch, where the content-compare then routed them into `unmanaged` when the engine content didn't match any single patch's expected result. The 2026-04-21 eval's `status --json` run reported `"classification": "unmanaged"` on two files that `status --ownership` correctly labelled `CONFLICT` — scripts built on the JSON view mis-diagnosed the drift and could have taken the wrong corrective action. The classifier now builds a `Map<string, string[]>` (filename → claiming patch filenames), emits `classification: "conflict"` for entries claimed by two or more patches, and attaches `claimedBy: string[]` to those entries so machine consumers can read the ownership set directly. The human default-mode output now surfaces a `Cross-patch ownership conflicts` section at the top pointing at `status --ownership` and `re-export --files` for recovery. Single-claim entries stay byte-identical to the pre-0.16.0 JSON shape (no unconditional `claimedBy` field) so parsers unaware of the new classification continue to work.
13
+ - **`fireforge furnace init --ftl-base-path` — rejects file-shaped values up-front.** `validateFtlBasePath` in `src/commands/furnace/init.ts` only enforced syntactic safety (no absolute paths, no `..`, no null bytes). A plausible-but-invalid value like `browser/forgefresh.ftl` passed the gate, and the next localized `furnace create` scaffolded a component whose generated `.mjs` referenced `insertFTLIfNeeded("<name>.ftl")` while `furnace.json` never got the component entry — the scaffold was orphaned, every follow-up command failed with "not found in furnace.json", and the 2026-04-21 eval recorded this as Finding #5 (apparent non-registration) whose real root cause was Finding #6 (bad `ftlBasePath`). The validator now refuses any value whose basename carries `.ftl`, `.properties`, or `.dtd` with a message that names the file and points at a locale directory (`toolkit/locales/en-US/toolkit/global` or `browser/locales/en-US/browser`). When the engine directory is present on disk, it additionally probes whether the resolved path is a directory and warns (non-blocking) if the path does not yet exist — a fresh project that has not `fireforge download`-ed yet is legitimate.
14
+ - **`fireforge furnace init` — defaults `tokenPrefix` from `fireforge.json`'s `binaryName`.** `createDefaultFurnaceConfig` previously accepted no arguments and omitted `tokenPrefix` entirely, so every fresh project that ran `furnace init` → `token add` → `token coverage` got `0 tokens` and "all unknown" reports until the operator discovered the missing key and hand-edited `furnace.json`. The helper now accepts `{ binaryName }` and, when passed, seeds `tokenPrefix: \`--${binaryName}-\``so the coverage scan has a prefix to key off immediately.`furnaceInitCommand`best-effort-loads`fireforge.json`and threads the binaryName through — a project that initialises Furnace before`fireforge setup`completes still gets a valid prefix-less default (and`token coverage` continues to warn when invoked without a prefix set).
15
+ - **`fireforge build` + `fireforge build --ui` — per-project build lock prevents overlap.** A second `fireforge build --ui` launched while a full `fireforge build` was still running against the same engine tree raced the `obj-*` directory and failed immediately with `No rule to make target 'XUL'` — mach's downstream consequence of an incomplete backend, not a clue that the overlap was the root cause. A new `withBuildLock(projectRoot, operation)` in `src/core/mach.ts` backs onto `withFileLock` at `.fireforge-build.lock` beside the project root; `buildCommand` in `src/commands/build.ts` wraps both `build()` and `buildUI()` call paths in the lock. The refusal message names the holder PID and points at the stale-lock recovery path. Timeouts are bumped to 24h (a slow full build legitimately exceeds the default 30s) but the lock releases on process exit or via PID-alive stale recovery so a crashed build cannot permanently wedge the next invocation. A dedicated integration test in `src/core/__tests__/build-lock.integration.test.ts` pins the serialisation, throw-propagation, and stale-recovery contracts.
16
+ - **`fireforge wire --dry-run` — insertion-point probe runs in both modes.** The dry-run branch previously skipped the `pathExists(join(paths.engine, domTargetPath))` check that the real-run branch ran, and even with existence confirmed the real run could still throw `Could not find insertion point in chrome document` from deep inside `addDomFragment` when the resolved chrome doc offered neither `#include browser-sets.inc` nor `<html:body>`. The 2026-04-21 eval's `wire ... --dom ... --dry-run` previewed a plausible plan targeting `tokenHostDocuments[0]`, then the same command without `--dry-run` failed against a `furnace chrome-doc create`-scaffolded document that lacked both anchors. A new `probeDomFragmentInsertionPoint` helper in `src/core/wire-dom-fragment.ts` reads the chrome doc and runs the same tokenised + legacy insertion-point scan the real run uses; `wireCommand` now calls it in both modes and surfaces the `Could not find insertion point` error before printing the plan. The dry-run preview now refuses the same cases the real run would refuse, before any operator commits to executing.
17
+ - **`fireforge resolve` — `--yes` escape hatch + clearer two-step messaging.** The command refused any non-interactive invocation even after a CI-assisted manual merge was complete, because the TTY guard ran unconditionally — scripted recovery flows could complete the merge but could not then record the refreshed patch body. A new `--yes` / `-y` flag in `src/commands/resolve.ts` skips the interactive `confirm(...)` prompt and passes through in non-interactive mode; the unconditional TTY refusal fires only when the flag is absent. The command description changes from `Update a broken patch with manual fixes and continue` to `Update a broken patch with manual fixes (then run "fireforge import" to resume the queue)`, and the post-success info line now names the second-step command explicitly — the old copy implied a one-step flow where operators sometimes believed resolve continued the queue itself. Help snapshot under `src/__tests__/__snapshots__/help.test.ts.snap` is refreshed to match; resolve tests cover both the `--yes`-in-non-TTY path and the continuation messaging.
18
+ - **`fireforge test` — marionette port probe catches stale browsers before mach launches.** An interrupted `fireforge test --headless` run can leave a `<binaryName> -marionette` child listening on port `2828` with parent PID `1`. The next `fireforge test` run — potentially in a sibling FireForge project — fails immediately with a mach Marionette bind error that points nowhere near the real cause, and the generic "delete obj-\* and rebuild" guidance wastes operator time. A new `probeMarionettePort` / `assertMarionettePortAvailable` pair in `src/core/marionette-port.ts` runs `lsof -i tcp:2828 -sTCP:LISTEN` (POSIX) or `Get-NetTCPConnection` (Windows) before every test launch; when the holder's basename or command line identifies a Firefox-family browser (including `binaryName` from `fireforge.json` for branded forks), the probe raises a targeted `GeneralError` naming the PID and the exact `kill` command. Unrelated listeners produce a softer "this is not a FireForge-launched browser" error so the operator can tell the two cases apart. The probe is best-effort: missing `lsof`/PowerShell falls back to `{ inUse: false }` rather than failing the test run itself.
19
+ - **`fireforge lint` (default) — aggregate-mode skips tool-managed branding.** `resolveLintDiff` in `src/commands/lint.ts` passed the full `getAllDiff(engineDir)` to `lintExportedPatch` when no file list was supplied, so a fresh-setup workspace with 63 modified branding files fired `large-patch-lines`, `large-patch-files`, and `missing-license-header` on tool-managed content the operator never authored. The 2026-04-21 eval's first `fireforge lint` on a newly-built `fresh/` tree failed with blocking patch-lint errors despite the project being minimal-customisation. The aggregate-mode branch now loads `fireforge.json`'s `binaryName`, partitions the dirty tree with `isBrandingManagedPath`, and passes only the non-branding paths into `getDiffForFilesAgainstHead`. The operator sees a one-line `info()` naming the excluded count so the exclusion is visible, not silent. Explicit-path mode (`fireforge lint <path>`) preserves the previous behaviour — passing a branding path explicitly still lints it, so operators who need to audit generated branding content can do so.
20
+ - **`fireforge doctor --repair-patches-manifest` — names every reconstructed entry.** `rebuildPatchesManifest` previously returned only the rebuilt manifest, silently overwriting `description` / `createdAt` with generic fallback values when the existing entry was missing. FireForge patch files do not carry header metadata that could carry human-written descriptions forward, so full fidelity is impossible — but at least visibility is. The helper now returns `{ manifest, recoveredFilenames }` in `src/core/patch-manifest-consistency.ts`; the doctor repair path prints a per-filename `warn(...)` telling the operator exactly which manifest entry was reconstructed from generic defaults and suggesting they edit `patches.json` if they have the original description backed up. The summary row count now reports both the total patches rebuilt and the subset that needed reconstruction.
21
+
3
22
  ## 0.16.0
4
23
 
5
24
  ### UX, correctness, and consistency
package/README.md CHANGED
@@ -174,7 +174,7 @@ When `fireforge import` fails on a patch, fix the `.rej` files in `engine/`, the
174
174
  fireforge resolve
175
175
  ```
176
176
 
177
- This re-exports the fixed patch and continues applying the remaining stack.
177
+ This re-exports the fixed patch and clears the conflict state. The command is deliberately a single-patch refresh — to continue applying the remainder of the queue, run `fireforge import` afterwards. For scripted or CI-driven recovery, pass `--yes` (or `-y`) to skip the interactive "are you done?" prompt; the flag is the explicit opt-in for non-interactive use once the manual merge is complete.
178
178
 
179
179
  <details>
180
180
  <summary>Patch manifest format</summary>
@@ -211,7 +211,7 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
211
211
 
212
212
  `fireforge lint` runs automatically during export, export-all and re-export. Use `--skip-lint` to downgrade errors to warnings. Errors block the export; warnings are printed but do not block.
213
213
 
214
- By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed. 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; the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
214
+ 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
215
 
216
216
  | Check | Scope | Severity |
217
217
  | ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
@@ -409,6 +409,8 @@ fireforge config firefox.version 145.0.0esr
409
409
  fireforge config customKey "value" --force
410
410
  ```
411
411
 
412
+ Writes are serialised behind a sidecar lock — two concurrent `fireforge config` invocations against the same `fireforge.json` (for example, parallel automation steps) queue instead of racing the read-modify-write. The lock is released automatically on process exit; stale locks from a crashed earlier command are reclaimed on the next invocation via the PID-alive probe.
413
+
412
414
  ### Patch queue management
413
415
 
414
416
  ```bash
@@ -440,7 +442,7 @@ fireforge watch
440
442
  fireforge token add --category 'Colors — General' -- --my-color 'light-dark(#fff, #000)'
441
443
  ```
442
444
 
443
- Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`.
445
+ Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`, and derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
444
446
 
445
447
  ### Diff-scoped lint (`lint --since`)
446
448
 
@@ -5,7 +5,7 @@ import { auditBuildArtifacts } from '../core/build-audit.js';
5
5
  import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
6
6
  import { prepareBuildEnvironment } from '../core/build-prepare.js';
7
7
  import { getProjectPaths, loadConfig } from '../core/config.js';
8
- import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, } from '../core/mach.js';
8
+ import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, withBuildLock, } from '../core/mach.js';
9
9
  import { GeneralError } from '../errors/base.js';
10
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
11
  import { toError } from '../utils/errors.js';
@@ -129,12 +129,21 @@ export async function buildCommand(projectRoot, options) {
129
129
  const startTime = Date.now();
130
130
  let exitCode;
131
131
  try {
132
- if (options.ui) {
133
- exitCode = await buildUI(paths.engine);
134
- }
135
- else {
136
- exitCode = await build(paths.engine, jobs);
137
- }
132
+ // Hold the per-project build lock across the mach invocation so two
133
+ // overlapping `fireforge build` / `fireforge build --ui` commands
134
+ // against the same engine tree serialise instead of racing through
135
+ // the same obj-*. 2026-04-21 eval: a `build --ui` launched during
136
+ // an in-progress full build hit `No rule to make target 'XUL'` in
137
+ // mach, which is the downstream consequence of an incomplete
138
+ // backend — not a clue that a concurrent build was the cause. The
139
+ // lock turns the second invocation's failure into an explicit
140
+ // refusal naming the holder PID.
141
+ exitCode = await withBuildLock(projectRoot, async () => {
142
+ if (options.ui) {
143
+ return buildUI(paths.engine);
144
+ }
145
+ return build(paths.engine, jobs);
146
+ });
138
147
  }
139
148
  catch (error) {
140
149
  throw new BuildError('Build process failed to start', options.ui ? 'mach build faster' : 'mach build', error instanceof Error ? error : undefined);
@@ -1,4 +1,4 @@
1
- import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, writeConfig, writeConfigDocument, } from '../core/config.js';
1
+ import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, withConfigFileLock, writeConfig, writeConfigDocument, } from '../core/config.js';
2
2
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
3
3
  import { toError } from '../utils/errors.js';
4
4
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -112,25 +112,37 @@ export async function configCommand(projectRoot, key, value, options = {}) {
112
112
  const parsedValue = parseValue(value, key);
113
113
  const keyIsKnown = SUPPORTED_CONFIG_PATHS.includes(key);
114
114
  try {
115
- // `--force` is intended as an escape hatch for *unknown* keys; it
116
- // should not also let the user write a structurally invalid value
117
- // for a *known* key. Apply strict validation whenever the key is
118
- // listed in SUPPORTED_CONFIG_PATHS, regardless of --force, and only
119
- // skip validation for genuinely unknown key paths.
120
- if (options.force && !keyIsKnown) {
121
- // Seed mutation from the raw on-disk document so previously-forced
122
- // keys (which `validateConfig` would strip) survive the round-trip.
123
- // Without this, writing a second --force key would silently drop
124
- // every earlier forced key from fireforge.json.
125
- const rawConfig = await loadRawConfigDocument(projectRoot);
126
- const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
127
- await writeConfigDocument(projectRoot, updatedConfig);
128
- }
129
- else {
130
- const config = await loadConfig(projectRoot);
131
- const updatedConfig = mutateConfig(config, key, parsedValue);
132
- await writeConfig(projectRoot, updatedConfig);
133
- }
115
+ // Serialise the read mutate write round-trip behind the sidecar
116
+ // config lock so two concurrent `fireforge config` invocations can't
117
+ // each read the pre-state, mutate their own copy, and clobber each
118
+ // other on write. Before the lock, the 2026-04-21 eval reproduced
119
+ // silent data loss with two parallel `fireforge config <key>
120
+ // <value>` commands writing different keys: both exited 0, one key
121
+ // survived, the other vanished. Atomic file writes (temp + rename)
122
+ // were never enough on their own the lost update happens before
123
+ // the rename, inside the read-modify step. Readers stay lock-free
124
+ // (see `withConfigFileLock` docstring).
125
+ await withConfigFileLock(projectRoot, async () => {
126
+ // `--force` is intended as an escape hatch for *unknown* keys; it
127
+ // should not also let the user write a structurally invalid value
128
+ // for a *known* key. Apply strict validation whenever the key is
129
+ // listed in SUPPORTED_CONFIG_PATHS, regardless of --force, and only
130
+ // skip validation for genuinely unknown key paths.
131
+ if (options.force && !keyIsKnown) {
132
+ // Seed mutation from the raw on-disk document so previously-forced
133
+ // keys (which `validateConfig` would strip) survive the round-trip.
134
+ // Without this, writing a second --force key would silently drop
135
+ // every earlier forced key from fireforge.json.
136
+ const rawConfig = await loadRawConfigDocument(projectRoot);
137
+ const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
138
+ await writeConfigDocument(projectRoot, updatedConfig);
139
+ }
140
+ else {
141
+ const config = await loadConfig(projectRoot);
142
+ const updatedConfig = mutateConfig(config, key, parsedValue);
143
+ await writeConfig(projectRoot, updatedConfig);
144
+ }
145
+ });
134
146
  }
135
147
  catch (error) {
136
148
  throw new InvalidArgumentError(`Invalid value for "${key}": ${toError(error).message}`, key);
@@ -314,7 +314,20 @@ const DOCTOR_CHECKS = [
314
314
  }
315
315
  try {
316
316
  const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version);
317
- return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.patches.length} patch${repaired.patches.length === 1 ? '' : 'es'}. Review recovered metadata before release.`);
317
+ // 2026-04-21 eval (Finding #17): the repair path silently
318
+ // overwrote useful human-written descriptions on recovered
319
+ // entries, leaving the queue less trustworthy as an audit
320
+ // trail. The rebuilder now returns the list of filenames
321
+ // whose metadata was entirely invented, and we name them
322
+ // explicitly here so the operator knows exactly which
323
+ // patches to review. Names that DID have a preserved entry
324
+ // (only `filesAffected` / ordering drifted) are not flagged.
325
+ if (repaired.recoveredFilenames.length > 0) {
326
+ for (const filename of repaired.recoveredFilenames) {
327
+ warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. Edit patches.json to restore the original description if you have it backed up.`);
328
+ }
329
+ }
330
+ return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
318
331
  }
319
332
  catch (err) {
320
333
  return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
@@ -117,9 +117,16 @@ add_task(async function test_${taskSuffix}_files_packaged() {
117
117
  ["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
118
118
  "${name}.xhtml",
119
119
  );
120
+ // The scoped CSS is registered through jar.inc.mn under
121
+ // \`content/browser/<name>-chrome.css\` (see \`chromeDocJarIncMnCssEntry\`
122
+ // in \`src/commands/furnace/chrome-doc-templates.ts\`), so the packaged
123
+ // file lands under \`chrome/browser/content/browser/\`, not under
124
+ // \`skin/classic/browser/\`. The 2026-04-21 eval's first
125
+ // \`fireforge test --build\` against a scaffolded chrome-doc reported
126
+ // a false failure because the probe was looking at the skin layout.
120
127
  probeEither(
121
- ["chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
122
- ["browser", "chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
128
+ ["chrome", "browser", "content", "browser", "${name}-chrome.css"],
129
+ ["browser", "chrome", "browser", "content", "browser", "${name}-chrome.css"],
123
130
  "${name}-chrome.css",
124
131
  );
125
132
  });
@@ -76,6 +76,17 @@ export declare function mochikitTestFileName(name: string): string;
76
76
  * depend on the component's shape; operators can extend the test using
77
77
  * the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
78
78
  * rely on.
79
+ *
80
+ * The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
81
+ * `add_task` owns the test lifecycle: when every queued task resolves,
82
+ * the task harness calls `SimpleTest.finish()` on its own. Combining
83
+ * `waitForExplicitFinish()` with `add_task` *and* no explicit
84
+ * `SimpleTest.finish()` inside the task body makes the harness wait
85
+ * forever, which the 2026-04-21 eval run tripped into as an indefinite
86
+ * hang on a `fireforge test --headless` against a scaffolded widget
87
+ * test. Leaving `waitForExplicitFinish()` out matches the convention
88
+ * upstream toolkit widget tests use (see `test_moz-button.html` and
89
+ * siblings under `toolkit/content/tests/widgets/`).
79
90
  */
80
91
  export declare function generateMochikitTestContent(name: string): string;
81
92
  /**
@@ -227,6 +227,17 @@ export function mochikitTestFileName(name) {
227
227
  * depend on the component's shape; operators can extend the test using
228
228
  * the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
229
229
  * rely on.
230
+ *
231
+ * The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
232
+ * `add_task` owns the test lifecycle: when every queued task resolves,
233
+ * the task harness calls `SimpleTest.finish()` on its own. Combining
234
+ * `waitForExplicitFinish()` with `add_task` *and* no explicit
235
+ * `SimpleTest.finish()` inside the task body makes the harness wait
236
+ * forever, which the 2026-04-21 eval run tripped into as an indefinite
237
+ * hang on a `fireforge test --headless` against a scaffolded widget
238
+ * test. Leaving `waitForExplicitFinish()` out matches the convention
239
+ * upstream toolkit widget tests use (see `test_moz-button.html` and
240
+ * siblings under `toolkit/content/tests/widgets/`).
230
241
  */
231
242
  export function generateMochikitTestContent(name) {
232
243
  return `<!DOCTYPE html>
@@ -244,8 +255,6 @@ export function generateMochikitTestContent(name) {
244
255
  <script type="module">
245
256
  import "chrome://global/content/elements/${name}.mjs";
246
257
 
247
- SimpleTest.waitForExplicitFinish();
248
-
249
258
  add_task(async function test_${name.replace(/-/g, '_')}_defined() {
250
259
  const ctor = await customElements.whenDefined("${name}");
251
260
  ok(ctor, "${name} custom element should be defined");
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { dirname, isAbsolute, join, normalize } from 'node:path';
2
+ import { stat } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
3
4
  import { text } from '@clack/prompts';
4
5
  import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
5
6
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
@@ -11,12 +12,46 @@ import { toError } from '../../utils/errors.js';
11
12
  import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
12
13
  import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
13
14
  /**
14
- * Validates an FTL base path before writing it to furnace.json. Rejects
15
- * absolute paths, null bytes, and any normalised segment starting with
16
- * `..` the previous `includes('..')` substring check caught the common
17
- * case but missed `./../../` and absolute paths that are arguably worse.
15
+ * File extensions that are definitely FTL resources (not locale
16
+ * directories). A value ending in one of these is almost certainly the
17
+ * result of the operator pointing at a single FTL file instead of the
18
+ * locale directory that contains it.
19
+ *
20
+ * 2026-04-21 eval: `furnace init --ftl-base-path browser/forgefresh.ftl`
21
+ * produced a misleading success path — the subsequent
22
+ * `furnace create --localized` scaffolded an `.mjs` referencing
23
+ * `insertFTLIfNeeded("<name>.ftl")` while furnace.json had no component
24
+ * entry, leaving the scaffold orphaned. Switching to a locale directory
25
+ * (`toolkit/locales/en-US/toolkit/global`) fixed the downstream path.
26
+ * Rejecting file-shaped values up-front keeps the operator on the
27
+ * correct path before any partial state is written.
28
+ */
29
+ const FTL_FILE_EXTENSIONS = new Set(['.ftl', '.properties', '.dtd']);
30
+ function hasFtlFileExtension(value) {
31
+ const lower = value.toLowerCase();
32
+ const dotIdx = lower.lastIndexOf('.');
33
+ const slashIdx = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
34
+ if (dotIdx <= slashIdx)
35
+ return false; // No extension in the basename.
36
+ return FTL_FILE_EXTENSIONS.has(lower.slice(dotIdx));
37
+ }
38
+ /**
39
+ * Validates an FTL base path before writing it to furnace.json.
40
+ * Rejects:
41
+ * - empty values and null bytes;
42
+ * - absolute paths (POSIX or Windows-drive) that escape the engine;
43
+ * - `..` segments that escape the engine;
44
+ * - file-shaped values ending in `.ftl` / `.properties` / `.dtd`
45
+ * (these are locale resources, not directories — the operator
46
+ * almost certainly meant to name the parent directory).
47
+ *
48
+ * When {@link engineDir} is provided and exists on disk, the resolved
49
+ * `engine/${value}` path is probed: if it exists but is not a
50
+ * directory, the same file-shape error fires; if it does not exist yet,
51
+ * a non-blocking warning is logged (a fresh project that has not
52
+ * `fireforge download`-ed yet is the legitimate pre-existence case).
18
53
  */
19
- function validateFtlBasePath(value) {
54
+ async function validateFtlBasePath(value, engineDir) {
20
55
  if (value.length === 0) {
21
56
  throw new FurnaceError('ftlBasePath must not be empty.');
22
57
  }
@@ -30,6 +65,40 @@ function validateFtlBasePath(value) {
30
65
  if (normalized === '..' || normalized.startsWith('../')) {
31
66
  throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
32
67
  }
68
+ if (hasFtlFileExtension(value)) {
69
+ throw new FurnaceError(`ftlBasePath "${value}" looks like a file (basename "${basename(value)}" ends in .ftl/.properties/.dtd), but FireForge expects a locale directory such as toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser. Use the parent directory instead.`);
70
+ }
71
+ // Shape probe against the real filesystem when we have an engine
72
+ // directory to anchor against. The probe is best-effort: a missing
73
+ // engine directory or a not-yet-extracted locale tree is
74
+ // legitimate (an operator may `furnace init` before `fireforge
75
+ // download`), so we emit a warning rather than refusing.
76
+ if (engineDir) {
77
+ const resolved = join(engineDir, value);
78
+ try {
79
+ const info = await stat(resolved);
80
+ if (!info.isDirectory()) {
81
+ throw new FurnaceError(`ftlBasePath "${value}" resolves to a non-directory at ${resolved}. FireForge expects a locale directory (for example toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser).`);
82
+ }
83
+ }
84
+ catch (error) {
85
+ // FurnaceError (from the `isDirectory()` branch above) is a real
86
+ // shape failure — re-throw so the operator sees it.
87
+ if (error instanceof FurnaceError)
88
+ throw error;
89
+ // ENOENT is expected on a fresh project before `fireforge
90
+ // download` has populated engine/; only warn.
91
+ const code = typeof error === 'object' && error !== null && 'code' in error
92
+ ? error.code
93
+ : undefined;
94
+ if (code === 'ENOENT') {
95
+ warn(`ftlBasePath "${value}" does not yet exist at ${resolved}. This is fine if you have not run "fireforge download" yet; rerun "fireforge furnace init --force" after the engine is extracted to re-validate.`);
96
+ }
97
+ // Any other stat error is also best-effort ignored here — a
98
+ // permission issue or malformed engine checkout will surface on
99
+ // the next command that actually reads the FTL tree.
100
+ }
101
+ }
33
102
  }
34
103
  /**
35
104
  * Runs the furnace init command to create a default furnace.json with
@@ -42,8 +111,27 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
42
111
  if ((await furnaceConfigExists(projectRoot)) && !options.force) {
43
112
  throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
44
113
  }
45
- const config = createDefaultFurnaceConfig();
114
+ const paths = getProjectPaths(projectRoot);
115
+ // Seed the default furnace config with a tokenPrefix derived from
116
+ // fireforge.json's binaryName so `token coverage` sees real tokens on
117
+ // the very first run. The 2026-04-21 eval initialised Furnace, added
118
+ // tokens, ran coverage, and got `0 tokens / N unknown` — the prefix
119
+ // default was absent and the scan had nothing to key off. Loading
120
+ // fireforge.json here is best-effort: a project without one (e.g.
121
+ // mid-setup) falls through to the prefix-less default, and
122
+ // `token coverage` emits the existing "no tokenPrefix" warning.
123
+ let derivedBinaryName;
124
+ try {
125
+ const fireForgeConfig = await loadConfig(projectRoot);
126
+ derivedBinaryName = fireForgeConfig.binaryName;
127
+ }
128
+ catch {
129
+ // Best-effort only: initialising furnace without a fireforge.json is
130
+ // rare but not forbidden. Skip the prefix default in that case.
131
+ }
132
+ const config = createDefaultFurnaceConfig(derivedBinaryName ? { binaryName: derivedBinaryName } : {});
46
133
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
134
+ const engineForValidation = (await pathExists(paths.engine)) ? paths.engine : undefined;
47
135
  // Resolve componentPrefix
48
136
  if (options.prefix !== undefined) {
49
137
  config.componentPrefix = options.prefix;
@@ -66,7 +154,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
66
154
  }
67
155
  // Resolve ftlBasePath
68
156
  if (options.ftlBasePath !== undefined) {
69
- validateFtlBasePath(options.ftlBasePath);
157
+ await validateFtlBasePath(options.ftlBasePath, engineForValidation);
70
158
  config.ftlBasePath = options.ftlBasePath;
71
159
  }
72
160
  else if (isInteractive) {
@@ -80,7 +168,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
80
168
  }
81
169
  const ftlValue = ftlResult.trim();
82
170
  if (ftlValue) {
83
- validateFtlBasePath(ftlValue);
171
+ await validateFtlBasePath(ftlValue, engineForValidation);
84
172
  config.ftlBasePath = ftlValue;
85
173
  }
86
174
  }
@@ -122,6 +122,94 @@ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal
122
122
  }
123
123
  }
124
124
  }
125
+ /**
126
+ * Removes the deployed custom-widget directory at the old target path so
127
+ * a subsequent `furnace apply` is the single writer of the new name's
128
+ * deployment. Best-effort: logs a warning but never blocks the rename.
129
+ *
130
+ * 2026-04-21 eval: renaming `ff-chip-row` → `ff-chip-stack` registered
131
+ * and deployed the new name correctly but left `engine/toolkit/content/
132
+ * widgets/ff-chip-row/` in place. Subsequent `furnace sync` runs could
133
+ * not clear the stale widget, and packaging would have pulled in both
134
+ * copies. The snapshot is taken before the remove so the rollback
135
+ * journal restores the old directory if any later step in
136
+ * `performRenameMutations` fails.
137
+ */
138
+ async function removeStaleDeployedComponentDir(engineDir, oldTargetPath, journal) {
139
+ const oldDeployed = join(engineDir, oldTargetPath);
140
+ if (!(await pathExists(oldDeployed)))
141
+ return;
142
+ try {
143
+ await snapshotDir(journal, oldDeployed);
144
+ await removeDir(oldDeployed);
145
+ info(`Removed stale deployed widget directory: ${oldTargetPath}`);
146
+ }
147
+ catch (error) {
148
+ warn(`Could not remove stale deployed widget directory at ${oldTargetPath}: ${toError(error).message}. Remove it manually if needed.`);
149
+ }
150
+ }
151
+ /**
152
+ * Renames the mochikit test scaffold produced by `furnace create
153
+ * --with-tests` when the default test style is used. The scaffold lives
154
+ * at `engine/toolkit/content/tests/widgets/test_<name>.html`, and the
155
+ * accompanying `chrome.toml` entry names the same file. Neither piece
156
+ * was handled by the pre-0.16.0 rename, so operators were left with a
157
+ * `test_<old>.html` file that still imported `chrome://global/content/
158
+ * elements/<old>.mjs` and referenced `customElements.whenDefined("<old>")`
159
+ * — the test ran against a component that no longer existed under that
160
+ * name and either failed or (if the old component was still deployed)
161
+ * passed for the wrong reason.
162
+ *
163
+ * Best-effort: individual failures log a warning. The same journal used
164
+ * for the rest of the rename snapshots every touched file so a later
165
+ * failure rolls the pair back together.
166
+ */
167
+ async function renameMochikitTestFiles(engineDir, oldName, newName, journal) {
168
+ const testDir = join(engineDir, 'toolkit/content/tests/widgets');
169
+ if (!(await pathExists(testDir)))
170
+ return;
171
+ const oldTestFileName = `test_${oldName}.html`;
172
+ const newTestFileName = `test_${newName}.html`;
173
+ const oldTestPath = join(testDir, oldTestFileName);
174
+ const newTestPath = join(testDir, newTestFileName);
175
+ if (await pathExists(oldTestPath)) {
176
+ try {
177
+ await snapshotFile(journal, oldTestPath);
178
+ const content = await readText(oldTestPath);
179
+ const updatedContent = content
180
+ .replace(new RegExp(`chrome://global/content/elements/${escapeRegex(oldName)}\\.mjs`, 'g'), `chrome://global/content/elements/${newName}.mjs`)
181
+ .replace(new RegExp(`customElements\\.whenDefined\\("${escapeRegex(oldName)}"\\)`, 'g'), `customElements.whenDefined("${newName}")`)
182
+ .replace(new RegExp(`Test the ${escapeRegex(oldName)} `, 'g'), `Test the ${newName} `)
183
+ .replace(new RegExp(`add_task\\(async function test_${escapeRegex(oldName.replace(/-/g, '_'))}_defined\\(`, 'g'), `add_task(async function test_${newName.replace(/-/g, '_')}_defined(`)
184
+ .replace(new RegExp(`"${escapeRegex(oldName)} custom element`, 'g'), `"${newName} custom element`);
185
+ await writeText(newTestPath, updatedContent);
186
+ await removeFile(oldTestPath);
187
+ info(`Renamed mochikit test: ${oldTestFileName} → ${newTestFileName}`);
188
+ }
189
+ catch (error) {
190
+ warn(`Could not rename mochikit test file — ${toError(error).message}. Rename it manually if needed.`);
191
+ }
192
+ }
193
+ // Update `chrome.toml` entry if present. The file may live in the
194
+ // same widgets/tests directory as the test file itself; upstream
195
+ // convention places exactly one `chrome.toml` there for all widget
196
+ // scaffolds.
197
+ const chromeTomlPath = join(testDir, 'chrome.toml');
198
+ if (await pathExists(chromeTomlPath)) {
199
+ try {
200
+ const toml = await readText(chromeTomlPath);
201
+ if (toml.includes(`["${oldTestFileName}"]`)) {
202
+ await snapshotFile(journal, chromeTomlPath);
203
+ const updated = toml.replace(`["${oldTestFileName}"]`, `["${newTestFileName}"]`);
204
+ await writeText(chromeTomlPath, updated);
205
+ info(`Updated chrome.toml: ${oldTestFileName} → ${newTestFileName}`);
206
+ }
207
+ }
208
+ catch (error) {
209
+ warn(`Could not update widgets chrome.toml — ${toError(error).message}. Update it manually if needed.`);
210
+ }
211
+ }
212
+ }
125
213
  /**
126
214
  * Performs the transactional rename mutation inside a furnace lock.
127
215
  */
@@ -129,6 +217,11 @@ async function performRenameMutations(args) {
129
217
  const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
130
218
  const oldClassName = tagNameToClassName(oldName);
131
219
  const newClassName = tagNameToClassName(newName);
220
+ // Capture the pre-rename deployed target path so we know what to
221
+ // clean up in the engine tree. `updateConfigForCustomRename` rewrites
222
+ // `targetPath` in-place once the mutation enters phase 2, so we read
223
+ // it here while it still points at the old name's deployment.
224
+ const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
132
225
  await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
133
226
  const journal = createRollbackJournal();
134
227
  ctx.registerJournal(journal);
@@ -197,6 +290,23 @@ async function performRenameMutations(args) {
197
290
  // 7. Rename test files created by `furnace create --with-tests` (custom only).
198
291
  if (isCustom && (await pathExists(args.engineDir))) {
199
292
  await renameTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
293
+ // Mochikit scaffold + widgets/chrome.toml live in a different
294
+ // tree than browser.toml-registered browser-chrome tests, so
295
+ // renameTestFiles doesn't reach them. 2026-04-21 eval: a rename
296
+ // left `engine/toolkit/content/tests/widgets/test_<old>.html`
297
+ // and its `chrome.toml` entry pointing at the old name, which
298
+ // either failed the test run outright or (worse) passed for the
299
+ // wrong component.
300
+ await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
301
+ // Clear the stale deployed component directory so the next
302
+ // `furnace apply` is the single writer of the new name's
303
+ // deployment. Without this, eval runs showed the old widget
304
+ // still living at `engine/toolkit/content/widgets/<old>/`
305
+ // alongside the newly-deployed `engine/toolkit/content/
306
+ // widgets/<new>/`, with no signal to `status` / `verify`.
307
+ if (oldCustomTargetPath) {
308
+ await removeStaleDeployedComponentDir(args.engineDir, oldCustomTargetPath, journal);
309
+ }
200
310
  }
201
311
  info(`Renamed ${componentType} component: ${oldName} → ${newName}`);
202
312
  }