@hominis/fireforge 0.16.2 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +9 -2
  3. package/dist/bin/fireforge.js +11 -2
  4. package/dist/src/commands/doctor-furnace.js +83 -1
  5. package/dist/src/commands/doctor.js +18 -0
  6. package/dist/src/commands/download.js +16 -1
  7. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  8. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  9. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  10. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  11. package/dist/src/commands/furnace/create-templates.js +85 -31
  12. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  13. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  14. package/dist/src/commands/import.js +63 -11
  15. package/dist/src/commands/patch/delete.js +10 -1
  16. package/dist/src/commands/setup-support.js +60 -7
  17. package/dist/src/commands/status.js +28 -1
  18. package/dist/src/commands/test.js +20 -4
  19. package/dist/src/commands/token.js +7 -1
  20. package/dist/src/core/branding.d.ts +10 -0
  21. package/dist/src/core/branding.js +7 -9
  22. package/dist/src/core/build-prepare.js +8 -1
  23. package/dist/src/core/file-lock.js +49 -15
  24. package/dist/src/core/furnace-operation.d.ts +17 -0
  25. package/dist/src/core/furnace-operation.js +30 -1
  26. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  27. package/dist/src/core/furnace-validate-helpers.js +53 -2
  28. package/dist/src/core/git.js +39 -10
  29. package/dist/src/core/manifest-rules.js +16 -0
  30. package/dist/src/core/marionette-preflight.js +43 -12
  31. package/dist/src/core/patch-files.d.ts +12 -1
  32. package/dist/src/core/patch-files.js +14 -11
  33. package/dist/src/core/patch-lint.js +62 -11
  34. package/package.json +1 -1
@@ -43,23 +43,39 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
43
43
  try {
44
44
  const lockStat = await stat(lockPath);
45
45
  const ageMs = Date.now() - lockStat.mtimeMs;
46
- if (ageMs <= staleMs) {
47
- return false;
48
- }
49
- // If the lock directory contains a PID file, check whether the owning
50
- // process is still running before removing. This prevents premature
51
- // removal when a slow operation (e.g. mach build) legitimately holds
52
- // the lock past the stale threshold.
53
- try {
54
- const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
55
- const pid = parseInt(pidContent.trim(), 10);
56
- if (Number.isFinite(pid) && isProcessAlive(pid)) {
57
- verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${pid} is still running — not removing`);
58
- return false;
46
+ // Check PID FIRST, independent of age. Before this ordering change the
47
+ // function age-gated everything: a lock younger than `staleMs` (default
48
+ // 5 minutes) was never removed even when its PID file pointed at a
49
+ // process that had already exited. That's the exact situation an
50
+ // operator lands in after SIGINT'ing `furnace preview` the signal
51
+ // handler calls `process.exit`, `withFileLock`'s `finally { rm }`
52
+ // never runs, and the next command has to either wait 5 minutes for
53
+ // the staleness gate or manually remove the lock directory. With the
54
+ // PID-first check, an explicitly-dead owner unblocks immediately.
55
+ const pidCheck = await readLockOwnerPid(lockPath);
56
+ if (pidCheck.present) {
57
+ if (!isProcessAlive(pidCheck.pid)) {
58
+ const staleMessage = onStaleLockMessage?.(ageMs);
59
+ if (staleMessage) {
60
+ warn(staleMessage);
61
+ }
62
+ else {
63
+ verbose(`Lock at ${lockPath} owner PID ${pidCheck.pid} is no longer running — removing (age: ${Math.round(ageMs / 1000)}s)`);
64
+ }
65
+ await rm(lockPath, { recursive: true, force: true });
66
+ return true;
59
67
  }
68
+ // PID is alive — respect it regardless of age. A slow `mach build`
69
+ // legitimately holds the lock past the stale threshold and we don't
70
+ // want to race-remove it.
71
+ verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${pidCheck.pid} is still running — not removing`);
72
+ return false;
60
73
  }
61
- catch {
62
- // No PID file or unreadable fall through to stale removal
74
+ // No readable PID file. Fall back to the age-only heuristic so locks
75
+ // written by earlier FireForge releases (which may not have written a
76
+ // PID file) still clear after the staleness window elapses.
77
+ if (ageMs <= staleMs) {
78
+ return false;
63
79
  }
64
80
  const staleMessage = onStaleLockMessage?.(ageMs);
65
81
  if (staleMessage) {
@@ -78,6 +94,24 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
78
94
  throw toError(error);
79
95
  }
80
96
  }
97
+ /**
98
+ * Reads the owner PID from a lock directory's PID file. Returns `{ present:
99
+ * false }` when the PID file is missing or the content does not parse as a
100
+ * finite integer (caller falls back to the age-only staleness heuristic).
101
+ */
102
+ async function readLockOwnerPid(lockPath) {
103
+ try {
104
+ const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
105
+ const pid = parseInt(pidContent.trim(), 10);
106
+ if (Number.isFinite(pid)) {
107
+ return { present: true, pid };
108
+ }
109
+ }
110
+ catch {
111
+ // PID file missing or unreadable — treat as absent.
112
+ }
113
+ return { present: false };
114
+ }
81
115
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
82
116
  export async function withFileLock(lockPath, operation, options = {}) {
83
117
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
@@ -74,6 +74,23 @@ export declare function rollbackActiveOperationsForSignal(signal: FurnaceShutdow
74
74
  * touch this directly.
75
75
  */
76
76
  export declare function getFurnaceLockPath(root: string): string;
77
+ /**
78
+ * Forcibly removes the furnace lock directory for every active operation.
79
+ *
80
+ * The bin-layer signal handler calls `process.exit` after rollback, which
81
+ * short-circuits Node's normal unwinding — `withFileLock`'s `finally { rm
82
+ * }` never runs, so the lock directory survives the process. The next
83
+ * FireForge command then has to either wait out the staleness window or
84
+ * have the operator remove the lock manually. This sweeper runs inside
85
+ * the signal-handler pipeline BEFORE `process.exit`, so the lock is gone
86
+ * by the time the next command starts.
87
+ *
88
+ * Errors are logged and swallowed: we do not want a slow I/O failure at
89
+ * shutdown to prevent the process from exiting. The doctor-side stale
90
+ * lock check (`src/commands/doctor-furnace.ts`) is the backup path for
91
+ * any lock that escapes this sweep.
92
+ */
93
+ export declare function forceReleaseFurnaceLocksForActiveOperations(): Promise<void>;
77
94
  /**
78
95
  * Runs a furnace-mutating body under the apply-wide lock and registers it
79
96
  * with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
@@ -1,8 +1,9 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { rm } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { FurnaceError } from '../errors/furnace.js';
4
5
  import { toError } from '../utils/errors.js';
5
- import { warn } from '../utils/logger.js';
6
+ import { verbose, warn } from '../utils/logger.js';
6
7
  import { FIREFORGE_DIR } from './config-paths.js';
7
8
  import { withFileLock } from './file-lock.js';
8
9
  import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
@@ -136,6 +137,34 @@ async function persistPendingRepair(root, operation, reason) {
136
137
  export function getFurnaceLockPath(root) {
137
138
  return join(root, FIREFORGE_DIR, FURNACE_LOCK_FILENAME);
138
139
  }
140
+ /**
141
+ * Forcibly removes the furnace lock directory for every active operation.
142
+ *
143
+ * The bin-layer signal handler calls `process.exit` after rollback, which
144
+ * short-circuits Node's normal unwinding — `withFileLock`'s `finally { rm
145
+ * }` never runs, so the lock directory survives the process. The next
146
+ * FireForge command then has to either wait out the staleness window or
147
+ * have the operator remove the lock manually. This sweeper runs inside
148
+ * the signal-handler pipeline BEFORE `process.exit`, so the lock is gone
149
+ * by the time the next command starts.
150
+ *
151
+ * Errors are logged and swallowed: we do not want a slow I/O failure at
152
+ * shutdown to prevent the process from exiting. The doctor-side stale
153
+ * lock check (`src/commands/doctor-furnace.ts`) is the backup path for
154
+ * any lock that escapes this sweep.
155
+ */
156
+ export async function forceReleaseFurnaceLocksForActiveOperations() {
157
+ const paths = new Set([...activeOperations.values()].map((op) => getFurnaceLockPath(op.root)));
158
+ for (const lockPath of paths) {
159
+ try {
160
+ await rm(lockPath, { recursive: true, force: true });
161
+ verbose(`Removed furnace lock at ${lockPath} during signal teardown`);
162
+ }
163
+ catch (error) {
164
+ verbose(`Could not remove furnace lock at ${lockPath} during signal teardown: ${toError(error).message}`);
165
+ }
166
+ }
167
+ }
139
168
  /**
140
169
  * Runs a furnace-mutating body under the apply-wide lock and registers it
141
170
  * with the process-wide SIGINT/SIGTERM rollback pathway. The lock prevents
@@ -61,7 +61,39 @@ export declare function stripCssBlockComments(content: string): string;
61
61
  export declare function hasRelativeModuleImport(mjsContent: string): boolean;
62
62
  /** Detects whether a module defines a custom element at runtime. */
63
63
  export declare function hasCustomElementDefineCall(mjsContent: string): boolean;
64
- /** Checks whether a declared component class extends MozLitElement. */
64
+ /**
65
+ * Detects whether the module's `customElements.define(...)` call includes a
66
+ * literal `extends:` option (third argument). That shape is the marker for
67
+ * a customized built-in element — the class extends a specific
68
+ * `HTMLxxxElement` rather than the autonomous `MozLitElement` path.
69
+ *
70
+ * Firefox's own widgets use this pattern for toolkit anchors (e.g.
71
+ * `moz-support-link` extends `HTMLAnchorElement` with
72
+ * `customElements.define("moz-support-link", ..., { extends: "a" })`), and
73
+ * the validator's `not-moz-lit-element` check must allow them through or
74
+ * `furnace override` of a valid upstream component fails its own
75
+ * `furnace validate` pass with nothing the operator can fix.
76
+ */
77
+ export declare function hasCustomElementExtendsOption(mjsContent: string): boolean;
78
+ /**
79
+ * Checks whether a declared component class extends a valid element base.
80
+ *
81
+ * Two shapes are accepted:
82
+ *
83
+ * 1. Autonomous custom element: `class X extends MozLitElement` — the
84
+ * default FireForge pattern for fork-authored components and most
85
+ * toolkit widgets.
86
+ * 2. Customized built-in: `class X extends HTML<Something>Element` paired
87
+ * with a `customElements.define(..., ..., { extends: "<tagname>" })`
88
+ * call. Firefox's `moz-support-link`, `moz-button-group` tabbing
89
+ * widgets, and a handful of other toolkit components use this form;
90
+ * `furnace override` of those components writes the original source
91
+ * verbatim and the validator must not reject them.
92
+ *
93
+ * A class that extends a plain `HTMLElement` WITHOUT a `extends:` option
94
+ * is still rejected — that's the legitimate `not-moz-lit-element` case
95
+ * the rule was originally designed to catch.
96
+ */
65
97
  export declare function classExtendsMozLitElement(mjsContent: string): boolean;
66
98
  /** Collects CSS custom property references used via var(--token-name). */
67
99
  export declare function collectCssVariableReferences(cssContent: string): string[];
@@ -269,7 +269,46 @@ export function hasRelativeModuleImport(mjsContent) {
269
269
  export function hasCustomElementDefineCall(mjsContent) {
270
270
  return /customElements\.define\s*\(/.test(mjsContent);
271
271
  }
272
- /** Checks whether a declared component class extends MozLitElement. */
272
+ /**
273
+ * Detects whether the module's `customElements.define(...)` call includes a
274
+ * literal `extends:` option (third argument). That shape is the marker for
275
+ * a customized built-in element — the class extends a specific
276
+ * `HTMLxxxElement` rather than the autonomous `MozLitElement` path.
277
+ *
278
+ * Firefox's own widgets use this pattern for toolkit anchors (e.g.
279
+ * `moz-support-link` extends `HTMLAnchorElement` with
280
+ * `customElements.define("moz-support-link", ..., { extends: "a" })`), and
281
+ * the validator's `not-moz-lit-element` check must allow them through or
282
+ * `furnace override` of a valid upstream component fails its own
283
+ * `furnace validate` pass with nothing the operator can fix.
284
+ */
285
+ export function hasCustomElementExtendsOption(mjsContent) {
286
+ // Match `customElements.define(..., { ..., extends: "..." })`. Tolerant of
287
+ // whitespace, line breaks, and other object properties. The `[^)]*` stops
288
+ // the inner greedy match at the closing define call paren so a later
289
+ // `define` call on a different custom element in the same module does
290
+ // not bleed its options in.
291
+ return /customElements\.define\s*\([^)]*\bextends\s*:\s*["'`]/.test(mjsContent);
292
+ }
293
+ /**
294
+ * Checks whether a declared component class extends a valid element base.
295
+ *
296
+ * Two shapes are accepted:
297
+ *
298
+ * 1. Autonomous custom element: `class X extends MozLitElement` — the
299
+ * default FireForge pattern for fork-authored components and most
300
+ * toolkit widgets.
301
+ * 2. Customized built-in: `class X extends HTML<Something>Element` paired
302
+ * with a `customElements.define(..., ..., { extends: "<tagname>" })`
303
+ * call. Firefox's `moz-support-link`, `moz-button-group` tabbing
304
+ * widgets, and a handful of other toolkit components use this form;
305
+ * `furnace override` of those components writes the original source
306
+ * verbatim and the validator must not reject them.
307
+ *
308
+ * A class that extends a plain `HTMLElement` WITHOUT a `extends:` option
309
+ * is still rejected — that's the legitimate `not-moz-lit-element` case
310
+ * the rule was originally designed to catch.
311
+ */
273
312
  export function classExtendsMozLitElement(mjsContent) {
274
313
  const hasClassDeclaration = /class\s+\w+\s+extends\s+/.test(mjsContent);
275
314
  if (!hasClassDeclaration) {
@@ -278,7 +317,19 @@ export function classExtendsMozLitElement(mjsContent) {
278
317
  // structural issues.
279
318
  return true;
280
319
  }
281
- return /class\s+\w+\s+extends\s+MozLitElement\b/.test(mjsContent);
320
+ if (/class\s+\w+\s+extends\s+MozLitElement\b/.test(mjsContent)) {
321
+ return true;
322
+ }
323
+ // Customized built-in: extend a specific HTMLxxxElement AND the define
324
+ // call carries an `extends:` option. Both halves are required — a class
325
+ // that extends `HTMLAnchorElement` without the matching define option is
326
+ // almost certainly an author mistake, and a define call with `extends:`
327
+ // without a matching class is unreachable.
328
+ const extendsCustomizedBuiltin = /class\s+\w+\s+extends\s+HTML[A-Z]\w*Element\b/.test(mjsContent);
329
+ if (extendsCustomizedBuiltin && hasCustomElementExtendsOption(mjsContent)) {
330
+ return true;
331
+ }
332
+ return false;
282
333
  }
283
334
  /** Collects CSS custom property references used via var(--token-name). */
284
335
  export function collectCssVariableReferences(cssContent) {
@@ -83,6 +83,15 @@ async function stageAllFilesChunked(dir, options = {}) {
83
83
  });
84
84
  }
85
85
  }
86
+ /**
87
+ * Interval between heartbeat progress messages during the monolithic
88
+ * `git add -A`. On a fresh ~600 MB Firefox tree the monolithic add runs
89
+ * 60–120 seconds, during which git emits nothing to stdout/stderr. Without
90
+ * a heartbeat the CLI spinner stays pinned on "Indexing Firefox source …"
91
+ * for the full window, looks hung, and in the eval scenario operators
92
+ * SIGINT'd mid-way assuming the process had stalled.
93
+ */
94
+ const GIT_ADD_HEARTBEAT_MS = 15_000;
86
95
  /**
87
96
  * Stages all files in the repository.
88
97
  * Tries a monolithic `git add -A` first; if that times out, falls back to
@@ -90,19 +99,39 @@ async function stageAllFilesChunked(dir, options = {}) {
90
99
  */
91
100
  export async function stageAllFiles(dir, options = {}) {
92
101
  const timeout = options.timeout ?? GIT_ADD_TIMEOUT_MS;
102
+ const reportProgress = options.onProgress;
103
+ const heartbeatStartedAt = Date.now();
104
+ // Periodic heartbeat so non-TTY log scrapers (CI, tail -f) AND operators
105
+ // watching a spinner both see that the add is still making progress
106
+ // rather than a dead process. Each tick reports elapsed seconds so the
107
+ // expected 1–3 minute window (see `download.ts`' info banner) is
108
+ // observable as it unfolds.
109
+ const heartbeatTimer = reportProgress
110
+ ? setInterval(() => {
111
+ const elapsedS = Math.round((Date.now() - heartbeatStartedAt) / 1000);
112
+ reportProgress(`Indexing Firefox source (still staging, ${elapsedS}s elapsed)`);
113
+ }, GIT_ADD_HEARTBEAT_MS)
114
+ : null;
115
+ heartbeatTimer?.unref();
93
116
  try {
94
- await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
95
- return;
96
- }
97
- catch (error) {
98
- if (!isTimeoutError(error)) {
99
- throw await maybeWrapIndexLockError(dir, error);
117
+ try {
118
+ await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
119
+ return;
120
+ }
121
+ catch (error) {
122
+ if (!isTimeoutError(error)) {
123
+ throw await maybeWrapIndexLockError(dir, error);
124
+ }
125
+ options.onProgress?.('Monolithic git add timed out; falling back to chunked staging...');
100
126
  }
101
- options.onProgress?.('Monolithic git add timed out; falling back to chunked staging...');
127
+ // The killed process may have left an index lock
128
+ await cleanupIndexLock(dir);
129
+ await stageAllFilesChunked(dir, options);
130
+ }
131
+ finally {
132
+ if (heartbeatTimer)
133
+ clearInterval(heartbeatTimer);
102
134
  }
103
- // The killed process may have left an index lock
104
- await cleanupIndexLock(dir);
105
- await stageAllFilesChunked(dir, options);
106
135
  }
107
136
  /**
108
137
  * Initializes a new git repository with an orphan branch.
@@ -119,6 +119,22 @@ function getUnregistrableAdvice(filePath) {
119
119
  '"fireforge wire <name> --dom <path>" to insert the #include, or add the directive manually ' +
120
120
  'in the top-level chrome document.');
121
121
  }
122
+ // xpcshell.toml manifests scaffolded by `furnace create --test-style
123
+ // xpcshell` or `furnace chrome-doc create --with-tests` live under
124
+ // `browser/base/content/test/<binary-name>-xpcshell/<component>/`. The
125
+ // scaffold intentionally does NOT auto-register — wiring
126
+ // `XPCSHELL_TESTS_MANIFESTS` requires a deliberate choice about which
127
+ // moz.build should own the entry. `register` surfaces that same guidance
128
+ // instead of falling through to browser.toml-centric advice, which was
129
+ // the eval finding (operator saw "register browser.toml" hint for a
130
+ // path that was xpcshell-shaped and contained no browser.toml).
131
+ if (filePath.endsWith('/xpcshell.toml')) {
132
+ return (`xpcshell.toml manifests are not auto-registered by "fireforge register". ` +
133
+ `Add "XPCSHELL_TESTS_MANIFESTS += ['${basename(filePath)}']" to the ` +
134
+ `moz.build that covers ${filePath.replace(/\/xpcshell\.toml$/, '/')}, ` +
135
+ 'or let `furnace create --test-style xpcshell` print the warning that names the canonical wiring location. ' +
136
+ 'Browser-chrome tests (browser.toml) are auto-registered via "fireforge register <path>/browser.toml".');
137
+ }
122
138
  const testMatch = filePath.match(/^browser\/base\/content\/test\/([^/]+)\/(?!browser\.toml$).+$/);
123
139
  if (testMatch) {
124
140
  const dir = testMatch[1];
@@ -110,6 +110,16 @@ export async function runMarionettePreflight(engineDir, options = {}) {
110
110
  cwd: engineDir,
111
111
  env: { ...process.env, MOZ_HEADLESS: '1' },
112
112
  stdio: ['ignore', 'ignore', 'pipe'],
113
+ // `detached: true` puts the child in a new process group so we can
114
+ // signal it and every descendant (Firefox, its helpers) via
115
+ // `process.kill(-pid, …)` in the finally block. Without this, the
116
+ // child is Python running mach; a SIGTERM kills Python but the
117
+ // Firefox grandchild inherits the stderr pipe FD and keeps Node's
118
+ // event loop alive even after the preflight PASS log. The symptom
119
+ // is `fireforge test --doctor` printing `Marionette preflight:
120
+ // PASS` and then hanging indefinitely in `uv__io_poll` — see the
121
+ // eval finding.
122
+ detached: true,
113
123
  });
114
124
  }
115
125
  catch (error) {
@@ -154,24 +164,21 @@ export async function runMarionettePreflight(engineDir, options = {}) {
154
164
  }
155
165
  finally {
156
166
  if (child && !hasChildExited(child)) {
157
- try {
158
- child.kill('SIGTERM');
159
- }
160
- catch {
161
- // Already exited — nothing to do.
162
- }
167
+ killProcessGroup(child, 'SIGTERM');
163
168
  // Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
164
169
  // so we don't leave a ghost mach process around after a failed probe.
165
170
  await delay(500);
166
171
  if (!hasChildExited(child)) {
167
- try {
168
- child.kill('SIGKILL');
169
- }
170
- catch {
171
- // Already gone.
172
- }
172
+ killProcessGroup(child, 'SIGKILL');
173
173
  }
174
174
  }
175
+ // Destroy the stderr pipe explicitly. Firefox (a grandchild of the Python
176
+ // mach wrapper we spawned) can inherit and hold the stderr FD even after
177
+ // its direct parent exits — until the pipe closes, Node's readable
178
+ // stream stays attached and `uv__io_poll` keeps the event loop alive.
179
+ // `destroy()` closes the local end regardless, so `fireforge test
180
+ // --doctor` exits cleanly after a passing preflight.
181
+ child?.stderr?.destroy();
175
182
  try {
176
183
  await rm(profileDir, { recursive: true, force: true });
177
184
  }
@@ -180,6 +187,30 @@ export async function runMarionettePreflight(engineDir, options = {}) {
180
187
  }
181
188
  }
182
189
  }
190
+ /**
191
+ * Sends `signal` to the child's whole process group when possible, falling
192
+ * back to a direct `child.kill` for environments that don't support
193
+ * `detached: true` (Windows in particular: Node still returns a pid, but
194
+ * `kill(-pid, …)` is not a supported kernel primitive).
195
+ */
196
+ function killProcessGroup(child, signal) {
197
+ if (child.pid !== undefined && process.platform !== 'win32') {
198
+ try {
199
+ process.kill(-child.pid, signal);
200
+ return;
201
+ }
202
+ catch {
203
+ // ESRCH / EPERM — fall through to the narrow kill below so we at
204
+ // least signal the direct child.
205
+ }
206
+ }
207
+ try {
208
+ child.kill(signal);
209
+ }
210
+ catch {
211
+ // Already exited — nothing to do.
212
+ }
213
+ }
183
214
  function fail(layer, message, durationMs) {
184
215
  return {
185
216
  ok: false,
@@ -7,5 +7,16 @@ export declare function countPatches(patchesDir: string): Promise<number>;
7
7
  export declare function isNewFilePatch(patchPath: string): Promise<boolean>;
8
8
  /** Returns the first target file path referenced by a patch, if any. */
9
9
  export declare function getTargetFileFromPatch(patchPath: string): Promise<string | null>;
10
- /** Returns all target file paths referenced by a multi-file patch. */
10
+ /**
11
+ * Returns all target file paths referenced by a multi-file patch.
12
+ *
13
+ * Delegates to {@link extractAffectedFiles} so `GIT binary patch` sections
14
+ * (which have no `+++ b/…` line, only a `diff --git a/… b/…` header) are
15
+ * included alongside text hunks. Before this delegation the custom regex
16
+ * only matched `+++ b/…` lines, dropping every binary file from
17
+ * `filesAffected` — verify reported `files-affected-mismatch` against
18
+ * branding patches and `doctor --repair-patches-manifest` "repaired" the
19
+ * manifest by rewriting it to the text-only subset, hiding the true scope
20
+ * of the patch.
21
+ */
11
22
  export declare function getAllTargetFilesFromPatch(patchPath: string): Promise<string[]>;
@@ -2,7 +2,7 @@
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
4
  import { pathExists, readText } from '../utils/fs.js';
5
- import { extractOrder } from './patch-parse.js';
5
+ import { extractAffectedFiles, extractOrder } from './patch-parse.js';
6
6
  /** Discovers patch files in a directory and returns them in apply order. */
7
7
  export async function discoverPatches(patchesDir) {
8
8
  if (!(await pathExists(patchesDir))) {
@@ -35,17 +35,20 @@ export async function getTargetFileFromPatch(patchPath) {
35
35
  const match = /^\+\+\+ b\/(.+)$/m.exec(content);
36
36
  return match?.[1] ?? null;
37
37
  }
38
- /** Returns all target file paths referenced by a multi-file patch. */
38
+ /**
39
+ * Returns all target file paths referenced by a multi-file patch.
40
+ *
41
+ * Delegates to {@link extractAffectedFiles} so `GIT binary patch` sections
42
+ * (which have no `+++ b/…` line, only a `diff --git a/… b/…` header) are
43
+ * included alongside text hunks. Before this delegation the custom regex
44
+ * only matched `+++ b/…` lines, dropping every binary file from
45
+ * `filesAffected` — verify reported `files-affected-mismatch` against
46
+ * branding patches and `doctor --repair-patches-manifest` "repaired" the
47
+ * manifest by rewriting it to the text-only subset, hiding the true scope
48
+ * of the patch.
49
+ */
39
50
  export async function getAllTargetFilesFromPatch(patchPath) {
40
51
  const content = await readText(patchPath);
41
- const files = [];
42
- const regex = /^\+\+\+ b\/(.+)$/gm;
43
- let match;
44
- while ((match = regex.exec(content)) !== null) {
45
- if (match[1]) {
46
- files.push(match[1]);
47
- }
48
- }
49
- return files;
52
+ return extractAffectedFiles(content);
50
53
  }
51
54
  //# sourceMappingURL=patch-files.js.map
@@ -60,7 +60,28 @@ export function countNonBinaryDiffLines(diffContent) {
60
60
  const PATCH_LINE_THRESHOLDS = {
61
61
  general: { notice: 800, warning: 1500, error: 3000 },
62
62
  test: { notice: 1500, warning: 3000, error: 6000 },
63
+ /**
64
+ * Branding patches have a legitimate reason to be large: they include
65
+ * every locale's `brand.ftl`, copied upstream CSS/PNG assets, and the
66
+ * fork-specific `configure.sh` / `brand.properties` under a single
67
+ * `browser/branding/<name>/` subtree. The general hard limit of 3000
68
+ * lines fires on even a first-export branding patch (the eval saw 15904
69
+ * lines for a freshly-setup fork), which is loud but not actionable —
70
+ * the patch already is the minimum branding diff. A dedicated tier keeps
71
+ * the size guidance while moving the hard limit to a threshold that
72
+ * corresponds to "something other than branding is bundled in here too".
73
+ */
74
+ branding: { notice: 3000, warning: 8000, error: 20000 },
63
75
  };
76
+ /**
77
+ * Returns true when every file in a patch lives under `browser/branding/`.
78
+ * Used by `lintPatchSize` to pick the branding threshold tier.
79
+ */
80
+ function isBrandingOnlyPatch(files) {
81
+ if (files.length === 0)
82
+ return false;
83
+ return files.every((file) => file.startsWith('browser/branding/'));
84
+ }
64
85
  /**
65
86
  * Returns true if the filename looks like a JS/MJS/JSM file.
66
87
  * Handles `.sys.mjs` as well.
@@ -133,10 +154,18 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
133
154
  // Strip block comments before scanning
134
155
  const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
135
156
  // Check only introduced raw color values when diff context is available.
136
- // Skip files on the raw-color allowlist (exact path or basename match).
157
+ // Skip files on the raw-color allowlist (exact path or basename match) and
158
+ // auto-exempt files under `browser/branding/` — those are the fork's
159
+ // visual identity assets (app-about dialogs, installer pages, branded
160
+ // CSS copied from Firefox's `unofficial` template) and belong to the
161
+ // design-decision layer the design-token system does not govern.
162
+ // Without this auto-exemption, every first-time setup's copied CSS
163
+ // failed `raw-color-value` with no actionable fix other than manually
164
+ // listing each path in `rawColorAllowlist`.
137
165
  const allowlist = config?.patchLint?.rawColorAllowlist;
138
166
  const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
139
- if (!isAllowlisted) {
167
+ const isBranding = file.startsWith('browser/branding/');
168
+ if (!isAllowlisted && !isBranding) {
140
169
  // Strip lines with inline fireforge-ignore: raw-color-value suppression.
141
170
  // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
142
171
  const sourceForSuppression = addedLinesByFile
@@ -239,14 +268,25 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
239
268
  continue;
240
269
  const content = await readText(filePath);
241
270
  const expectedHeader = getLicenseHeader(license, style);
242
- if (!content.startsWith(expectedHeader)) {
243
- issues.push({
244
- file,
245
- check: 'missing-license-header',
246
- message: `New file is missing the required ${license} license header.`,
247
- severity: 'error',
248
- });
249
- }
271
+ // Auto-exempt `browser/branding/` when the file carries ANY recognised
272
+ // license header in the matching comment style. The setup-generated
273
+ // branding directory is copied from Firefox's `unofficial` template,
274
+ // which arrives with Mozilla MPL-2.0 headers — those are legitimate
275
+ // for copyright purposes (the assets are Mozilla's) even when the
276
+ // fork's own code is 0BSD / EUPL-1.2 / GPL-2.0-or-later. The narrower
277
+ // license-match rule would force operators to either rewrite the
278
+ // copied headers (misrepresenting authorship) or suppress the lint
279
+ // with `--skip-lint` (hiding real issues elsewhere).
280
+ if (content.startsWith(expectedHeader))
281
+ continue;
282
+ if (file.startsWith('browser/branding/') && hasAnyLicenseHeader(content, style))
283
+ continue;
284
+ issues.push({
285
+ file,
286
+ check: 'missing-license-header',
287
+ message: `New file is missing the required ${license} license header.`,
288
+ severity: 'error',
289
+ });
250
290
  }
251
291
  return issues;
252
292
  }
@@ -402,8 +442,19 @@ export function lintPatchSize(filesAffected, lineCount) {
402
442
  severity: 'warning',
403
443
  });
404
444
  }
445
+ // Tier selection: test > branding > general. Tests keep their elevated
446
+ // thresholds because a big regression test is legitimate (table-driven
447
+ // harnesses run into the thousands of lines). Branding patches get their
448
+ // own tier so a first-export of setup-generated branding doesn't fire
449
+ // the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
450
+ // for the eval data motivating this tier.
405
451
  const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
406
- const thresholds = allTests ? PATCH_LINE_THRESHOLDS.test : PATCH_LINE_THRESHOLDS.general;
452
+ const branding = !allTests && isBrandingOnlyPatch(filesAffected);
453
+ const thresholds = allTests
454
+ ? PATCH_LINE_THRESHOLDS.test
455
+ : branding
456
+ ? PATCH_LINE_THRESHOLDS.branding
457
+ : PATCH_LINE_THRESHOLDS.general;
407
458
  if (lineCount >= thresholds.error) {
408
459
  issues.push({
409
460
  file: '(patch)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",