@hominis/fireforge 0.16.2 → 0.16.5

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 (57) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +15 -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 +58 -12
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  13. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  14. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  15. package/dist/src/commands/furnace/create-readback.js +34 -0
  16. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  17. package/dist/src/commands/furnace/create-templates.js +85 -31
  18. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  19. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  20. package/dist/src/commands/furnace/create.js +2 -0
  21. package/dist/src/commands/furnace/preview.d.ts +12 -0
  22. package/dist/src/commands/furnace/preview.js +34 -2
  23. package/dist/src/commands/furnace/status.js +1 -1
  24. package/dist/src/commands/import.js +63 -11
  25. package/dist/src/commands/patch/delete.js +10 -1
  26. package/dist/src/commands/patch/index.js +10 -1
  27. package/dist/src/commands/re-export.js +79 -6
  28. package/dist/src/commands/resolve.js +15 -1
  29. package/dist/src/commands/run.js +27 -5
  30. package/dist/src/commands/setup-support.js +60 -7
  31. package/dist/src/commands/status.js +28 -1
  32. package/dist/src/commands/test.js +28 -5
  33. package/dist/src/commands/token-coverage.js +55 -1
  34. package/dist/src/commands/token.js +19 -2
  35. package/dist/src/commands/wire.js +22 -2
  36. package/dist/src/core/branding.d.ts +10 -0
  37. package/dist/src/core/branding.js +7 -9
  38. package/dist/src/core/build-prepare.js +8 -1
  39. package/dist/src/core/file-lock.js +49 -15
  40. package/dist/src/core/furnace-operation.d.ts +17 -0
  41. package/dist/src/core/furnace-operation.js +30 -1
  42. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  43. package/dist/src/core/furnace-validate-helpers.js +53 -2
  44. package/dist/src/core/git.js +39 -10
  45. package/dist/src/core/mach-error-hints.js +16 -0
  46. package/dist/src/core/mach.js +15 -6
  47. package/dist/src/core/manifest-rules.js +16 -0
  48. package/dist/src/core/marionette-preflight.js +43 -12
  49. package/dist/src/core/patch-files.d.ts +12 -1
  50. package/dist/src/core/patch-files.js +14 -11
  51. package/dist/src/core/patch-lint.js +62 -11
  52. package/dist/src/core/wire-destroy.js +18 -5
  53. package/dist/src/core/wire-init.js +20 -5
  54. package/dist/src/core/wire-utils.d.ts +15 -0
  55. package/dist/src/core/wire-utils.js +17 -0
  56. package/dist/src/types/commands/options.d.ts +7 -0
  57. package/package.json +1 -1
@@ -106,7 +106,18 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
106
106
  }
107
107
  /** Registers token management commands on the CLI program. */
108
108
  export function registerToken(program, { getProjectRoot, withErrorHandling }) {
109
- const token = program.command('token').description('Design token management');
109
+ const token = program
110
+ .command('token')
111
+ .description('Design token management')
112
+ // Match `fireforge furnace`'s no-args contract: print the group's help and
113
+ // exit 0. Without this default action, commander routes `fireforge token`
114
+ // (no subcommand) through its own help-then-exit-1 path, so scripts that
115
+ // probe the CLI surface see a misleading non-zero exit for a purely
116
+ // informational invocation. The action prints the exact same help commander
117
+ // would otherwise print, but returns successfully.
118
+ .action(() => {
119
+ token.outputHelp();
120
+ });
110
121
  token
111
122
  .command('add <token-name> <value>')
112
123
  .description('Add a design token to CSS and documentation')
@@ -117,7 +128,13 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
117
128
  // valid choices up-front. The runtime check in tokenAddCommand remains
118
129
  // as a defence-in-depth guard for programmatic callers that bypass
119
130
  // Commander's argument parsing.
120
- new Option('--mode <mode>', 'Dark mode behavior')
131
+ // Description ends with `(required)` because Commander's
132
+ // `makeOptionMandatory` does not render a required marker in `--help`
133
+ // output — only `.requiredOption` does that, and switching to
134
+ // `.requiredOption` would lose the `.choices()` enforcement. The
135
+ // explicit suffix keeps the runtime validation AND surfaces required
136
+ // status in help alongside the other options that use `.requiredOption`.
137
+ new Option('--mode <mode>', 'Dark mode behavior (required)')
121
138
  .choices(['auto', 'static', 'override'])
122
139
  .makeOptionMandatory(true))
123
140
  .option('--description <desc>', 'Comment description for the CSS file')
@@ -5,6 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { furnaceConfigExists as checkFurnaceConfigExists, loadFurnaceConfig, } from '../core/furnace-config.js';
6
6
  import { consumeParserFallbackEvents } from '../core/parser-fallback.js';
7
7
  import { DEFAULT_DOM_TARGET } from '../core/wire-dom-fragment.js';
8
+ import { coerceToCall, validateWireName as validateWireExpression } from '../core/wire-utils.js';
8
9
  import { InvalidArgumentError } from '../errors/base.js';
9
10
  import { toError } from '../utils/errors.js';
10
11
  import { pathExists } from '../utils/fs.js';
@@ -17,10 +18,14 @@ function printWireDryRun(engineDir, name, subscriptDir, domFilePath, domTargetPa
17
18
  info(` source: ${subscriptDir}/${name}.js`);
18
19
  info(` browser-main.js: loadSubScript("chrome://browser/content/${name}.js")`);
19
20
  if (options.init) {
20
- info(` browser-init.js: ${options.init}`);
21
+ // Show the coerced form so the preview matches the emitted block.
22
+ // Before 0.16.0 the preview echoed the raw input ("EvalStartup.init"),
23
+ // which did not reflect that the real run writes `EvalStartup.init();`
24
+ // to browser-init.js.
25
+ info(` browser-init.js: ${coerceToCall(options.init)}`);
21
26
  }
22
27
  if (options.destroy) {
23
- info(` browser-init.js onUnload(): ${options.destroy}`);
28
+ info(` browser-init.js onUnload(): ${coerceToCall(options.destroy)}`);
24
29
  }
25
30
  if (domFilePath) {
26
31
  const includePath = relative(join(engineDir, subscriptDir), join(engineDir, domFilePath)).replace(/\\/g, '/');
@@ -95,6 +100,21 @@ export async function wireCommand(projectRoot, name, options = {}) {
95
100
  // --after and have it forwarded unchanged to the lookup layer.
96
101
  validateWireName(options.after);
97
102
  }
103
+ // Validate init/destroy expressions BEFORE the dry-run/real fork so
104
+ // both paths enforce the same contract. Pre-0.16.0, validation only
105
+ // ran inside `addInitToBrowserInit`/`addDestroyToBrowserInit` (the
106
+ // real-execution path), so `--dry-run --init 'void 0'` succeeded and
107
+ // rendered a plausible-looking preview even though the real run would
108
+ // reject the same arguments. Dropping `void 0` into the template
109
+ // silently (or breaking out of the string literal) was already
110
+ // prevented downstream — this hoist just makes the failure surface
111
+ // identical in preview mode.
112
+ if (options.init !== undefined) {
113
+ validateWireExpression(options.init, 'init expression');
114
+ }
115
+ if (options.destroy !== undefined) {
116
+ validateWireExpression(options.destroy, 'destroy expression');
117
+ }
98
118
  consumeParserFallbackEvents();
99
119
  // Resolve subscript directory: CLI flag > fireforge.json > default
100
120
  let subscriptDir = DEFAULT_BROWSER_SUBSCRIPT_DIR;
@@ -1,4 +1,5 @@
1
1
  import { FireForgeError } from '../errors/base.js';
2
+ import type { ProjectLicense } from '../types/config.js';
2
3
  /**
3
4
  * Error thrown when branding operations fail.
4
5
  */
@@ -41,6 +42,15 @@ export interface BrandingConfig {
41
42
  appId: string;
42
43
  /** Binary/branding directory name (e.g., "mybrowser") */
43
44
  binaryName: string;
45
+ /**
46
+ * Project license (from fireforge.json). Used to stamp the generated
47
+ * `configure.sh`, `brand.properties`, and `brand.ftl` files with the
48
+ * matching header so `patch-lint` does not flag them for
49
+ * `missing-license-header` when the project is not MPL-2.0. Optional for
50
+ * backwards compatibility with pre-0.16 callers that did not thread the
51
+ * license through — falls back to {@link DEFAULT_LICENSE}.
52
+ */
53
+ license?: ProjectLicense;
44
54
  }
45
55
  /**
46
56
  * Sets up the custom branding directory for the browser.
@@ -4,6 +4,7 @@ import { FireForgeError } from '../errors/base.js';
4
4
  import { ExitCode } from '../errors/codes.js';
5
5
  import { copyDir, pathExists, readText, writeTextIfChanged } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
+ import { DEFAULT_LICENSE, getLicenseHeader } from './license-headers.js';
7
8
  /**
8
9
  * Error thrown when branding operations fail.
9
10
  */
@@ -91,9 +92,8 @@ async function createConfigureScript(brandingDir, config) {
91
92
  await writeTextIfChanged(configureShPath, buildConfigureScriptContent(config));
92
93
  }
93
94
  function buildConfigureScriptContent(config) {
94
- return `# This Source Code Form is subject to the terms of the Mozilla Public
95
- # License, v. 2.0. If a copy of the MPL was not distributed with this
96
- # file, You can obtain one at http://mozilla.org/MPL/2.0/.
95
+ const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
96
+ return `${header}
97
97
 
98
98
  MOZ_APP_DISPLAYNAME="${escapeShellValue(config.name)}"
99
99
  MOZ_MACBUNDLE_ID="${escapeShellValue(config.appId)}"
@@ -111,9 +111,8 @@ async function updateBrandProperties(brandingDir, config) {
111
111
  await writeTextIfChanged(propsPath, buildBrandPropertiesContent(config));
112
112
  }
113
113
  function buildBrandPropertiesContent(config) {
114
- return `# This Source Code Form is subject to the terms of the Mozilla Public
115
- # License, v. 2.0. If a copy of the MPL was not distributed with this
116
- # file, You can obtain one at http://mozilla.org/MPL/2.0/.
114
+ const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
115
+ return `${header}
117
116
 
118
117
  brandShorterName=${escapePropertiesValue(config.name)}
119
118
  brandShortName=${escapePropertiesValue(config.name)}
@@ -132,9 +131,8 @@ async function updateBrandFtl(brandingDir, config) {
132
131
  await writeTextIfChanged(ftlPath, buildBrandFtlContent(config));
133
132
  }
134
133
  function buildBrandFtlContent(config) {
135
- return `# This Source Code Form is subject to the terms of the Mozilla Public
136
- # License, v. 2.0. If a copy of the MPL was not distributed with this
137
- # file, You can obtain one at http://mozilla.org/MPL/2.0/.
134
+ const header = getLicenseHeader(config.license ?? DEFAULT_LICENSE, 'hash');
135
+ return `${header}
138
136
 
139
137
  ## Brand names
140
138
  ##
@@ -123,12 +123,19 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
123
123
  }
124
124
  // Clean stories before build to ensure they don't leak into production binary
125
125
  await cleanStories(paths.engine);
126
- // Set up custom branding directory and patch moz.configure
126
+ // Set up custom branding directory and patch moz.configure. Thread the
127
+ // project license through so `buildConfigureScriptContent` /
128
+ // `buildBrandPropertiesContent` / `buildBrandFtlContent` stamp the
129
+ // generated files with a matching SPDX header — otherwise `patch-lint`
130
+ // flags them with `missing-license-header` on every subsequent export
131
+ // when the project is not MPL-2.0 (the eval finding: a 0BSD-licensed
132
+ // fork's first export failed `lint` on its own generated branding).
127
133
  const brandingConfig = {
128
134
  name: config.name,
129
135
  vendor: config.vendor,
130
136
  appId: config.appId,
131
137
  binaryName: config.binaryName,
138
+ ...(config.license !== undefined ? { license: config.license } : {}),
132
139
  };
133
140
  if (!(await isBrandingSetup(paths.engine, brandingConfig))) {
134
141
  const brandingSpinner = spinner('Setting up branding...');
@@ -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.
@@ -57,6 +57,22 @@ export const MACH_ERROR_HINTS = [
57
57
  'remove any `pub type basic_string___self_view = …<_CharT>;` line from ' +
58
58
  '`<objdir>/release/build/gecko-profiler-*/out/gecko/bindings.rs`.',
59
59
  },
60
+ {
61
+ // When `mach build` fails mid-compile, mach's own shutdown pipeline still
62
+ // runs its trailing "Config object not found by mach. / Configure
63
+ // complete! / Be sure to run |mach build|..." summary on the way out.
64
+ // Those three lines are plain upstream mach output, printed AFTER the
65
+ // non-zero exit code has already been established, and they look
66
+ // deceptively like a success banner — the eval's Darwin 25 log had
67
+ // operators double-checking whether `make` had actually failed. We do
68
+ // not own those lines, but we can give the operator a specific nudge
69
+ // that they are cosmetic post-failure output rather than a mixed
70
+ // success/failure signal.
71
+ pattern: /Config object not found by mach\.[\s\S]*?Configure complete!/,
72
+ hint: 'Ignore the trailing "Config object not found by mach. / Configure complete!" block — ' +
73
+ "that is mach's post-failure configure summary printed after the build already failed, " +
74
+ 'not a sign the build succeeded. The real failure is the error above this block.',
75
+ },
60
76
  ];
61
77
  /**
62
78
  * Scans captured stderr for known mach errors and returns matching hints.
@@ -111,13 +111,22 @@ export async function bootstrapWithOutput(engineDir) {
111
111
  return runMachInheritCapture(['bootstrap', '--application-choice', 'browser'], engineDir);
112
112
  }
113
113
  /**
114
- * Prints any matched {@link MachErrorHint} hints for the captured stderr.
114
+ * Prints any matched {@link MachErrorHint} hints for the captured mach output.
115
115
  * No-op when nothing matches. Always called before a non-zero exit propagates
116
116
  * so the hint sits immediately below the raw mach error in the operator's
117
117
  * terminal.
118
- */
119
- function surfaceMachErrorHints(stderr) {
120
- const hints = explainMachError(stderr);
118
+ *
119
+ * The scanner is passed the concatenation of stderr AND stdout because mach
120
+ * streams its subcommand output through a timestamp-prefixing wrapper that
121
+ * writes both streams to whatever FD the subprocess chose — in practice,
122
+ * `rustc` errors from `mach build` can land on stdout rather than stderr,
123
+ * and the eval run's Darwin 25 `_CharT` hint pattern matched the captured
124
+ * text but our pre-0.16 code only fed `result.stderr` into the scanner, so
125
+ * the hint never fired.
126
+ */
127
+ function surfaceMachErrorHints(result) {
128
+ const combined = `${result.stderr}\n${result.stdout}`;
129
+ const hints = explainMachError(combined);
121
130
  if (hints.length === 0)
122
131
  return;
123
132
  for (const hint of hints) {
@@ -139,7 +148,7 @@ export async function build(engineDir, jobs) {
139
148
  }
140
149
  const result = await runMachInheritCapture(args, engineDir);
141
150
  if (result.exitCode !== 0) {
142
- surfaceMachErrorHints(result.stderr);
151
+ surfaceMachErrorHints(result);
143
152
  }
144
153
  return result.exitCode;
145
154
  }
@@ -152,7 +161,7 @@ export async function build(engineDir, jobs) {
152
161
  export async function buildUI(engineDir) {
153
162
  const result = await runMachInheritCapture(['build', 'faster'], engineDir);
154
163
  if (result.exitCode !== 0) {
155
- surfaceMachErrorHints(result.stderr);
164
+ surfaceMachErrorHints(result);
156
165
  }
157
166
  return result.exitCode;
158
167
  }
@@ -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];