@hominis/fireforge 0.15.9 → 0.16.1

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 (59) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +6 -2
  3. package/dist/src/cli.d.ts +4 -1
  4. package/dist/src/cli.js +6 -3
  5. package/dist/src/commands/config.js +16 -5
  6. package/dist/src/commands/download.js +31 -4
  7. package/dist/src/commands/export-all.js +96 -9
  8. package/dist/src/commands/export.js +10 -1
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +11 -1
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +12 -2
  11. package/dist/src/commands/furnace/create.js +21 -3
  12. package/dist/src/commands/furnace/diff.js +22 -2
  13. package/dist/src/commands/furnace/index.js +1 -0
  14. package/dist/src/commands/furnace/init.js +76 -2
  15. package/dist/src/commands/furnace/override.js +35 -12
  16. package/dist/src/commands/furnace/preview.js +46 -1
  17. package/dist/src/commands/furnace/rename.js +14 -3
  18. package/dist/src/commands/lint.js +26 -2
  19. package/dist/src/commands/package.js +16 -5
  20. package/dist/src/commands/re-export.js +25 -0
  21. package/dist/src/commands/rebase/patch-loop.js +19 -0
  22. package/dist/src/commands/register.js +2 -18
  23. package/dist/src/commands/run.js +23 -2
  24. package/dist/src/commands/status.js +42 -8
  25. package/dist/src/commands/test.js +6 -24
  26. package/dist/src/commands/token.js +14 -1
  27. package/dist/src/commands/watch.js +14 -2
  28. package/dist/src/commands/wire.js +35 -9
  29. package/dist/src/core/branding.d.ts +23 -0
  30. package/dist/src/core/branding.js +39 -0
  31. package/dist/src/core/browser-wire.js +68 -23
  32. package/dist/src/core/build-baseline.d.ts +14 -0
  33. package/dist/src/core/build-baseline.js +61 -1
  34. package/dist/src/core/config-mutate.d.ts +1 -1
  35. package/dist/src/core/config.d.ts +17 -0
  36. package/dist/src/core/config.js +35 -0
  37. package/dist/src/core/firefox.d.ts +16 -2
  38. package/dist/src/core/firefox.js +7 -2
  39. package/dist/src/core/furnace-config.d.ts +23 -0
  40. package/dist/src/core/furnace-config.js +38 -0
  41. package/dist/src/core/mach-build-artifacts.d.ts +41 -0
  42. package/dist/src/core/mach-build-artifacts.js +70 -0
  43. package/dist/src/core/mach-error-hints.js +38 -0
  44. package/dist/src/core/mach-mozconfig.d.ts +25 -0
  45. package/dist/src/core/mach-mozconfig.js +66 -0
  46. package/dist/src/core/mach.d.ts +12 -1
  47. package/dist/src/core/mach.js +14 -1
  48. package/dist/src/core/manifest-rules.js +22 -1
  49. package/dist/src/core/patch-lint.js +43 -20
  50. package/dist/src/core/test-stale-check.js +46 -1
  51. package/dist/src/core/token-manager.js +57 -4
  52. package/dist/src/core/token-scaffold.d.ts +36 -0
  53. package/dist/src/core/token-scaffold.js +74 -0
  54. package/dist/src/types/commands/options.d.ts +10 -0
  55. package/dist/src/utils/fs.d.ts +12 -0
  56. package/dist/src/utils/fs.js +12 -0
  57. package/dist/src/utils/paths.d.ts +19 -0
  58. package/dist/src/utils/paths.js +33 -0
  59. package/package.json +1 -1
@@ -23,12 +23,26 @@ export declare function getDownloadUrl(version: string, product?: FirefoxProduct
23
23
  * @returns Tarball filename
24
24
  */
25
25
  export declare function getTarballFilename(version: string, product?: FirefoxProduct): string;
26
+ /**
27
+ * Lifecycle phase reported by {@link downloadFirefoxSource}. The download
28
+ * CLI command uses this to swap spinners between the bytes-on-the-wire
29
+ * phase and the silent tar-xz decompression phase that follows — before
30
+ * this, a single spinner stuck at "Downloading Firefox … 100%" covered
31
+ * both phases, making the first-run setup look hung precisely when the
32
+ * archive was already on disk and `tar` was the long pole.
33
+ */
34
+ export type FirefoxSourcePhase = 'download' | 'extract';
35
+ /** Callback fired at phase transitions during {@link downloadFirefoxSource}. */
36
+ export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
26
37
  /**
27
38
  * Downloads and extracts Firefox source.
28
39
  * @param version - Firefox version to download
29
40
  * @param product - Firefox product type
30
41
  * @param destDir - Destination directory for extracted source
31
42
  * @param cacheDir - Directory to store downloaded tarball
32
- * @param onProgress - Optional progress callback
43
+ * @param onProgress - Optional progress callback for the download byte stream
44
+ * @param onPhase - Optional callback fired when the function transitions
45
+ * between phases (`'download'` → `'extract'`). Fires exactly once per
46
+ * phase even if the cached archive path skips the wire entirely.
33
47
  */
34
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback): Promise<void>;
48
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback): Promise<void>;
@@ -39,16 +39,21 @@ export function getTarballFilename(version, product = 'firefox') {
39
39
  * @param product - Firefox product type
40
40
  * @param destDir - Destination directory for extracted source
41
41
  * @param cacheDir - Directory to store downloaded tarball
42
- * @param onProgress - Optional progress callback
42
+ * @param onProgress - Optional progress callback for the download byte stream
43
+ * @param onPhase - Optional callback fired when the function transitions
44
+ * between phases (`'download'` → `'extract'`). Fires exactly once per
45
+ * phase even if the cached archive path skips the wire entirely.
43
46
  */
44
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress) {
47
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase) {
45
48
  const archive = resolveArchive(version, product);
46
49
  const tarballPath = join(cacheDir, archive.filename);
47
50
  // Ensure cache directory exists
48
51
  await ensureDir(cacheDir);
52
+ onPhase?.('download');
49
53
  await ensureCachedArchive(archive, cacheDir, onProgress);
50
54
  // Extract to a unique temporary directory so concurrent downloads for
51
55
  // the same destination do not clobber each other.
56
+ onPhase?.('extract');
52
57
  const tempDir = `${destDir}.tmp-${randomUUID()}`;
53
58
  try {
54
59
  await extractTarXz(tarballPath, tempDir);
@@ -86,6 +86,29 @@ export declare function loadFurnaceConfig(root: string): Promise<FurnaceConfig>;
86
86
  * @param config - Configuration to write
87
87
  */
88
88
  export declare function writeFurnaceConfig(root: string, config: FurnaceConfig): Promise<void>;
89
+ /**
90
+ * Stamps every override's `baseVersion` to the supplied version. Used by
91
+ * `fireforge rebase` after a successful patch re-export so a successful
92
+ * ESR bump does not leave Furnace overrides in a doctor-failing drift
93
+ * state. Returns the number of overrides stamped (zero if furnace.json
94
+ * has no overrides, or if the file is missing).
95
+ *
96
+ * Motivating case: a 140.9.0esr → 140.9.1esr rebase stamps patch
97
+ * `sourceEsrVersion` via `stampPatchVersions`, but before 0.16.0 no
98
+ * equivalent stamping ran for Furnace override `baseVersion`. `doctor`
99
+ * then immediately failed Furnace component validation on every
100
+ * override. The stamp is deliberately unconditional — `fireforge
101
+ * furnace validate` is the right tool for "does this override still
102
+ * apply", and rebase already attested that the patch layer re-validated
103
+ * against the new ESR; the per-override health check belongs in a
104
+ * separate pass, not inline with the stamp.
105
+ *
106
+ * @param root - Root directory of the project
107
+ * @param version - Firefox version string to stamp onto every override
108
+ * @returns Number of overrides whose `baseVersion` was updated (either
109
+ * because it was missing or because it differed from `version`).
110
+ */
111
+ export declare function stampFurnaceOverrideBaseVersions(root: string, version: string): Promise<number>;
89
112
  /**
90
113
  * Creates a default furnace configuration.
91
114
  * @returns A valid empty FurnaceConfig
@@ -420,6 +420,44 @@ export async function writeFurnaceConfig(root, config) {
420
420
  const paths = getFurnacePaths(root);
421
421
  await writeJson(paths.furnaceConfig, config);
422
422
  }
423
+ /**
424
+ * Stamps every override's `baseVersion` to the supplied version. Used by
425
+ * `fireforge rebase` after a successful patch re-export so a successful
426
+ * ESR bump does not leave Furnace overrides in a doctor-failing drift
427
+ * state. Returns the number of overrides stamped (zero if furnace.json
428
+ * has no overrides, or if the file is missing).
429
+ *
430
+ * Motivating case: a 140.9.0esr → 140.9.1esr rebase stamps patch
431
+ * `sourceEsrVersion` via `stampPatchVersions`, but before 0.16.0 no
432
+ * equivalent stamping ran for Furnace override `baseVersion`. `doctor`
433
+ * then immediately failed Furnace component validation on every
434
+ * override. The stamp is deliberately unconditional — `fireforge
435
+ * furnace validate` is the right tool for "does this override still
436
+ * apply", and rebase already attested that the patch layer re-validated
437
+ * against the new ESR; the per-override health check belongs in a
438
+ * separate pass, not inline with the stamp.
439
+ *
440
+ * @param root - Root directory of the project
441
+ * @param version - Firefox version string to stamp onto every override
442
+ * @returns Number of overrides whose `baseVersion` was updated (either
443
+ * because it was missing or because it differed from `version`).
444
+ */
445
+ export async function stampFurnaceOverrideBaseVersions(root, version) {
446
+ if (!(await furnaceConfigExists(root)))
447
+ return 0;
448
+ const config = await loadFurnaceConfig(root);
449
+ let changed = 0;
450
+ for (const override of Object.values(config.overrides)) {
451
+ if (override.baseVersion !== version) {
452
+ override.baseVersion = version;
453
+ changed++;
454
+ }
455
+ }
456
+ if (changed > 0) {
457
+ await writeFurnaceConfig(root, config);
458
+ }
459
+ return changed;
460
+ }
423
461
  /**
424
462
  * Creates a default furnace configuration.
425
463
  * @returns A valid empty FurnaceConfig
@@ -25,6 +25,47 @@ export interface BuildArtifactCheck {
25
25
  * @returns Build artifact check result
26
26
  */
27
27
  export declare function hasBuildArtifacts(engineDir: string): Promise<BuildArtifactCheck>;
28
+ /**
29
+ * Outcome of the `hasRunnableBundle` probe. Distinguishes "no objdir at
30
+ * all" from "objdir exists but the launchable binary is not yet written"
31
+ * so callers (notably `fireforge run`) can give the operator a specific
32
+ * message instead of the generic build-artifacts-missing line.
33
+ */
34
+ export interface RunnableBundleCheck {
35
+ /** True when an objdir is present AND the expected binary was found under it. */
36
+ runnable: boolean;
37
+ /** Repo-relative (engine-rooted) path we probed; populated even on failure for error copy. */
38
+ expectedPath?: string;
39
+ }
40
+ /**
41
+ * Checks whether the built browser's launchable binary exists under
42
+ * `<engineDir>/<objDir>/dist/...`. `hasBuildArtifacts` only confirms that
43
+ * an obj tree with a `dist/` subdir exists; a partial or in-progress build
44
+ * can satisfy that check without ever writing the executable, which is the
45
+ * failure mode that makes `fireforge run` throw `mach run` after having
46
+ * reported the build as usable. Separating the probes lets `run` fail fast
47
+ * with a precise message and `watch` stay permissive (it exists to drive
48
+ * rebuilds of incomplete trees) while still reporting the bundle state in
49
+ * its startup banner.
50
+ *
51
+ * Platform layout:
52
+ * - macOS: `<objDir>/dist/*.app/Contents/MacOS/<binaryName>` (the `.app`
53
+ * display casing can differ from `binaryName` — e.g. `Hominis.app` for
54
+ * binary `hominis`, so we enumerate the `*.app` bundles rather than
55
+ * compute the name.
56
+ * - Linux: `<objDir>/dist/bin/<binaryName>`.
57
+ * - Windows: `<objDir>/dist/bin/<binaryName>.exe`.
58
+ *
59
+ * Returns `runnable: false` with no `expectedPath` when the `objDir`
60
+ * itself cannot be scanned — same degraded contract as `hasBuildArtifacts`.
61
+ *
62
+ * @param engineDir Path to the engine directory
63
+ * @param binaryName Lowercase binary name from `fireforge.json`
64
+ * @param objDir The single matching `obj-*` directory name (caller
65
+ * resolves it; typically from `hasBuildArtifacts().objDir`)
66
+ * @returns Structured check result
67
+ */
68
+ export declare function hasRunnableBundle(engineDir: string, binaryName: string, objDir: string): Promise<RunnableBundleCheck>;
28
69
  /** Builds a user-facing explanation when detected build artifacts belong to another workspace. */
29
70
  export declare function buildArtifactMismatchMessage(engineDir: string, buildCheck: BuildArtifactCheck, commandName: string): string | undefined;
30
71
  /**
@@ -4,6 +4,7 @@ import { join, relative, resolve, sep } from 'node:path';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists, readJson, writeJson } from '../utils/fs.js';
6
6
  import { verbose } from '../utils/logger.js';
7
+ import { getPlatform } from '../utils/platform.js';
7
8
  import { isObject, isString } from '../utils/validation.js';
8
9
  function validateBuildMozinfo(data) {
9
10
  if (!isObject(data)) {
@@ -94,6 +95,75 @@ export async function hasBuildArtifacts(engineDir) {
94
95
  return { exists: false };
95
96
  }
96
97
  }
98
+ /**
99
+ * Checks whether the built browser's launchable binary exists under
100
+ * `<engineDir>/<objDir>/dist/...`. `hasBuildArtifacts` only confirms that
101
+ * an obj tree with a `dist/` subdir exists; a partial or in-progress build
102
+ * can satisfy that check without ever writing the executable, which is the
103
+ * failure mode that makes `fireforge run` throw `mach run` after having
104
+ * reported the build as usable. Separating the probes lets `run` fail fast
105
+ * with a precise message and `watch` stay permissive (it exists to drive
106
+ * rebuilds of incomplete trees) while still reporting the bundle state in
107
+ * its startup banner.
108
+ *
109
+ * Platform layout:
110
+ * - macOS: `<objDir>/dist/*.app/Contents/MacOS/<binaryName>` (the `.app`
111
+ * display casing can differ from `binaryName` — e.g. `Hominis.app` for
112
+ * binary `hominis`, so we enumerate the `*.app` bundles rather than
113
+ * compute the name.
114
+ * - Linux: `<objDir>/dist/bin/<binaryName>`.
115
+ * - Windows: `<objDir>/dist/bin/<binaryName>.exe`.
116
+ *
117
+ * Returns `runnable: false` with no `expectedPath` when the `objDir`
118
+ * itself cannot be scanned — same degraded contract as `hasBuildArtifacts`.
119
+ *
120
+ * @param engineDir Path to the engine directory
121
+ * @param binaryName Lowercase binary name from `fireforge.json`
122
+ * @param objDir The single matching `obj-*` directory name (caller
123
+ * resolves it; typically from `hasBuildArtifacts().objDir`)
124
+ * @returns Structured check result
125
+ */
126
+ export async function hasRunnableBundle(engineDir, binaryName, objDir) {
127
+ const platform = getPlatform();
128
+ const distDir = join(engineDir, objDir, 'dist');
129
+ if (!(await pathExists(distDir))) {
130
+ return { runnable: false };
131
+ }
132
+ if (platform === 'darwin') {
133
+ let entries;
134
+ try {
135
+ entries = await readdir(distDir, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return { runnable: false };
139
+ }
140
+ for (const entry of entries) {
141
+ if (!entry.isDirectory())
142
+ continue;
143
+ if (!entry.name.endsWith('.app'))
144
+ continue;
145
+ const candidate = join(distDir, entry.name, 'Contents', 'MacOS', binaryName);
146
+ if (await pathExists(candidate)) {
147
+ return { runnable: true, expectedPath: relative(engineDir, candidate) };
148
+ }
149
+ }
150
+ // Report an expected-but-missing path rooted at the first .app bundle we
151
+ // can see, or a synthetic path when no bundle exists yet, so the error
152
+ // message names something the operator can look for on disk.
153
+ const firstApp = entries.find((e) => e.isDirectory() && e.name.endsWith('.app'));
154
+ const expected = firstApp
155
+ ? relative(engineDir, join(distDir, firstApp.name, 'Contents', 'MacOS', binaryName))
156
+ : relative(engineDir, join(distDir, `<AppName>.app/Contents/MacOS/${binaryName}`));
157
+ return { runnable: false, expectedPath: expected };
158
+ }
159
+ const binaryFile = platform === 'win32' ? `${binaryName}.exe` : binaryName;
160
+ const candidate = join(distDir, 'bin', binaryFile);
161
+ const expectedPath = relative(engineDir, candidate);
162
+ if (await pathExists(candidate)) {
163
+ return { runnable: true, expectedPath };
164
+ }
165
+ return { runnable: false, expectedPath };
166
+ }
97
167
  /** Builds a user-facing explanation when detected build artifacts belong to another workspace. */
98
168
  export function buildArtifactMismatchMessage(engineDir, buildCheck, commandName) {
99
169
  if (!buildCheck.metadataMismatch || !buildCheck.objDir) {
@@ -19,6 +19,44 @@ export const MACH_ERROR_HINTS = [
19
19
  hint: 'A file registered under JS_PREFERENCE_PP_FILES contains no preprocessor directives. ' +
20
20
  'Use JS_PREFERENCE_FILES instead, or add at least one #filter / #expand directive to the file.',
21
21
  },
22
+ {
23
+ // `mach package` inside `packager.py` dereferences a `None` sink when
24
+ // the packaging input set cannot resolve an entry it expected — the
25
+ // most common real-world cause is running `fireforge package` before
26
+ // a full `fireforge build` has finished, so `obj-*/dist/` is missing
27
+ // pieces the packager assumes exist. The hint points at that root
28
+ // cause specifically; the broader "build failed" path has already
29
+ // surfaced the raw traceback above this hint.
30
+ pattern: /packager\.py[\s\S]*?AttributeError: 'NoneType' object has no attribute 'open'|AttributeError: 'NoneType' object has no attribute 'open'[\s\S]*?packager\.py/,
31
+ hint: '`mach package` tripped a `NoneType.open` inside `packager.py`. This is almost always a ' +
32
+ 'symptom of the packager being handed an incomplete `obj-*/dist/` tree — e.g. running ' +
33
+ '"fireforge package" before a full "fireforge build" (not --ui) completed, or packaging ' +
34
+ 'after a build that failed late. Re-run "fireforge build" to completion, confirm the app ' +
35
+ 'bundle exists under `obj-*/dist/`, and rerun "fireforge package".',
36
+ },
37
+ {
38
+ // Upstream bindgen on some macOS libc++ SDK versions emits
39
+ // `pub type basic_string___self_view = root::std::__1::basic_string_view<_CharT>;`
40
+ // inside gecko-profiler's generated `bindings.rs`, but `_CharT` is
41
+ // not in scope where the alias lands — so the Rust compile fails
42
+ // with "cannot find type `_CharT`". The symptom is obscure and the
43
+ // fix is external: Hominis ships
44
+ // `990-infra-bindgen-basic-string-workaround.patch` in its patch
45
+ // queue, which strips the offending alias line post-generation.
46
+ // This hint surfaces the workaround pointer alongside the raw
47
+ // bindgen output so operators don't have to reverse-engineer the
48
+ // failure.
49
+ pattern: /cannot find type `_CharT` in this scope[\s\S]*?gecko-profiler-|gecko-profiler-[\s\S]*?cannot find type `_CharT` in this scope/,
50
+ hint: 'The Rust compile failed on a bindgen-generated `basic_string___self_view` alias in ' +
51
+ 'gecko-profiler/bindings.rs. This is an upstream bindgen output bug against some ' +
52
+ 'macOS libc++ SDK versions and needs a post-generation patch to strip the alias. ' +
53
+ 'The known-working workaround is the `990-infra-bindgen-basic-string-workaround.patch` ' +
54
+ "Hominis ships in its patch queue — import the equivalent into your fork's patches/, " +
55
+ 'then re-run "fireforge import" + "fireforge build". If you do not use Hominis\' queue, ' +
56
+ 'apply the following post-process to the generated file before the Rust compile: ' +
57
+ 'remove any `pub type basic_string___self_view = …<_CharT>;` line from ' +
58
+ '`<objdir>/release/build/gecko-profiler-*/out/gecko/bindings.rs`.',
59
+ },
22
60
  ];
23
61
  /**
24
62
  * Scans captured stderr for known mach errors and returns matching hints.
@@ -8,6 +8,31 @@ export interface MozconfigVariables {
8
8
  appId: string;
9
9
  binaryName: string;
10
10
  }
11
+ /**
12
+ * Extracts the `--with-branding=<path>` value from a rendered mozconfig
13
+ * body. Returns `undefined` when no directive is present — callers treat
14
+ * that as "mozconfig is missing branding", which is itself an actionable
15
+ * configuration error.
16
+ *
17
+ * Exported for testing.
18
+ */
19
+ export declare function extractWithBrandingPath(mozconfigContent: string): string | undefined;
20
+ /**
21
+ * Preflights the just-written mozconfig against the branding tree FireForge
22
+ * set up. A drift between the two is silent-corruption territory — the
23
+ * build runs, `mach configure` reads the stale directory name out of
24
+ * mozconfig, and then the recursive make backend errors out with a "path
25
+ * does not exist" message that names the branding dir the mozconfig
26
+ * referenced. By parsing the mozconfig here and comparing to
27
+ * `config.binaryName`, we turn that into a single-line actionable error
28
+ * before `mach` runs.
29
+ *
30
+ * @param engineDir Path to the engine directory (the branding tree lives here)
31
+ * @param mozconfigPath Path to the mozconfig just written
32
+ * @param config FireForge configuration (reads `binaryName`)
33
+ * @throws BrandingMozconfigMismatchError on drift or missing directive
34
+ */
35
+ export declare function assertBrandingMozconfigAgreement(engineDir: string, mozconfigPath: string, config: FireForgeConfig): Promise<void>;
11
36
  /**
12
37
  * Generates a mozconfig file from templates.
13
38
  * @param configsDir - Path to the configs directory
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { MozconfigError } from '../errors/build.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
5
  import { getPlatform } from '../utils/platform.js';
6
+ import { BrandingMozconfigMismatchError } from './branding.js';
6
7
  /**
7
8
  * Replaces template variables in a string.
8
9
  * @param content - Content with ${variable} placeholders
@@ -16,6 +17,66 @@ function replaceVariables(content, variables) {
16
17
  .replace(/\$\{appId\}/g, variables.appId)
17
18
  .replace(/\$\{binaryName\}/g, variables.binaryName);
18
19
  }
20
+ /**
21
+ * Matches an `--with-branding=<path>` directive anywhere in a rendered
22
+ * mozconfig. The directive form is the one mach reads; an optional
23
+ * `ac_add_options` prefix is the on-disk convention. `m` flag anchors the
24
+ * search per-line so a multi-line mozconfig with older directives earlier
25
+ * in the file doesn't confuse the extractor. We pick the LAST match
26
+ * because mach itself takes the last-write-wins semantics of shell
27
+ * configuration for overlapping `ac_add_options` calls.
28
+ */
29
+ const WITH_BRANDING_PATTERN = /^\s*(?:ac_add_options\s+)?--with-branding\s*=\s*(\S+)/gm;
30
+ /**
31
+ * Extracts the `--with-branding=<path>` value from a rendered mozconfig
32
+ * body. Returns `undefined` when no directive is present — callers treat
33
+ * that as "mozconfig is missing branding", which is itself an actionable
34
+ * configuration error.
35
+ *
36
+ * Exported for testing.
37
+ */
38
+ export function extractWithBrandingPath(mozconfigContent) {
39
+ const matches = [...mozconfigContent.matchAll(WITH_BRANDING_PATTERN)];
40
+ const last = matches.at(-1);
41
+ return last?.[1];
42
+ }
43
+ /**
44
+ * Preflights the just-written mozconfig against the branding tree FireForge
45
+ * set up. A drift between the two is silent-corruption territory — the
46
+ * build runs, `mach configure` reads the stale directory name out of
47
+ * mozconfig, and then the recursive make backend errors out with a "path
48
+ * does not exist" message that names the branding dir the mozconfig
49
+ * referenced. By parsing the mozconfig here and comparing to
50
+ * `config.binaryName`, we turn that into a single-line actionable error
51
+ * before `mach` runs.
52
+ *
53
+ * @param engineDir Path to the engine directory (the branding tree lives here)
54
+ * @param mozconfigPath Path to the mozconfig just written
55
+ * @param config FireForge configuration (reads `binaryName`)
56
+ * @throws BrandingMozconfigMismatchError on drift or missing directive
57
+ */
58
+ export async function assertBrandingMozconfigAgreement(engineDir, mozconfigPath, config) {
59
+ const mozconfigContent = await readText(mozconfigPath);
60
+ const found = extractWithBrandingPath(mozconfigContent);
61
+ const expected = `browser/branding/${config.binaryName}`;
62
+ if (!found) {
63
+ throw new BrandingMozconfigMismatchError(expected, '(no --with-branding directive)', 'mozconfig-missing-branding');
64
+ }
65
+ // Normalise both sides to forward slashes before compare — Windows-edited
66
+ // configs can carry backslash path separators that the build would treat
67
+ // as literal characters in a repo-relative path.
68
+ const normalizedFound = found.replace(/\\/g, '/');
69
+ if (normalizedFound !== expected) {
70
+ throw new BrandingMozconfigMismatchError(expected, found, 'name-mismatch');
71
+ }
72
+ // Last line of defence: even with matching names, a missing branding tree
73
+ // means the scaffold step hasn't run. Preflight here so the operator
74
+ // doesn't pay for a configure-through-build cycle to discover it.
75
+ const brandingMozBuild = join(engineDir, expected, 'moz.build');
76
+ if (!(await pathExists(brandingMozBuild))) {
77
+ throw new BrandingMozconfigMismatchError(expected, found, 'branding-dir-missing');
78
+ }
79
+ }
19
80
  /**
20
81
  * Generates a mozconfig file from templates.
21
82
  * @param configsDir - Path to the configs directory
@@ -46,5 +107,10 @@ export async function generateMozconfig(configsDir, engineDir, config) {
46
107
  const platformContent = await readText(platformPath);
47
108
  content += `# Platform configuration (${platform})\n${replaceVariables(platformContent, variables)}`;
48
109
  await writeText(outputPath, content);
110
+ // Preflight: the mozconfig we just wrote must reference the branding
111
+ // directory FireForge actually set up. Catching the drift here (after the
112
+ // write, before anything consumes mozconfig) keeps `generateMozconfig`
113
+ // the single source of truth for both the render and the sanity-check.
114
+ await assertBrandingMozconfigAgreement(engineDir, outputPath, config);
49
115
  }
50
116
  //# sourceMappingURL=mach-mozconfig.js.map
@@ -1,5 +1,5 @@
1
1
  import { type SmokeLineCallback, type SmokeRunResult } from '../utils/process.js';
2
- export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, type MozinfoRewriteResult, } from './mach-build-artifacts.js';
2
+ export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, type MozinfoRewriteResult, type RunnableBundleCheck, } from './mach-build-artifacts.js';
3
3
  export { generateMozconfig, type MozconfigVariables } from './mach-mozconfig.js';
4
4
  export { ensurePython, resetResolvedPython } from './mach-python.js';
5
5
  /**
@@ -111,6 +111,17 @@ export declare function runMachSmoke(args: string[], engineDir: string, options:
111
111
  * @returns Exit code
112
112
  */
113
113
  export declare function machPackage(engineDir: string): Promise<number>;
114
+ /**
115
+ * Creates a distribution package while streaming output to the terminal
116
+ * and capturing the stderr tail for post-run diagnostics. Callers that
117
+ * want to consult {@link explainMachError} on failure should use this
118
+ * variant; the inherit-only `machPackage` above remains for callers that
119
+ * just need an exit code.
120
+ *
121
+ * @param engineDir - Path to the engine directory
122
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
123
+ */
124
+ export declare function machPackageCapture(engineDir: string): Promise<MachCommandResult>;
114
125
  /**
115
126
  * Runs mach watch for auto-rebuilding.
116
127
  * @param engineDir - Path to the engine directory
@@ -7,7 +7,7 @@ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from
7
7
  import { explainMachError } from './mach-error-hints.js';
8
8
  import { getPython } from './mach-python.js';
9
9
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
10
- export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, } from './mach-build-artifacts.js';
10
+ export { attemptMozinfoRewrite, buildArtifactMismatchMessage, hasBuildArtifacts, hasRunnableBundle, } from './mach-build-artifacts.js';
11
11
  export { generateMozconfig } from './mach-mozconfig.js';
12
12
  export { ensurePython, resetResolvedPython } from './mach-python.js';
13
13
  /**
@@ -197,6 +197,19 @@ export async function runMachSmoke(args, engineDir, options) {
197
197
  export async function machPackage(engineDir) {
198
198
  return runMach(['package'], engineDir, { inherit: true });
199
199
  }
200
+ /**
201
+ * Creates a distribution package while streaming output to the terminal
202
+ * and capturing the stderr tail for post-run diagnostics. Callers that
203
+ * want to consult {@link explainMachError} on failure should use this
204
+ * variant; the inherit-only `machPackage` above remains for callers that
205
+ * just need an exit code.
206
+ *
207
+ * @param engineDir - Path to the engine directory
208
+ * @returns Captured mach result (stdout tail, stderr tail, exit code)
209
+ */
210
+ export async function machPackageCapture(engineDir) {
211
+ return runMachCapture(['package'], engineDir);
212
+ }
200
213
  /**
201
214
  * Runs mach watch for auto-rebuilding.
202
215
  * @param engineDir - Path to the engine directory
@@ -16,7 +16,15 @@ export function getRules(binaryName) {
16
16
  extractArgs: (m) => [m[1] ?? ''],
17
17
  },
18
18
  {
19
- pattern: /^browser\/base\/content\/(.+\.(?:js|mjs|xhtml|css))$/,
19
+ // `.inc.xhtml` fragments under browser/base/content/ are deliberately
20
+ // excluded: they are consumed via `#include` from a registered chrome
21
+ // document (typically browser.xhtml) and do not get their own
22
+ // packaged chrome URI. Before this carve-out, `status` flagged every
23
+ // wired fragment as "potentially unregistered" and `register --dry-run`
24
+ // proposed a bogus jar.mn entry. The lookahead blocks the match so
25
+ // `getUnregistrableAdvice` gets a chance to emit the correct
26
+ // guidance for the `.inc.xhtml` case.
27
+ pattern: /^browser\/base\/content\/(?!.+\.inc\.xhtml$)(.+\.(?:js|mjs|xhtml|css))$/,
20
28
  isRegistered: (engineDir, fileName) => isBrowserContentRegistered(engineDir, fileName),
21
29
  register: (engineDir, after, dryRun, fileName) => registerBrowserContent(engineDir, fileName, after, undefined, dryRun),
22
30
  extractArgs: (m) => [m[1] ?? ''],
@@ -98,6 +106,19 @@ function getUnregistrableAdvice(filePath) {
98
106
  if (filePath.endsWith('.ftl')) {
99
107
  return "FTL locale files are auto-discovered via jar.mn glob patterns and don't need manual registration.";
100
108
  }
109
+ // `.inc.xhtml` fragments live under browser/base/content/ but are
110
+ // consumed via `#include` from a registered chrome document (browser.xhtml
111
+ // by default; a fork's custom top-level doc when `wire --dom-target` is
112
+ // set). The preprocessor resolves the include at packaging time, so the
113
+ // fragment never needs its own chrome URI entry in jar.mn. Give the
114
+ // operator the actionable `wire` path instead of letting the generic
115
+ // "unknown file pattern" message above fire.
116
+ if (/^browser\/base\/content\/.+\.inc\.xhtml$/.test(filePath)) {
117
+ return ('`.inc.xhtml` fragments are consumed via `#include` from a registered chrome document ' +
118
+ '(e.g. browser.xhtml). They do not need an independent jar.mn entry — run ' +
119
+ '"fireforge wire <name> --dom <path>" to insert the #include, or add the directive manually ' +
120
+ 'in the top-level chrome document.');
121
+ }
101
122
  const testMatch = filePath.match(/^browser\/base\/content\/test\/([^/]+)\/(?!browser\.toml$).+$/);
102
123
  if (testMatch) {
103
124
  const dir = testMatch[1];
@@ -159,6 +159,17 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
159
159
  // Check for non-tokenized custom properties. A variable that is both
160
160
  // declared and consumed inside the same file is auto-exempted as a
161
161
  // runtime state channel (see furnace.json → runtimeVariables).
162
+ //
163
+ // When diff context is available, scope the `var(...)` scan to
164
+ // added/modified lines only. `cssContent` (full-file) is still the
165
+ // source of `localDeclarations` so vars declared anywhere in the file
166
+ // are recognised as same-file refs regardless of where the consuming
167
+ // `var(...)` appears. Before this scoping change, a small edit to a
168
+ // Furnace override of a stock component (e.g. moz-card) produced a
169
+ // `token-prefix-violation` for every stock `var(--moz-card-*)` the
170
+ // upstream file already carried, because the scanner saw the full
171
+ // applied file and flagged each inherited reference as if the fork
172
+ // had introduced it.
162
173
  if (tokenPrefix) {
163
174
  const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
164
175
  const localDeclarations = new Set();
@@ -168,26 +179,38 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
168
179
  if (name)
169
180
  localDeclarations.add(name);
170
181
  }
171
- const varPattern = /var\(\s*(--[\w-]+)/g;
172
- let match;
173
- while ((match = varPattern.exec(cssContent)) !== null) {
174
- const prop = match[1];
175
- if (!prop)
176
- continue;
177
- if (prop.startsWith(tokenPrefix))
178
- continue;
179
- if (tokenAllowlist?.has(prop))
180
- continue;
181
- if (runtimeVariables?.has(prop))
182
- continue;
183
- if (localDeclarations.has(prop))
184
- continue;
185
- issues.push({
186
- file,
187
- check: 'token-prefix-violation',
188
- message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
189
- severity: 'error',
190
- });
182
+ const prefixScanSource = addedLinesByFile
183
+ ? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
184
+ : cssContent;
185
+ if (prefixScanSource.length > 0) {
186
+ const varPattern = /var\(\s*(--[\w-]+)/g;
187
+ const flaggedProps = new Set();
188
+ let match;
189
+ while ((match = varPattern.exec(prefixScanSource)) !== null) {
190
+ const prop = match[1];
191
+ if (!prop)
192
+ continue;
193
+ if (prop.startsWith(tokenPrefix))
194
+ continue;
195
+ if (tokenAllowlist?.has(prop))
196
+ continue;
197
+ if (runtimeVariables?.has(prop))
198
+ continue;
199
+ if (localDeclarations.has(prop))
200
+ continue;
201
+ // De-duplicate per (file, prop) pair so the same introduced var
202
+ // used five times in the added hunk doesn't produce five
203
+ // identical issue entries.
204
+ if (flaggedProps.has(prop))
205
+ continue;
206
+ flaggedProps.add(prop);
207
+ issues.push({
208
+ file,
209
+ check: 'token-prefix-violation',
210
+ message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
211
+ severity: 'error',
212
+ });
213
+ }
191
214
  }
192
215
  }
193
216
  }