@hominis/fireforge 0.18.3 → 0.18.6

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.
package/README.md CHANGED
@@ -358,11 +358,11 @@ fireforge furnace chrome-doc create mybrowser --with-tests # + xpcshell packagin
358
358
 
359
359
  The command writes:
360
360
 
361
- - `engine/browser/base/content/<name>.xhtml` — XHTML shell, optional titlebar-buttonbox, Fluent `<link>`.
361
+ - `engine/browser/base/content/<name>.xhtml` — XHTML shell. `data-l10n-id` is bound on the leaf `<title>` only (binding it on the root `<window>` would let Fluent's first-paint translation pass overwrite the entire body subtree, the standard `data-l10n-id`-on-non-leaf failure mode). The `<head>` loads `chrome://global/content/customElements.js` ahead of the per-doc subscript so any `<moz-*>` widget the author drops into the body resolves through the toolkit registry instead of silently degrading to `HTMLUnknownElement` — matches the `webrtcIndicator.xhtml` shape upstream uses for non-`browser.xhtml` chrome documents. Under `--with-titlebar` (the default) the root carries the `navigator:browser` minimum attribute set: `windowtype="navigator:browser"`, `customtitlebar="true"`, default `width="1024"` / `height="640"`, and `persist="screenX screenY width height sizemode"` so XULStore remembers geometry across restarts; without these a fork shipping the scaffold verbatim opens at the OS intrinsic minimum size on first launch and forgets the user's window position.
362
362
  - `engine/browser/base/content/<name>.js` — startup-topic observer fired on first idle.
363
- - `engine/browser/themes/shared/<name>-chrome.css` — scoped CSS; emits the macOS `.titlebar-button { display: none }` carve-out under `--no-titlebar`.
363
+ - `engine/browser/themes/shared/<name>-chrome.css` — scoped CSS. Under `--with-titlebar` the buttonbox container is a `-moz-window-dragging: drag` region and `.titlebar-buttonbox` opts into the platform-native `-moz-window-button-box` appearance so the OS renders traffic-light / minimize-maximize-close controls in their canonical positions; under `--no-titlebar` the macOS `.titlebar-button { display: none }` carve-out is emitted instead so frameless overlays don't inherit the platform window controls `global.css` applies by default.
364
364
  - `engine/browser/locales/en-US/browser/<name>.ftl` — Fluent stub keyed on `<name>-window-title`.
365
- - Appends the corresponding `jar.mn` / `jar.inc.mn` / `locales/jar.mn` entries.
365
+ - Appends the corresponding `jar.mn` / `jar.inc.mn` entries. The locales/jar.mn append is suppressed when the fork's existing `engine/browser/locales/jar.mn` already carries a `[localization] (%browser/**/*.ftl)` (or `(%browser/*.ftl)`) wildcard that would already pick up the scaffolded FTL — on those forks a per-file `locale/<name>.ftl` entry would be dead weight at best and an outright build break when the fork has dropped the `% locale browser …` registration the per-file entry depends on. Forks still on the legacy registration get the per-file entry as before.
366
366
  - When `--with-tests` is set, also scaffolds an xpcshell test + `xpcshell.toml` under `engine/browser/base/content/test/<binary>-xpcshell/<name>/` that probes the packaged app directory (`Services.dirsvc.get("XCurProcD")/chrome/browser/...`) directly rather than going through `chrome://` URI resolution — see "Platform module compatibility" and the xpcshell chrome-URI note further down for why direct filesystem probing is the reliable way to verify chrome-doc packaging. Registration in `XPCSHELL_TESTS_MANIFESTS` is left to the operator because the owning moz.build depends on the fork layout.
367
367
 
368
368
  Writes are transactional: a SIGINT mid-scaffold rolls back every touched file. Requires an existing engine — run `fireforge download` first.
@@ -429,6 +429,8 @@ fireforge config customKey "value" --force
429
429
 
430
430
  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.
431
431
 
432
+ Re-setting a key to its current value is a no-op: `fireforge.json` is not rewritten, key ordering is preserved, and the success log surfaces `<key> = <value> (unchanged)` instead of a fresh `Set …` line. This means automation that idempotently runs `fireforge config <key> <value>` no longer produces spurious diffs in `fireforge.json`.
433
+
432
434
  ### Patch queue management
433
435
 
434
436
  ```bash
@@ -476,7 +478,7 @@ fireforge token add --category 'Colors — General' --mode static -- --my-color
476
478
  fireforge token add --category 'Colors — General' --mode static my-color '#fff' # bare-name form
477
479
  ```
478
480
 
479
- 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.
481
+ 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` does three more things in the same step so the file is owned end-to-end by tooling: it registers the tokens CSS path in `patchLint.rawColorAllowlist` (so raw color literals inside it are not flagged by `fireforge lint`); it adds the matching `skin/classic/browser/<binaryName>-tokens.css (../shared/<binaryName>-tokens.css)` entry to `browser/themes/shared/jar.inc.mn` (so `fireforge status` does not flag the file as unmanaged or unregistered); and it 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.
480
482
 
481
483
  ### Diff-scoped lint (`lint --since`)
482
484
 
@@ -518,6 +520,10 @@ The build also auto-runs `mach configure` before the mach build step when any `m
518
520
 
519
521
  Mach build failures with known-cryptic mozbuild errors now print actionable hints. Example: a `JS_PREFERENCE_PP_FILES` entry with no `#filter` / `#expand` directives now prints `Hint: ...use JS_PREFERENCE_FILES instead, or add at least one #filter / #expand directive to the file.` alongside the raw mach traceback.
520
522
 
523
+ When mach prints a post-build `config.status is out of date …` or `Config object not found by mach. / Configure complete!` banner on a successful build, FireForge surfaces a one-line annotation immediately before its own `Build completed in Xm Ys!` outro explaining that the banner is a known side effect of tool-managed branding edits applied before the build and that the build does not need to be re-run. The FireForge exit code remains authoritative regardless of the mach guard text.
524
+
525
+ The reported `Build completed in Xm Ys!` duration is wall-clock measured with `Date.now()`, so it includes any time the host spent suspended (laptop sleep, system idle) during the build. Treat it as wall-clock-with-sleep, not active CPU time, when comparing builds across machines or sessions.
526
+
521
527
  ### Relocated workspaces: `fireforge build --rewrite-mozinfo`
522
528
 
523
529
  When a workspace is moved to a new path (e.g. the project directory was renamed or relocated on disk), `obj-*/mozinfo.json` still records the old `topsrcdir` / `topobjdir`. The pre-flight detects the mismatch and aborts with a "delete and rebuild" instruction — correct but expensive; a fresh clean build typically runs ~20 minutes and discards ~14 GB of intact obj artefacts on a moved checkout.
@@ -157,18 +157,33 @@ export async function buildCommand(projectRoot, options) {
157
157
  throw new BuildError(`Build failed with exit code ${result.exitCode}`, options.ui ? 'mach build faster' : 'mach build');
158
158
  }
159
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.');
160
+ // before the build cause mach's post-build guard to print one of two
161
+ // banners that read like build failures even though the build
162
+ // completed cleanly:
163
+ //
164
+ // 1) "config.status is out of date with respect to ..."
165
+ // 2) "Config object not found by mach. / Configure complete! /
166
+ // Be sure to run |mach build| to pick up any changes."
167
+ //
168
+ // 2026-04-21 eval covered (1); 2026-04-26 eval Finding 8 reproduced
169
+ // (2) on a successful build. The pre-fix pattern only matched (1),
170
+ // so operators on the (2) path saw mach's own "Configure complete!"
171
+ // and "run |mach build|" lines unexplained between mach's
172
+ // "Your build was successful!" and FireForge's own "Build completed
173
+ // in Xm Ys" outro — a contradictory tail. Both shapes now route
174
+ // through the same annotation, emitted BEFORE FireForge's outro so
175
+ // the operator's last terminal line is the explanation, not the
176
+ // confusing mach guard text.
177
+ const staleConfigurePatterns = [
178
+ /config\.status is out of date/i,
179
+ /Config object not found by mach\.[\s\S]*Configure complete!/i,
180
+ ];
181
+ const captured = `${result.stdout}\n${result.stderr}`;
182
+ if (staleConfigurePatterns.some((p) => p.test(captured))) {
183
+ info('Note: mach printed a post-build "Configure complete!" / "config.status is out of date" ' +
184
+ 'banner. That is a known side effect of tool-managed branding edits applied before the ' +
185
+ 'build and does not mean the build is stale or that you need to rerun mach — the FireForge ' +
186
+ 'exit code is authoritative.');
172
187
  }
173
188
  // Warn-only post-build audit: surfaces silent packaging drops (files
174
189
  // edited in engine/ but never registered for packaging) against the
@@ -66,10 +66,15 @@ function formatValue(value) {
66
66
  if (value === undefined) {
67
67
  return '(not set)';
68
68
  }
69
- if (value === null || typeof value === 'object' || typeof value === 'function') {
70
- return JSON.stringify(value, null, 2);
69
+ if (typeof value === 'string')
70
+ return value;
71
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
72
+ return String(value);
73
+ }
74
+ if (typeof value === 'symbol') {
75
+ return value.toString();
71
76
  }
72
- return String(value);
77
+ return JSON.stringify(value, null, 2);
73
78
  }
74
79
  /**
75
80
  * Runs the config command to get or set configuration values.
@@ -111,6 +116,7 @@ export async function configCommand(projectRoot, key, value, options = {}) {
111
116
  }
112
117
  const parsedValue = parseValue(value, key);
113
118
  const keyIsKnown = SUPPORTED_CONFIG_PATHS.includes(key);
119
+ let unchanged;
114
120
  try {
115
121
  // Serialise the read → mutate → write round-trip behind the sidecar
116
122
  // config lock so two concurrent `fireforge config` invocations can't
@@ -122,7 +128,21 @@ export async function configCommand(projectRoot, key, value, options = {}) {
122
128
  // were never enough on their own — the lost update happens before
123
129
  // the rename, inside the read-modify step. Readers stay lock-free
124
130
  // (see `withConfigFileLock` docstring).
125
- await withConfigFileLock(projectRoot, async () => {
131
+ unchanged = await withConfigFileLock(projectRoot, async () => {
132
+ // 2026-04-26 eval Finding 11: short-circuit when the new value
133
+ // matches the current on-disk value. Pre-fix, every set ran
134
+ // through `mutateConfig` + `writeConfig`, which round-trips
135
+ // through `JSON.stringify` and rewrites the file even when no
136
+ // semantic change happened — the rewrite reorders top-level
137
+ // keys (`license`, `markerComment`, etc.) on every harmless
138
+ // re-set, producing diff churn for no reason. The check uses
139
+ // the raw on-disk document so forced-keys round-trip the same
140
+ // as known keys.
141
+ const rawConfig = await loadRawConfigDocument(projectRoot);
142
+ const currentValue = getNestedValue(rawConfig, key);
143
+ if (deepEqual(currentValue, parsedValue)) {
144
+ return true;
145
+ }
126
146
  // `--force` is intended as an escape hatch for *unknown* keys; it
127
147
  // should not also let the user write a structurally invalid value
128
148
  // for a *known* key. Apply strict validation whenever the key is
@@ -133,7 +153,6 @@ export async function configCommand(projectRoot, key, value, options = {}) {
133
153
  // keys (which `validateConfig` would strip) survive the round-trip.
134
154
  // Without this, writing a second --force key would silently drop
135
155
  // every earlier forced key from fireforge.json.
136
- const rawConfig = await loadRawConfigDocument(projectRoot);
137
156
  const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
138
157
  await writeConfigDocument(projectRoot, updatedConfig);
139
158
  }
@@ -142,15 +161,54 @@ export async function configCommand(projectRoot, key, value, options = {}) {
142
161
  const updatedConfig = mutateConfig(config, key, parsedValue);
143
162
  await writeConfig(projectRoot, updatedConfig);
144
163
  }
164
+ return false;
145
165
  });
146
166
  }
147
167
  catch (error) {
148
168
  throw new InvalidArgumentError(`Invalid value for "${key}": ${toError(error).message}`, key);
149
169
  }
150
- success(`Set ${key} = ${formatValue(parsedValue)}`);
170
+ if (unchanged) {
171
+ info(`${key} = ${formatValue(parsedValue)} (unchanged)`);
172
+ }
173
+ else {
174
+ success(`Set ${key} = ${formatValue(parsedValue)}`);
175
+ }
151
176
  }
152
177
  outro('');
153
178
  }
179
+ /**
180
+ * Structural equality check covering the shapes that
181
+ * `fireforge config` accepts: primitives (strings, numbers, booleans),
182
+ * `null`, arrays of primitives, and nested objects. Used to short-circuit
183
+ * no-op writes (Finding 11) — when the parsed value matches the current
184
+ * on-disk value, skip the mutate + write step entirely.
185
+ */
186
+ function deepEqual(a, b) {
187
+ if (a === b)
188
+ return true;
189
+ if (a === null || b === null)
190
+ return a === b;
191
+ if (typeof a !== typeof b)
192
+ return false;
193
+ if (typeof a !== 'object')
194
+ return false;
195
+ if (Array.isArray(a)) {
196
+ if (!Array.isArray(b))
197
+ return false;
198
+ if (a.length !== b.length)
199
+ return false;
200
+ return a.every((v, i) => deepEqual(v, b[i]));
201
+ }
202
+ if (Array.isArray(b))
203
+ return false;
204
+ const ar = a;
205
+ const br = b;
206
+ const keysA = Object.keys(ar);
207
+ const keysB = Object.keys(br);
208
+ if (keysA.length !== keysB.length)
209
+ return false;
210
+ return keysA.every((k) => deepEqual(ar[k], br[k]));
211
+ }
154
212
  /** Registers the config command on the CLI program. */
155
213
  export function registerConfig(program, { getProjectRoot, withErrorHandling }) {
156
214
  program
@@ -24,12 +24,29 @@ export declare const FURNACE_CHROME_DOC_SENTINEL = "data-furnace-chrome-doc";
24
24
  * XHTML shell for a top-level chrome document.
25
25
  *
26
26
  * The emitted document:
27
- * - Declares `windowtype="navigator:browser"` when `withTitlebar` is true
28
- * so chrome-wide stylesheets that target the browser window still apply.
29
- * - Emits a titlebar-buttonbox placeholder when `withTitlebar` is true so
30
- * platform-native window controls render.
27
+ * - When `withTitlebar` is true, declares the `navigator:browser` minimum
28
+ * set: `windowtype`, `customtitlebar`, default `width`/`height`, and a
29
+ * `persist` allowlist for screen position + size + sizemode. Without
30
+ * these, a fork-owned chrome doc that ships as the main window opens
31
+ * at the OS intrinsic minimum size on first launch and forgets the
32
+ * user's last-known geometry across restarts. The titlebar-buttonbox
33
+ * placeholder is emitted alongside so platform-native window controls
34
+ * render with the matching CSS rules from `generateChromeDocCss`.
35
+ * - Loads `chrome://global/content/customElements.js` in `<head>` ahead
36
+ * of the per-doc subscript. Without it, every `<moz-*>` widget the
37
+ * author drops into the body silently degrades to `HTMLUnknownElement`
38
+ * and the upstream a11y/keyboard semantics that motivated the use of
39
+ * the toolkit widget in the first place are lost. Matches the
40
+ * `webrtcIndicator.xhtml` shape upstream uses for non-`browser.xhtml`
41
+ * chrome documents.
31
42
  * - Links the per-document CSS at `chrome://browser/content/<name>-chrome.css`
32
43
  * and the Fluent bundle `browser/<name>.ftl`.
44
+ * - Keeps `data-l10n-id` on the leaf `<title>` only. Binding the same key
45
+ * on the root `<window>` would cause Fluent's first-paint translation
46
+ * pass to overwrite the entire body subtree with the message's text
47
+ * value (the standard `data-l10n-id`-on-non-leaf failure mode), since
48
+ * the FTL stub gives `<name>-window-title` a value rather than an
49
+ * attribute-only message.
33
50
  * - Carries the `data-furnace-chrome-doc="<name>"` sentinel so fork-side
34
51
  * patches to upstream platform modules (DevToolsStartup, PageActions, …)
35
52
  * that assume `browser.xhtml`'s DOM can guard against it cheaply. See
@@ -46,10 +63,21 @@ export declare function generateChromeDocXhtml(name: string, withTitlebar: boole
46
63
  */
47
64
  export declare function generateChromeDocJs(name: string, licenseHeader: string): string;
48
65
  /**
49
- * Scoped CSS for a chrome document. When `withTitlebar` is false the
50
- * macOS `.titlebar-button { display: none }` carve-out is emitted so
51
- * frameless overlay-style documents don't inherit the platform window
52
- * controls that `global.css` applies by default.
66
+ * Scoped CSS for a chrome document.
67
+ *
68
+ * When `withTitlebar` is true, the matching navigator:browser minimum
69
+ * CSS is emitted alongside the layout rules: the buttonbox container is
70
+ * a draggable region (`-moz-window-dragging: drag`) so the user can drag
71
+ * the window from the title bar, and the buttonbox itself opts into the
72
+ * platform-native window-button-box appearance so the OS renders the
73
+ * traffic-light / minimize-maximize-close controls in their canonical
74
+ * positions. Without these rules the buttonbox markup still draws but
75
+ * is unstyled and non-draggable, which is the failure mode a fork that
76
+ * ships the scaffold verbatim hits on first launch.
77
+ *
78
+ * When `withTitlebar` is false the macOS `.titlebar-button { display: none }`
79
+ * carve-out is emitted so frameless overlay-style documents don't inherit
80
+ * the platform window controls that `global.css` applies by default.
53
81
  */
54
82
  export declare function generateChromeDocCss(name: string, withTitlebar: boolean, licenseHeader: string): string;
55
83
  /** Fluent stub — one placeholder message keyed to the window title. */
@@ -92,3 +120,26 @@ export declare function jarIncMnEntryForChromeDoc(name: string): string;
92
120
  * "jar.mn: Cannot find ${name}.ftl".
93
121
  */
94
122
  export declare function localeJarMnEntryForChromeDoc(name: string): string;
123
+ /**
124
+ * Returns true when `jarMnContents` already carries a `[localization]`-style
125
+ * wildcard rooted at `%browser/` whose pattern would already pick up a
126
+ * scaffolded `browser/<name>.ftl` file. Recognises:
127
+ *
128
+ * - `(%browser/**\/*.ftl)` — recursive (the upstream shape).
129
+ * - `(%browser/*.ftl)` — flat.
130
+ *
131
+ * Forks that have migrated entirely to `[localization]` wildcards typically
132
+ * keep no per-file `locale/...` entries for FTL at all; appending one
133
+ * there is dead weight at best, and an outright build break when the fork
134
+ * has also dropped the `% locale browser …` registration. The chrome-doc
135
+ * scaffolder consults this predicate before its locales/jar.mn append and
136
+ * skips the per-file write when the wildcard already covers the scaffold's
137
+ * target path.
138
+ *
139
+ * Conservative by design: only wildcards rooted at `%browser/` count, and
140
+ * a `(%browser/foo.ftl)`-style explicit reference (no `*`) is not treated
141
+ * as a capture. A fork with a narrower wildcard (e.g. `(%browser/about/*.ftl)`)
142
+ * is correctly NOT captured by this predicate, because that wildcard would
143
+ * not pick up the top-level `browser/<name>.ftl` the scaffold writes.
144
+ */
145
+ export declare function localesFtlWildcardCapturesScaffoldedName(jarMnContents: string): boolean;
@@ -25,19 +25,49 @@ export const FURNACE_CHROME_DOC_SENTINEL = 'data-furnace-chrome-doc';
25
25
  * XHTML shell for a top-level chrome document.
26
26
  *
27
27
  * The emitted document:
28
- * - Declares `windowtype="navigator:browser"` when `withTitlebar` is true
29
- * so chrome-wide stylesheets that target the browser window still apply.
30
- * - Emits a titlebar-buttonbox placeholder when `withTitlebar` is true so
31
- * platform-native window controls render.
28
+ * - When `withTitlebar` is true, declares the `navigator:browser` minimum
29
+ * set: `windowtype`, `customtitlebar`, default `width`/`height`, and a
30
+ * `persist` allowlist for screen position + size + sizemode. Without
31
+ * these, a fork-owned chrome doc that ships as the main window opens
32
+ * at the OS intrinsic minimum size on first launch and forgets the
33
+ * user's last-known geometry across restarts. The titlebar-buttonbox
34
+ * placeholder is emitted alongside so platform-native window controls
35
+ * render with the matching CSS rules from `generateChromeDocCss`.
36
+ * - Loads `chrome://global/content/customElements.js` in `<head>` ahead
37
+ * of the per-doc subscript. Without it, every `<moz-*>` widget the
38
+ * author drops into the body silently degrades to `HTMLUnknownElement`
39
+ * and the upstream a11y/keyboard semantics that motivated the use of
40
+ * the toolkit widget in the first place are lost. Matches the
41
+ * `webrtcIndicator.xhtml` shape upstream uses for non-`browser.xhtml`
42
+ * chrome documents.
32
43
  * - Links the per-document CSS at `chrome://browser/content/<name>-chrome.css`
33
44
  * and the Fluent bundle `browser/<name>.ftl`.
45
+ * - Keeps `data-l10n-id` on the leaf `<title>` only. Binding the same key
46
+ * on the root `<window>` would cause Fluent's first-paint translation
47
+ * pass to overwrite the entire body subtree with the message's text
48
+ * value (the standard `data-l10n-id`-on-non-leaf failure mode), since
49
+ * the FTL stub gives `<name>-window-title` a value rather than an
50
+ * attribute-only message.
34
51
  * - Carries the `data-furnace-chrome-doc="<name>"` sentinel so fork-side
35
52
  * patches to upstream platform modules (DevToolsStartup, PageActions, …)
36
53
  * that assume `browser.xhtml`'s DOM can guard against it cheaply. See
37
54
  * the README "Platform module compatibility" section for the pattern.
38
55
  */
39
56
  export function generateChromeDocXhtml(name, withTitlebar, license) {
40
- const windowAttr = withTitlebar ? ' windowtype="navigator:browser"' : '';
57
+ // navigator:browser minimum set. Carrying every attribute together
58
+ // not just `windowtype` — lets a fork that uses the scaffold output
59
+ // verbatim launch as a real main window: `customtitlebar` opts into the
60
+ // platform-native title bar handling that pairs with the buttonbox
61
+ // markup below, the explicit width/height avoid the OS-minimum first
62
+ // launch, and `persist` lets the platform remember geometry across
63
+ // restarts via XULStore.
64
+ const navigatorBrowserAttrs = withTitlebar
65
+ ? ` windowtype="navigator:browser"
66
+ customtitlebar="true"
67
+ width="1024"
68
+ height="640"
69
+ persist="screenX screenY width height sizemode"`
70
+ : '';
41
71
  const titlebarMarkup = withTitlebar
42
72
  ? `
43
73
  <hbox class="titlebar-buttonbox-container">
@@ -50,9 +80,8 @@ export function generateChromeDocXhtml(name, withTitlebar, license) {
50
80
  <window
51
81
  xmlns="http://www.w3.org/1999/xhtml"
52
82
  xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
53
- id="${name}-window"${windowAttr}
83
+ id="${name}-window"${navigatorBrowserAttrs}
54
84
  ${FURNACE_CHROME_DOC_SENTINEL}="${name}"
55
- data-l10n-id="${name}-window-title"
56
85
  role="application">
57
86
  <head>
58
87
  <meta charset="utf-8" />
@@ -60,6 +89,7 @@ export function generateChromeDocXhtml(name, withTitlebar, license) {
60
89
  <link rel="localization" href="browser/${name}.ftl" />
61
90
  <link rel="stylesheet" href="chrome://global/skin/global.css" />
62
91
  <link rel="stylesheet" href="chrome://browser/content/${name}-chrome.css" />
92
+ <script src="chrome://global/content/customElements.js"></script>
63
93
  <script src="chrome://browser/content/${name}.js"></script>
64
94
  </head>
65
95
  <body>${titlebarMarkup}
@@ -105,14 +135,42 @@ window.addEventListener(
105
135
  `;
106
136
  }
107
137
  /**
108
- * Scoped CSS for a chrome document. When `withTitlebar` is false the
109
- * macOS `.titlebar-button { display: none }` carve-out is emitted so
110
- * frameless overlay-style documents don't inherit the platform window
111
- * controls that `global.css` applies by default.
138
+ * Scoped CSS for a chrome document.
139
+ *
140
+ * When `withTitlebar` is true, the matching navigator:browser minimum
141
+ * CSS is emitted alongside the layout rules: the buttonbox container is
142
+ * a draggable region (`-moz-window-dragging: drag`) so the user can drag
143
+ * the window from the title bar, and the buttonbox itself opts into the
144
+ * platform-native window-button-box appearance so the OS renders the
145
+ * traffic-light / minimize-maximize-close controls in their canonical
146
+ * positions. Without these rules the buttonbox markup still draws but
147
+ * is unstyled and non-draggable, which is the failure mode a fork that
148
+ * ships the scaffold verbatim hits on first launch.
149
+ *
150
+ * When `withTitlebar` is false the macOS `.titlebar-button { display: none }`
151
+ * carve-out is emitted so frameless overlay-style documents don't inherit
152
+ * the platform window controls that `global.css` applies by default.
112
153
  */
113
154
  export function generateChromeDocCss(name, withTitlebar, licenseHeader) {
114
155
  const titlebarOverrides = withTitlebar
115
- ? ''
156
+ ? `
157
+
158
+ /* navigator:browser minimum titlebar styling. Pairs with the
159
+ \`customtitlebar="true"\` + \`titlebar-buttonbox\` markup the XHTML
160
+ template emits when --with-titlebar is set. The container is the drag
161
+ region; the inner buttonbox opts into the platform-native traffic
162
+ light / minimize-maximize-close appearance via \`-moz-window-button-box\`. */
163
+ .titlebar-buttonbox-container {
164
+ -moz-window-dragging: drag;
165
+ display: flex;
166
+ align-items: center;
167
+ }
168
+
169
+ .titlebar-buttonbox {
170
+ appearance: auto;
171
+ -moz-default-appearance: -moz-window-button-box;
172
+ }
173
+ `
116
174
  : `
117
175
 
118
176
  /* Frameless overlay — suppress the platform titlebar buttons that
@@ -194,4 +252,29 @@ export function jarIncMnEntryForChromeDoc(name) {
194
252
  export function localeJarMnEntryForChromeDoc(name) {
195
253
  return ` locale/browser/${name}.ftl (%browser/${name}.ftl)`;
196
254
  }
255
+ /**
256
+ * Returns true when `jarMnContents` already carries a `[localization]`-style
257
+ * wildcard rooted at `%browser/` whose pattern would already pick up a
258
+ * scaffolded `browser/<name>.ftl` file. Recognises:
259
+ *
260
+ * - `(%browser/**\/*.ftl)` — recursive (the upstream shape).
261
+ * - `(%browser/*.ftl)` — flat.
262
+ *
263
+ * Forks that have migrated entirely to `[localization]` wildcards typically
264
+ * keep no per-file `locale/...` entries for FTL at all; appending one
265
+ * there is dead weight at best, and an outright build break when the fork
266
+ * has also dropped the `% locale browser …` registration. The chrome-doc
267
+ * scaffolder consults this predicate before its locales/jar.mn append and
268
+ * skips the per-file write when the wildcard already covers the scaffold's
269
+ * target path.
270
+ *
271
+ * Conservative by design: only wildcards rooted at `%browser/` count, and
272
+ * a `(%browser/foo.ftl)`-style explicit reference (no `*`) is not treated
273
+ * as a capture. A fork with a narrower wildcard (e.g. `(%browser/about/*.ftl)`)
274
+ * is correctly NOT captured by this predicate, because that wildcard would
275
+ * not pick up the top-level `browser/<name>.ftl` the scaffold writes.
276
+ */
277
+ export function localesFtlWildcardCapturesScaffoldedName(jarMnContents) {
278
+ return /\(%browser\/(?:\*\*\/)?\*\.ftl\)/.test(jarMnContents);
279
+ }
197
280
  //# sourceMappingURL=chrome-doc-templates.js.map
@@ -25,7 +25,7 @@ import { InvalidArgumentError } from '../../errors/base.js';
25
25
  import { FurnaceError } from '../../errors/furnace.js';
26
26
  import { pathExists, readText, writeText } from '../../utils/fs.js';
27
27
  import { intro, note, outro } from '../../utils/logger.js';
28
- import { generateChromeDocCss, generateChromeDocFtl, generateChromeDocJs, generateChromeDocXhtml, jarIncMnEntryForChromeDoc, jarMnEntriesForChromeDoc, localeJarMnEntryForChromeDoc, } from './chrome-doc-templates.js';
28
+ import { generateChromeDocCss, generateChromeDocFtl, generateChromeDocJs, generateChromeDocXhtml, jarIncMnEntryForChromeDoc, jarMnEntriesForChromeDoc, localeJarMnEntryForChromeDoc, localesFtlWildcardCapturesScaffoldedName, } from './chrome-doc-templates.js';
29
29
  import { chromeDocPackagingTestFileName, generateChromeDocPackagingManifest, generateChromeDocPackagingTest, } from './chrome-doc-tests.js';
30
30
  /** Chrome-doc name shape: lowercase ASCII, optional hyphens, no leading digit. */
31
31
  const CHROME_DOC_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -122,7 +122,29 @@ async function performChromeDocMutations(args) {
122
122
  await appendJarEntryIfAbsent(jarIncMnPath, jarIncMnEntryForChromeDoc(args.name), journal);
123
123
  written.push('browser/themes/shared/jar.inc.mn');
124
124
  const localeJarMnPath = join(args.engineDir, 'browser/locales/jar.mn');
125
- await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
125
+ // Forks that have migrated to a `[localization] (%browser/**/*.ftl)`
126
+ // wildcard already pick up the scaffolded FTL automatically — appending
127
+ // a per-file `locale/...` entry on top is at best dead weight and at
128
+ // worst a build error when the fork has dropped the `% locale browser`
129
+ // registration the per-file entry depends on. The wildcard predicate
130
+ // is intentionally narrow: only `%browser/`-rooted globs that end in
131
+ // `*.ftl` count as a capture.
132
+ if (await pathExists(localeJarMnPath)) {
133
+ const existingLocaleJar = await readText(localeJarMnPath);
134
+ if (localesFtlWildcardCapturesScaffoldedName(existingLocaleJar)) {
135
+ note(`Locale jar.mn already carries a [localization] wildcard that captures browser/${args.name}.ftl — skipping the per-file entry.`, args.name);
136
+ }
137
+ else {
138
+ await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
139
+ }
140
+ }
141
+ else {
142
+ // Preserve the existing "missing locale jar.mn" failure mode: pretend
143
+ // we still want to append so appendJarEntryIfAbsent surfaces the same
144
+ // FurnaceError it does for the other two jars. Forks that move the
145
+ // file deserve the same explicit complaint everywhere.
146
+ await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
147
+ }
126
148
  written.push('browser/locales/jar.mn');
127
149
  // --with-tests scaffolds an xpcshell packaging verification. All writes
128
150
  // go through the same rollback journal so a SIGINT here restores the
@@ -5,6 +5,7 @@ import { applyAllComponents, applyCustomComponent, applyOverrideComponent, compu
5
5
  import { logApplyResult } from '../../core/furnace-apply-output.js';
6
6
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, updateFurnaceState, } from '../../core/furnace-config.js';
7
7
  import { resolveFtlDir } from '../../core/furnace-constants.js';
8
+ import { resolveFurnaceMarkerComment } from '../../core/furnace-marker.js';
8
9
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
9
10
  import { createRollbackJournal, restoreRollbackJournalOrThrow, } from '../../core/furnace-rollback.js';
10
11
  import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
@@ -305,6 +306,14 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
305
306
  // the plan before deciding whether to refresh the override or acknowledge
306
307
  // the new baseline in furnace.json.
307
308
  const forgeConfig = await loadConfig(projectRoot);
309
+ // 2026-04-26 eval Finding 6: when `markerComment` is unset in
310
+ // fireforge.json, default it to `binaryName.toUpperCase()` so the
311
+ // furnace-emitted edits to upstream files satisfy
312
+ // `lintModificationComments` on the next `lint`/`export` round-trip.
313
+ // The lint rule keys on the same uppercased binaryName, so the
314
+ // implicit default is identical to what the rule expects. Threaded
315
+ // through `applyNamedComponent` below.
316
+ const resolvedMarkerComment = resolveFurnaceMarkerComment(forgeConfig);
308
317
  const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
309
318
  const force = options.force ?? false;
310
319
  const scopedDrift = name ? driftEntries.filter((entry) => entry.name === name) : driftEntries;
@@ -317,7 +326,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
317
326
  // `furnace deploy` runs only contend on the actual mutation.
318
327
  const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
319
328
  if (name) {
320
- const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, forgeConfig.markerComment);
329
+ const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, resolvedMarkerComment);
321
330
  if (namedApplyResult === 'stock') {
322
331
  return { kind: 'stock' };
323
332
  }
@@ -5,6 +5,7 @@ import { text } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
6
6
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
7
7
  import { DEFAULT_LICENSE } from '../../core/license-headers.js';
8
+ import { registerSharedCSS } from '../../core/register-shared-css.js';
8
9
  import { getTokensCssPath } from '../../core/token-manager.js';
9
10
  import { generateDefaultTokensCss } from '../../core/token-scaffold.js';
10
11
  import { FurnaceError } from '../../errors/furnace.js';
@@ -184,9 +185,11 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
184
185
  }
185
186
  note(lines.join('\n'), 'Configuration');
186
187
  info('Next steps:\n' +
187
- ' fireforge furnace scan — discover engine components\n' +
188
+ ' fireforge furnace scan — discover engine components\n' +
188
189
  ' fireforge furnace create — create a new custom component\n' +
189
- ' fireforge furnace override — fork an existing component');
190
+ ' fireforge furnace override — fork an existing component\n' +
191
+ ' fireforge token add — define a token in the scaffolded tokens CSS\n' +
192
+ ' fireforge export <tokens.css> — capture the tokens CSS + its registration in a patch');
190
193
  outro('Init complete');
191
194
  }
192
195
  /**
@@ -251,6 +254,29 @@ async function scaffoldTokensCss(projectRoot) {
251
254
  warn(`Could not register tokens CSS in patchLint.rawColorAllowlist: ${toError(error).message}. ` +
252
255
  `Add "${tokensCssPath}" manually under patchLint.rawColorAllowlist in fireforge.json if lint flags its contents.`);
253
256
  }
257
+ // 2026-04-26 eval Finding 2: register the tokens CSS in
258
+ // browser/themes/shared/jar.inc.mn so the file is owned end-to-end by
259
+ // tooling. Pre-fix, `furnace init` only scaffolded + allowlisted the
260
+ // file, so the very next `fireforge status` correctly flagged it as
261
+ // unmanaged + unregistered and `furnace deploy --dry-run` reported
262
+ // nothing to deploy — a documented init command turned a clean
263
+ // project into an unclean one. The CSS lives at the canonical
264
+ // `browser/themes/shared/<binaryName>-tokens.css` path that the
265
+ // shared-CSS rule already targets, so the tokens file gets the same
266
+ // `skin/classic/browser/<name>.css (../shared/<name>.css)` jar.inc.mn
267
+ // entry as any other shared CSS. Idempotent — running
268
+ // `furnace init --force` against a registered tree is a no-op.
269
+ try {
270
+ const fileBase = `${forgeConfig.binaryName}-tokens.css`;
271
+ const result = await registerSharedCSS(paths.engine, fileBase, undefined, false);
272
+ if (!result.skipped) {
273
+ info(`Registered ${fileBase} in browser/themes/shared/jar.inc.mn`);
274
+ }
275
+ }
276
+ catch (error) {
277
+ warn(`Could not register tokens CSS in browser/themes/shared/jar.inc.mn: ${toError(error).message}. ` +
278
+ `Run "fireforge register browser/themes/shared/${forgeConfig.binaryName}-tokens.css" once jar.inc.mn is reachable.`);
279
+ }
254
280
  return { tokensCssPath };
255
281
  }
256
282
  //# sourceMappingURL=init.js.map
@@ -348,17 +348,22 @@ async function lintPerPatch(projectRoot, paths) {
348
348
  const ctx = await buildPatchQueueContext(paths.patches);
349
349
  const issues = [];
350
350
  let linted = 0;
351
+ let skipped = 0;
351
352
  for (const patch of manifest.patches) {
352
353
  const existing = [];
353
354
  for (const f of patch.filesAffected) {
354
355
  if (await pathExists(join(paths.engine, f)))
355
356
  existing.push(f);
356
357
  }
357
- if (existing.length === 0)
358
+ if (existing.length === 0) {
359
+ skipped++;
358
360
  continue;
361
+ }
359
362
  const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
360
- if (!diff.trim())
363
+ if (!diff.trim()) {
364
+ skipped++;
361
365
  continue;
366
+ }
362
367
  const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
363
368
  const decision = resolvePatchSizeTier(existing, patch.tier);
364
369
  if (decision.tier === 'branding') {
@@ -377,7 +382,21 @@ async function lintPerPatch(projectRoot, paths) {
377
382
  // context.
378
383
  issues.push(...lintPatchQueue(ctx));
379
384
  if (issues.length === 0) {
380
- success(`No lint issues found across ${linted} patch(es).`);
385
+ // 2026-04-26 eval Finding 7: pre-fix the success line read
386
+ // `No lint issues found across 0 patch(es).` whenever the queue
387
+ // had not been applied to the engine — every patch's
388
+ // `filesAffected` filtered out, so `existing` was empty and the
389
+ // patch was silently skipped. Operators read that as "the queue
390
+ // is clean" when in reality nothing was checked. Surface the
391
+ // skipped count and, when nothing was linted at all, point at
392
+ // `fireforge import` as the missing prerequisite.
393
+ if (linted === 0 && skipped > 0) {
394
+ info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
395
+ }
396
+ const summary = skipped > 0
397
+ ? `No lint issues found across ${linted} patch(es) (${skipped} skipped — files not present in engine/).`
398
+ : `No lint issues found across ${linted} patch(es).`;
399
+ success(summary);
381
400
  outro('Lint passed');
382
401
  return;
383
402
  }
@@ -10,6 +10,7 @@
10
10
  import { basename } from 'node:path';
11
11
  import { getProjectPaths } from '../../core/config.js';
12
12
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
13
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
13
14
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
14
15
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
15
16
  import { loadPatchesManifest, removePatchFileAndManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
@@ -39,10 +40,7 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
39
40
  }
40
41
  const target = resolvePatchIdentifier(identifier, manifest.patches);
41
42
  if (!target) {
42
- const available = manifest.patches
43
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
44
- .join(', ');
45
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
43
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
46
44
  }
47
45
  // Build the full queue context once so we can scan each patch's newFiles
48
46
  // without re-parsing for the dependency check below.
@@ -22,6 +22,7 @@
22
22
  import { getProjectPaths } from '../../core/config.js';
23
23
  import { appendHistory } from '../../core/destructive.js';
24
24
  import { mutatePatchMetadata } from '../../core/patch-export.js';
25
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
26
  import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
26
27
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
27
28
  import { toError } from '../../utils/errors.js';
@@ -113,10 +114,7 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
113
114
  }
114
115
  const target = resolvePatchIdentifier(identifier, manifest.patches);
115
116
  if (!target) {
116
- const available = manifest.patches
117
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
118
- .join(', ');
119
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
117
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
120
118
  }
121
119
  if (isDryRun) {
122
120
  const existing = target.lintIgnore ?? [];
@@ -11,6 +11,7 @@
11
11
  import { Option } from 'commander';
12
12
  import { getProjectPaths } from '../../core/config.js';
13
13
  import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
14
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
14
15
  import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
15
16
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
16
17
  import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
@@ -270,10 +271,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
270
271
  }
271
272
  const target = resolvePatchIdentifier(identifier, manifest.patches);
272
273
  if (!target) {
273
- const available = manifest.patches
274
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
275
- .join(', ');
276
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
274
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
277
275
  }
278
276
  const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
279
277
  const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
@@ -18,6 +18,7 @@ import { Option } from 'commander';
18
18
  import { getProjectPaths } from '../../core/config.js';
19
19
  import { appendHistory } from '../../core/destructive.js';
20
20
  import { updatePatchMetadata } from '../../core/patch-export.js';
21
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
21
22
  import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
22
23
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
23
24
  import { toError } from '../../utils/errors.js';
@@ -56,10 +57,7 @@ export async function patchTierCommand(projectRoot, identifier, options = {}) {
56
57
  }
57
58
  const target = resolvePatchIdentifier(identifier, manifest.patches);
58
59
  if (!target) {
59
- const available = manifest.patches
60
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
61
- .join(', ');
62
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
60
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
63
61
  }
64
62
  const before = target.tier;
65
63
  const after = setting ? options.tier : undefined;
@@ -7,7 +7,8 @@ import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-tab
7
7
  import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
8
8
  import { loadPatchesManifest } from '../core/patch-manifest.js';
9
9
  import { classifyFiles, } from '../core/status-classify.js';
10
- import { GeneralError } from '../errors/base.js';
10
+ import { CommandError, GeneralError } from '../errors/base.js';
11
+ import { ExitCode } from '../errors/codes.js';
11
12
  import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
12
13
  import { info, intro, outro, warn } from '../utils/logger.js';
13
14
  /**
@@ -295,9 +296,19 @@ export async function statusCommand(projectRoot, options = {}) {
295
296
  // and exit non-zero via GeneralError so the exit code still reflects the
296
297
  // failure but stdout remains valid JSON. The same guard runs for
297
298
  // ownership mode below because that path also throws on missing engine.
299
+ // 2026-04-26 eval Finding 1: throw `CommandError` rather than
300
+ // `GeneralError` after the JSON line lands on stdout. `GeneralError`
301
+ // is a `FireForgeError`, so the `withErrorHandling` wrapper in cli.ts
302
+ // calls `logError(error.userMessage)` on it, which routes the styled
303
+ // human banner through clack to stdout — `status --json` therefore
304
+ // emitted both the JSON object AND the `■ Firefox source not found …`
305
+ // line on stdout, breaking every script that pipes the command into
306
+ // a JSON parser. `CommandError` is the bin-only sentinel that
307
+ // `withErrorHandling` does not log: bin/fireforge.ts catches it,
308
+ // exits with the carried code, and stdout stays a single JSON line.
298
309
  const emitJsonError = (code, message) => {
299
310
  process.stdout.write(JSON.stringify({ error: message, code }) + '\n');
300
- throw new GeneralError(message);
311
+ throw new CommandError(ExitCode.GENERAL_ERROR);
301
312
  };
302
313
  // Ownership mode is a flat file→patch table; sources are the manifest's
303
314
  // filesAffected, any worktree drift, and the cross-patch
@@ -9,6 +9,7 @@ import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums
9
9
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from './furnace-config.js';
10
10
  import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from './furnace-constants.js';
11
11
  import { topologicalSortCustom } from './furnace-graph-utils.js';
12
+ import { resolveFurnaceMarkerComment } from './furnace-marker.js';
12
13
  import { recordFurnaceRollbackFailure } from './furnace-operation.js';
13
14
  import { addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from './furnace-registration.js';
14
15
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotFile, } from './furnace-rollback.js';
@@ -304,9 +305,16 @@ export async function applyAllComponents(root, dryRun = false, options) {
304
305
  const { engine: engineDir } = getProjectPaths(root);
305
306
  const furnacePaths = getFurnacePaths(root);
306
307
  const ftlDir = resolveFtlDir(config.ftlBasePath);
307
- const markerComment = await loadConfig(root)
308
- .then((forgeConfig) => forgeConfig.markerComment)
309
- .catch(() => undefined);
308
+ // 2026-04-26 eval Finding 6: when `markerComment` is unset in
309
+ // fireforge.json, default it to `binaryName.toUpperCase()` so the
310
+ // furnace-emitted edits to upstream files (e.g. customElements.js)
311
+ // carry a marker that satisfies `lintModificationComments` — that
312
+ // rule keys on `${binaryName.toUpperCase()}:` and was firing
313
+ // `[missing-modification-comment]` on every furnace-applied
314
+ // upstream edit because the implicit default was `undefined`. An
315
+ // explicit `markerComment` in fireforge.json still wins.
316
+ const forgeConfig = await loadConfig(root).catch(() => undefined);
317
+ const markerComment = resolveFurnaceMarkerComment(forgeConfig);
310
318
  if (!(await pathExists(engineDir))) {
311
319
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
312
320
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Marker-comment resolution shared between furnace apply and deploy.
3
+ *
4
+ * 2026-04-26 eval Finding 6: when `markerComment` is unset in
5
+ * fireforge.json, fall back to `binaryName.toUpperCase()` so the
6
+ * patch-lint rule `lintModificationComments` (which keys on
7
+ * `${binaryName.toUpperCase()}:`) accepts furnace-emitted edits on the
8
+ * next `lint`/`export` round-trip. The helper tolerates the
9
+ * undefined-config case (a project that hasn't run `fireforge setup`
10
+ * yet) and the missing-binaryName case (test fixtures that mock
11
+ * `loadConfig` with a partial shape).
12
+ */
13
+ export declare function resolveFurnaceMarkerComment(forgeConfig: {
14
+ markerComment?: string;
15
+ binaryName?: string;
16
+ } | undefined): string | undefined;
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Marker-comment resolution shared between furnace apply and deploy.
4
+ *
5
+ * 2026-04-26 eval Finding 6: when `markerComment` is unset in
6
+ * fireforge.json, fall back to `binaryName.toUpperCase()` so the
7
+ * patch-lint rule `lintModificationComments` (which keys on
8
+ * `${binaryName.toUpperCase()}:`) accepts furnace-emitted edits on the
9
+ * next `lint`/`export` round-trip. The helper tolerates the
10
+ * undefined-config case (a project that hasn't run `fireforge setup`
11
+ * yet) and the missing-binaryName case (test fixtures that mock
12
+ * `loadConfig` with a partial shape).
13
+ */
14
+ export function resolveFurnaceMarkerComment(forgeConfig) {
15
+ if (!forgeConfig)
16
+ return undefined;
17
+ if (forgeConfig.markerComment !== undefined)
18
+ return forgeConfig.markerComment;
19
+ if (forgeConfig.binaryName)
20
+ return forgeConfig.binaryName.toUpperCase();
21
+ return undefined;
22
+ }
23
+ //# sourceMappingURL=furnace-marker.js.map
@@ -70,9 +70,7 @@ function isInsideDOMContentLoaded(ancestors, content) {
70
70
  call.callee.property.type === 'Identifier' &&
71
71
  call.callee.property.name === 'addEventListener') {
72
72
  const firstArg = call.arguments[0];
73
- if (firstArg &&
74
- firstArg.type === 'Literal' &&
75
- firstArg.value === 'DOMContentLoaded') {
73
+ if (firstArg && firstArg.type === 'Literal' && firstArg.value === 'DOMContentLoaded') {
76
74
  return true;
77
75
  }
78
76
  // Check if "DOMContentLoaded" appears in the call's source (handles edge cases)
@@ -65,6 +65,35 @@ async function cleanupIndexLock(dir) {
65
65
  verbose('Cleaned up stale .git/index.lock after timeout');
66
66
  }
67
67
  }
68
+ /**
69
+ * Returns true when {@link relativePath} is ignored by `.gitignore` (or
70
+ * any other exclusion mechanism git considers, e.g. `.git/info/exclude`,
71
+ * core.excludesFile). Used by the chunked staging fallback to skip
72
+ * entries that would otherwise fail `git add -- <path>` with the fatal
73
+ * "The following paths are ignored by one of your .gitignore files"
74
+ * error — a state the monolithic `git add -A` path silently handles.
75
+ *
76
+ * Implementation: `git check-ignore -q -- <path>` exits 0 when the path
77
+ * is ignored, 1 when it isn't, and >=128 on real failures. Treat
78
+ * anything other than 0/1 as "unknown" and conservatively return false
79
+ * so the chunk runs and any real underlying failure surfaces normally.
80
+ *
81
+ * 2026-04-26 eval Finding 4: a Firefox checkout's top-level `.vscode/`
82
+ * is gitignored by the source tree's own `.gitignore`. Pre-fix, the
83
+ * chunked `git add -- .vscode` invocation aborted the entire fallback
84
+ * and turned a recoverable monolithic timeout into a hard setup
85
+ * failure that required `fireforge download --force`.
86
+ */
87
+ async function isPathIgnored(dir, relativePath) {
88
+ const result = await exec('git', ['check-ignore', '-q', '--', relativePath], { cwd: dir });
89
+ if (result.exitCode === 0)
90
+ return true;
91
+ if (result.exitCode === 1)
92
+ return false;
93
+ // Any other shape is "we don't know" — let the caller proceed and
94
+ // surface the real error if `git add` rejects the path.
95
+ return false;
96
+ }
68
97
  /**
69
98
  * Stages every file by walking top-level directories one at a time.
70
99
  * This avoids a single monolithic `git add -A` that may time out on
@@ -98,11 +127,26 @@ async function stageAllFilesChunked(dir, options = {}) {
98
127
  }
99
128
  }
100
129
  for (const dirName of directories) {
130
+ if (await isPathIgnored(dir, dirName)) {
131
+ options.onProgress?.(`Skipping gitignored: ${dirName}/`);
132
+ continue;
133
+ }
101
134
  options.onProgress?.(`Staging directory: ${dirName}/...`);
102
135
  await runChunk(['add', '--', dirName], dirName);
103
136
  }
104
- // Stage any top-level files
105
- const topLevelFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
137
+ // Stage any top-level files (excluding gitignored ones — `git add`
138
+ // on an explicit ignored path errors out, which would otherwise
139
+ // abort the chunked fallback after the monolithic path has already
140
+ // timed out).
141
+ const topLevelCandidates = entries.filter((e) => e.isFile()).map((e) => e.name);
142
+ const topLevelFiles = [];
143
+ for (const name of topLevelCandidates) {
144
+ if (await isPathIgnored(dir, name)) {
145
+ options.onProgress?.(`Skipping gitignored: ${name}`);
146
+ continue;
147
+ }
148
+ topLevelFiles.push(name);
149
+ }
106
150
  if (topLevelFiles.length > 0) {
107
151
  options.onProgress?.('Staging top-level files...');
108
152
  await runChunk(['add', '--', ...topLevelFiles], 'top-level files');
@@ -125,16 +169,23 @@ const GIT_ADD_HEARTBEAT_MS = 15_000;
125
169
  export async function stageAllFiles(dir, options = {}) {
126
170
  const timeout = options.timeout ?? GIT_ADD_TIMEOUT_MS;
127
171
  const reportProgress = options.onProgress;
128
- const heartbeatStartedAt = Date.now();
129
- // Periodic heartbeat so non-TTY log scrapers (CI, tail -f) AND operators
130
- // watching a spinner both see that the add is still making progress
131
- // rather than a dead process. Each tick reports elapsed seconds so the
132
- // expected 1–3 minute window (see `download.ts`' info banner) is
133
- // observable as it unfolds.
172
+ // 2026-04-26 eval Finding 5: the pre-fix heartbeat used a single
173
+ // `heartbeatStartedAt` set at function entry and reported cumulative
174
+ // elapsed for the whole `stageAllFiles` invocation. After a
175
+ // monolithic timeout, the chunked-phase ticks therefore named
176
+ // numbers that already included the entire monolithic budget plus
177
+ // any host-sleep time, with no way for an operator watching the log
178
+ // to tell where the monolithic attempt ended and the chunked pass
179
+ // began. The heartbeat now tracks a per-phase start timestamp and
180
+ // labels each tick with the phase, so the chunked pass reports its
181
+ // own elapsed window and the monolithic→chunked handoff is visible.
182
+ let phase = 'monolithic';
183
+ let phaseStartedAt = Date.now();
134
184
  const heartbeatTimer = reportProgress
135
185
  ? setInterval(() => {
136
- const elapsedS = Math.round((Date.now() - heartbeatStartedAt) / 1000);
137
- reportProgress(`Indexing Firefox source (still staging, ${elapsedS}s elapsed)`);
186
+ const elapsedS = Math.round((Date.now() - phaseStartedAt) / 1000);
187
+ const label = phase === 'monolithic' ? 'monolithic' : 'chunked staging';
188
+ reportProgress(`Indexing Firefox source (${label}, ${elapsedS}s elapsed)`);
138
189
  }, GIT_ADD_HEARTBEAT_MS)
139
190
  : null;
140
191
  heartbeatTimer?.unref();
@@ -158,6 +209,11 @@ export async function stageAllFiles(dir, options = {}) {
158
209
  }
159
210
  // The killed process may have left an index lock
160
211
  await cleanupIndexLock(dir);
212
+ // Reset elapsed accounting for the chunked phase so its heartbeat
213
+ // names a believable per-phase number rather than rolling the
214
+ // monolithic budget forward.
215
+ phase = 'chunked';
216
+ phaseStartedAt = Date.now();
161
217
  try {
162
218
  await stageAllFilesChunked(dir, options);
163
219
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Builds a concise "patch not found" error message with did-you-mean
3
+ * suggestions in place of the full queue enumeration.
4
+ *
5
+ * 2026-04-26 eval Finding 12: pre-fix every `patch` subcommand
6
+ * (`delete`, `reorder`, `tier`, `lint-ignore`) caught a missing
7
+ * identifier by joining every queued patch's filename and (optional)
8
+ * manifest name into a single comma-separated `Available: ...` tail.
9
+ * On a 29-patch queue the resulting line ran ~1500 characters and
10
+ * buried the actual error under noise that was almost never useful in
11
+ * CI. The new shape ranks each known identifier (ordinal,
12
+ * filename-with-and-without-`.patch`, manifest name) by Levenshtein
13
+ * distance from the operator's input, surfaces up to three suggestions
14
+ * close enough to be plausibly the intended target, and falls back to
15
+ * a count-only summary that points at `fireforge patch list` when no
16
+ * close match exists. The full enumeration is no longer ever inlined.
17
+ */
18
+ import type { PatchMetadata } from '../types/commands/index.js';
19
+ /**
20
+ * Formats the user-facing "patch not found" error message used by
21
+ * `patch delete`, `patch reorder`, `patch tier`, and
22
+ * `patch lint-ignore`. Returns a single string suitable for the
23
+ * `InvalidArgumentError` body — never the full queue enumeration.
24
+ */
25
+ export declare function formatPatchNotFoundError(identifier: string, patches: readonly PatchMetadata[]): string;
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Builds a concise "patch not found" error message with did-you-mean
4
+ * suggestions in place of the full queue enumeration.
5
+ *
6
+ * 2026-04-26 eval Finding 12: pre-fix every `patch` subcommand
7
+ * (`delete`, `reorder`, `tier`, `lint-ignore`) caught a missing
8
+ * identifier by joining every queued patch's filename and (optional)
9
+ * manifest name into a single comma-separated `Available: ...` tail.
10
+ * On a 29-patch queue the resulting line ran ~1500 characters and
11
+ * buried the actual error under noise that was almost never useful in
12
+ * CI. The new shape ranks each known identifier (ordinal,
13
+ * filename-with-and-without-`.patch`, manifest name) by Levenshtein
14
+ * distance from the operator's input, surfaces up to three suggestions
15
+ * close enough to be plausibly the intended target, and falls back to
16
+ * a count-only summary that points at `fireforge patch list` when no
17
+ * close match exists. The full enumeration is no longer ever inlined.
18
+ */
19
+ /** Maximum Levenshtein distance accepted as a "did you mean" suggestion. */
20
+ const SUGGESTION_DISTANCE_THRESHOLD = 3;
21
+ /** Maximum number of suggestions to surface in the error message. */
22
+ const SUGGESTION_LIMIT = 3;
23
+ /**
24
+ * Computes the Levenshtein edit distance between two strings. Used by
25
+ * `formatPatchNotFoundError` to rank candidate identifiers; the small
26
+ * upper bound on input lengths (filenames, ordinals, names) makes the
27
+ * O(m*n) implementation trivially fast.
28
+ */
29
+ function levenshtein(a, b) {
30
+ if (a === b)
31
+ return 0;
32
+ if (a.length === 0)
33
+ return b.length;
34
+ if (b.length === 0)
35
+ return a.length;
36
+ // Allocate the row buffers up-front and fill with zero so every index in
37
+ // [0, b.length] is populated before any read. Using `Array.fill(0)` keeps
38
+ // the type as `number[]` (not `(number | undefined)[]`) so subsequent
39
+ // index reads compose without optional-chaining noise.
40
+ const prev = new Array(b.length + 1).fill(0);
41
+ const curr = new Array(b.length + 1).fill(0);
42
+ for (let j = 0; j <= b.length; j++)
43
+ prev[j] = j;
44
+ for (let i = 1; i <= a.length; i++) {
45
+ curr[0] = i;
46
+ for (let j = 1; j <= b.length; j++) {
47
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
48
+ const left = curr[j - 1] ?? 0;
49
+ const up = prev[j] ?? 0;
50
+ const diag = prev[j - 1] ?? 0;
51
+ curr[j] = Math.min(left + 1, up + 1, diag + cost);
52
+ }
53
+ for (let j = 0; j <= b.length; j++)
54
+ prev[j] = curr[j] ?? 0;
55
+ }
56
+ return prev[b.length] ?? 0;
57
+ }
58
+ /**
59
+ * Collects every identifier shape FireForge accepts for a queue entry:
60
+ * - the ordinal (string form), e.g. `"2"`
61
+ * - the filename, e.g. `"002-ui-foo.patch"`
62
+ * - the filename without the `.patch` suffix, e.g. `"002-ui-foo"`
63
+ * - the manifest `name` field, when distinct from the filename
64
+ * Returned as a flat list so the suggestion ranking can compare each
65
+ * candidate independently.
66
+ */
67
+ function collectAcceptedIdentifiers(patches) {
68
+ const set = new Set();
69
+ for (const patch of patches) {
70
+ set.add(String(patch.order));
71
+ set.add(patch.filename);
72
+ if (patch.filename.endsWith('.patch')) {
73
+ set.add(patch.filename.slice(0, -'.patch'.length));
74
+ }
75
+ if (patch.name && patch.name !== patch.filename)
76
+ set.add(patch.name);
77
+ }
78
+ return Array.from(set);
79
+ }
80
+ /**
81
+ * Returns up to {@link SUGGESTION_LIMIT} accepted identifiers ordered
82
+ * by closest Levenshtein distance to {@link identifier}, dropping any
83
+ * candidate whose distance exceeds {@link SUGGESTION_DISTANCE_THRESHOLD}.
84
+ */
85
+ function rankSuggestions(identifier, candidates) {
86
+ return candidates
87
+ .map((candidate) => ({ candidate, distance: levenshtein(identifier, candidate) }))
88
+ .filter((entry) => entry.distance <= SUGGESTION_DISTANCE_THRESHOLD)
89
+ .sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate))
90
+ .slice(0, SUGGESTION_LIMIT)
91
+ .map((entry) => entry.candidate);
92
+ }
93
+ /**
94
+ * Formats the user-facing "patch not found" error message used by
95
+ * `patch delete`, `patch reorder`, `patch tier`, and
96
+ * `patch lint-ignore`. Returns a single string suitable for the
97
+ * `InvalidArgumentError` body — never the full queue enumeration.
98
+ */
99
+ export function formatPatchNotFoundError(identifier, patches) {
100
+ const accepted = collectAcceptedIdentifiers(patches);
101
+ const suggestions = rankSuggestions(identifier, accepted);
102
+ const lead = `Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo).`;
103
+ if (suggestions.length > 0) {
104
+ return `${lead} Did you mean: ${suggestions.join(', ')}? (${patches.length} patches in queue — run "fireforge patch list" for the full list.)`;
105
+ }
106
+ return `${lead} No close match found among ${patches.length} patches in the queue. Run "fireforge patch list" to see them.`;
107
+ }
108
+ //# sourceMappingURL=patch-identifier-suggest.js.map
@@ -2,10 +2,38 @@
2
2
  * CSS registration in browser/themes/shared/jar.inc.mn.
3
3
  */
4
4
  import type { RegisterResult } from './manifest-register.js';
5
+ /**
6
+ * Measures the column at which the `(source)` parenthesis opens in
7
+ * adjacent `skin/classic/browser/<x>.css (...)` entries inside an
8
+ * existing jar.inc.mn body, and returns the maximum so a newly inserted
9
+ * entry can align its source column to match.
10
+ *
11
+ * 2026-04-26 eval Finding 3: pre-fix `registerSharedCSS` always emitted
12
+ * a four-space gap between the target path and the parenthesis,
13
+ * regardless of how the rest of the file was aligned. Adjacent Firefox
14
+ * entries are typically padded to a wider column, so a freshly
15
+ * registered file landed at the wrong column and produced avoidable
16
+ * formatting churn. Returns `undefined` when no existing entries
17
+ * provide an alignment signal — callers fall back to the four-space
18
+ * default in that case.
19
+ */
20
+ export declare function measureSourceColumn(content: string): number | undefined;
21
+ /**
22
+ * Builds a `skin/classic/browser/<name>.css (../shared/<name>.css)`
23
+ * line padded so the parenthesis lands at {@link sourceColumn} (when
24
+ * supplied) or at the default four-space gap (when {@link sourceColumn}
25
+ * is `undefined` or would force the parenthesis closer to the target
26
+ * than {@link MIN_SOURCE_GAP}).
27
+ */
28
+ export declare function buildEntry(name: string, sourceColumn: number | undefined): string;
5
29
  /**
6
30
  * Registers a CSS file in browser/themes/shared/jar.inc.mn.
7
31
  *
8
32
  * Entry format:
9
33
  * skin/classic/browser/{name}.css (../shared/{name}.css)
34
+ *
35
+ * The gap between target and source is sized to align with adjacent
36
+ * entries when the manifest already uses a wider column; falls back to
37
+ * a four-space minimum otherwise.
10
38
  */
11
39
  export declare function registerSharedCSS(engineDir: string, fileName: string, after?: string, dryRun?: boolean): Promise<RegisterResult>;
@@ -83,11 +83,68 @@ function legacyRegisterSharedCSS(content, name, entry, after) {
83
83
  lines.splice(insertIndex, 0, entry);
84
84
  return { result: lines.join('\n'), previousEntry, afterFallback };
85
85
  }
86
+ /** Minimum gap between the target path and the source parenthesis. */
87
+ const MIN_SOURCE_GAP = 4;
88
+ /**
89
+ * Measures the column at which the `(source)` parenthesis opens in
90
+ * adjacent `skin/classic/browser/<x>.css (...)` entries inside an
91
+ * existing jar.inc.mn body, and returns the maximum so a newly inserted
92
+ * entry can align its source column to match.
93
+ *
94
+ * 2026-04-26 eval Finding 3: pre-fix `registerSharedCSS` always emitted
95
+ * a four-space gap between the target path and the parenthesis,
96
+ * regardless of how the rest of the file was aligned. Adjacent Firefox
97
+ * entries are typically padded to a wider column, so a freshly
98
+ * registered file landed at the wrong column and produced avoidable
99
+ * formatting churn. Returns `undefined` when no existing entries
100
+ * provide an alignment signal — callers fall back to the four-space
101
+ * default in that case.
102
+ */
103
+ export function measureSourceColumn(content) {
104
+ const lines = content.split('\n');
105
+ let maxColumn = 0;
106
+ let sampled = 0;
107
+ // The regex guarantees `(` appears in the matched line, so the index
108
+ // lookup below is always >= 0. The `match` body's leading-whitespace
109
+ // and target-path captures are likewise guaranteed by the pattern, so
110
+ // we can take the literal `match[0]` (full match) length minus one to
111
+ // locate the `(` column without a fragile per-group lookup.
112
+ const lineRe = /^\s*skin\/classic\/browser\/[^\s()]+\s+\(/;
113
+ for (const line of lines) {
114
+ const match = lineRe.exec(line);
115
+ if (!match)
116
+ continue;
117
+ const parenIndex = match[0].length - 1;
118
+ if (parenIndex > maxColumn)
119
+ maxColumn = parenIndex;
120
+ sampled++;
121
+ }
122
+ return sampled > 0 ? maxColumn : undefined;
123
+ }
124
+ /**
125
+ * Builds a `skin/classic/browser/<name>.css (../shared/<name>.css)`
126
+ * line padded so the parenthesis lands at {@link sourceColumn} (when
127
+ * supplied) or at the default four-space gap (when {@link sourceColumn}
128
+ * is `undefined` or would force the parenthesis closer to the target
129
+ * than {@link MIN_SOURCE_GAP}).
130
+ */
131
+ export function buildEntry(name, sourceColumn) {
132
+ const indent = ' ';
133
+ const target = `${indent}skin/classic/browser/${name}.css`;
134
+ const minColumn = target.length + MIN_SOURCE_GAP;
135
+ const column = sourceColumn !== undefined && sourceColumn >= minColumn ? sourceColumn : minColumn;
136
+ const padding = ' '.repeat(column - target.length);
137
+ return `${target}${padding}(../shared/${name}.css)`.replace(/\\/g, '/');
138
+ }
86
139
  /**
87
140
  * Registers a CSS file in browser/themes/shared/jar.inc.mn.
88
141
  *
89
142
  * Entry format:
90
143
  * skin/classic/browser/{name}.css (../shared/{name}.css)
144
+ *
145
+ * The gap between target and source is sized to align with adjacent
146
+ * entries when the manifest already uses a wider column; falls back to
147
+ * a four-space minimum otherwise.
91
148
  */
92
149
  export async function registerSharedCSS(engineDir, fileName, after, dryRun = false) {
93
150
  const manifest = 'browser/themes/shared/jar.inc.mn';
@@ -96,8 +153,9 @@ export async function registerSharedCSS(engineDir, fileName, after, dryRun = fal
96
153
  throw new GeneralError(`Manifest not found: ${manifest}`);
97
154
  }
98
155
  const name = basename(fileName, '.css');
99
- const entry = ` skin/classic/browser/${name}.css (../shared/${name}.css)`.replace(/\\/g, '/');
100
156
  const content = await readText(manifestPath);
157
+ const sourceColumn = measureSourceColumn(content);
158
+ const entry = buildEntry(name, sourceColumn);
101
159
  // Idempotency check. `furnace chrome-doc create` writes its CSS as a
102
160
  // `content/browser/<name>.css` entry rather than the canonical
103
161
  // `skin/classic/browser/<name>.css` form `register` produces; recognise
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.18.3",
3
+ "version": "0.18.6",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",