@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 +10 -4
- package/dist/src/commands/build.js +27 -12
- package/dist/src/commands/config.js +64 -6
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +59 -8
- package/dist/src/commands/furnace/chrome-doc-templates.js +95 -12
- package/dist/src/commands/furnace/chrome-doc.js +24 -2
- package/dist/src/commands/furnace/deploy.js +10 -1
- package/dist/src/commands/furnace/init.js +28 -2
- package/dist/src/commands/lint.js +22 -3
- package/dist/src/commands/patch/delete.js +2 -4
- package/dist/src/commands/patch/lint-ignore.js +2 -4
- package/dist/src/commands/patch/reorder.js +2 -4
- package/dist/src/commands/patch/tier.js +2 -4
- package/dist/src/commands/status.js +13 -2
- package/dist/src/core/furnace-apply.js +11 -3
- package/dist/src/core/furnace-marker.d.ts +16 -0
- package/dist/src/core/furnace-marker.js +23 -0
- package/dist/src/core/furnace-registration-ast.js +1 -3
- package/dist/src/core/git.js +66 -10
- package/dist/src/core/patch-identifier-suggest.d.ts +25 -0
- package/dist/src/core/patch-identifier-suggest.js +108 -0
- package/dist/src/core/register-shared-css.d.ts +28 -0
- package/dist/src/core/register-shared-css.js +59 -1
- package/package.json +1 -1
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
|
|
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;
|
|
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`
|
|
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`
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 (
|
|
70
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
* -
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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"${
|
|
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.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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)
|
package/dist/src/core/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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() -
|
|
137
|
-
|
|
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
|