@hominis/fireforge 0.15.2 → 0.15.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 (35) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +54 -0
  3. package/dist/src/commands/build.js +29 -2
  4. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +49 -0
  5. package/dist/src/commands/furnace/chrome-doc-templates.js +151 -0
  6. package/dist/src/commands/furnace/chrome-doc.d.ts +34 -0
  7. package/dist/src/commands/furnace/chrome-doc.js +168 -0
  8. package/dist/src/commands/furnace/create-mochikit.d.ts +30 -0
  9. package/dist/src/commands/furnace/create-mochikit.js +70 -0
  10. package/dist/src/commands/furnace/create-templates.d.ts +32 -0
  11. package/dist/src/commands/furnace/create-templates.js +69 -0
  12. package/dist/src/commands/furnace/create.d.ts +17 -0
  13. package/dist/src/commands/furnace/create.js +54 -16
  14. package/dist/src/commands/furnace/index.d.ts +2 -1
  15. package/dist/src/commands/furnace/index.js +20 -3
  16. package/dist/src/commands/lint.d.ts +13 -1
  17. package/dist/src/commands/lint.js +33 -7
  18. package/dist/src/core/build-audit.d.ts +46 -0
  19. package/dist/src/core/build-audit.js +251 -0
  20. package/dist/src/core/build-baseline.d.ts +59 -0
  21. package/dist/src/core/build-baseline.js +83 -0
  22. package/dist/src/core/build-prepare.d.ts +20 -1
  23. package/dist/src/core/build-prepare.js +89 -4
  24. package/dist/src/core/furnace-operation.d.ts +2 -1
  25. package/dist/src/core/furnace-operation.js +13 -7
  26. package/dist/src/core/mach-error-hints.d.ts +29 -0
  27. package/dist/src/core/mach-error-hints.js +43 -0
  28. package/dist/src/core/mach.d.ts +5 -2
  29. package/dist/src/core/mach.js +31 -4
  30. package/dist/src/core/patch-lint-diff-tag.d.ts +33 -0
  31. package/dist/src/core/patch-lint-diff-tag.js +83 -0
  32. package/dist/src/types/commands/options.d.ts +15 -0
  33. package/dist/src/types/commands/patches.d.ts +9 -0
  34. package/dist/src/types/furnace.d.ts +1 -1
  35. package/package.json +1 -1
@@ -0,0 +1,251 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /*
3
+ * Post-build dist-tree audit.
4
+ *
5
+ * Purpose: catch the class of bug where a file under engine/ was edited
6
+ * but never registered in moz.build, jar.mn, or package-manifest.in, so
7
+ * the mach build reports success but the packaged bundle carries stale
8
+ * or missing content. A fork-specific pref file that was never registered
9
+ * for packaging is the motivating case.
10
+ *
11
+ * The audit is best-effort and warn-only:
12
+ * - It enumerates engine files changed since the previous build baseline
13
+ * (git-tracked diff + workdir modifications).
14
+ * - For each file whose path pattern implies packaging, it resolves
15
+ * the expected dist artifact under obj-star/dist/binary-name-star.
16
+ * - A warning fires when the expected artifact is missing OR when its
17
+ * mtime is older than the engine source (the build was reported
18
+ * successful but that file's path never flowed through packaging).
19
+ * - False positives are acceptable at this stage: fork-specific packaging
20
+ * tricks FireForge doesn't know about will surface as warnings an
21
+ * operator can investigate. The audit never fails the build.
22
+ */
23
+ import { stat } from 'node:fs/promises';
24
+ import { basename, join } from 'node:path';
25
+ import { toError } from '../utils/errors.js';
26
+ import { pathExists } from '../utils/fs.js';
27
+ import { info, verbose, warn } from '../utils/logger.js';
28
+ import { hasChanges, isMissingHeadError } from './git.js';
29
+ import { git } from './git-base.js';
30
+ import { getUntrackedFiles } from './git-status.js';
31
+ /** Path extensions that are conventionally packaged into the Firefox bundle. */
32
+ const PACKAGEABLE_EXTENSIONS = [
33
+ '.js',
34
+ '.mjs',
35
+ '.jsm',
36
+ '.css',
37
+ '.ftl',
38
+ '.xhtml',
39
+ '.xul',
40
+ '.html',
41
+ '.properties',
42
+ ];
43
+ /** Path fragments whose contents are packaged regardless of extension. */
44
+ const PACKAGEABLE_PATH_FRAGMENTS = ['/app/profile/', '/chrome/', '/locales/'];
45
+ /** Directories that are build artifacts, not source — never audited. */
46
+ const IGNORE_PATH_FRAGMENTS = ['obj-', 'node_modules/', '.git/', '.cargo/', '.mozbuild/'];
47
+ /*
48
+ * Finds the first file with the given basename anywhere under the dist
49
+ * bundle. Scans the darwin Contents/Resources layout and the linux/win
50
+ * top-level layout with a depth-limited traversal so deeply-nested
51
+ * node_modules in the dist copy do not dominate the audit wall clock.
52
+ */
53
+ async function findArtifactByBasename(distRoot, name, maxDepth = 10) {
54
+ const { readdir } = await import('node:fs/promises');
55
+ const stack = [{ dir: distRoot, depth: 0 }];
56
+ while (stack.length > 0) {
57
+ const entry = stack.pop();
58
+ if (!entry)
59
+ break;
60
+ if (entry.depth > maxDepth)
61
+ continue;
62
+ let children;
63
+ try {
64
+ children = await readdir(entry.dir, { withFileTypes: true });
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ for (const child of children) {
70
+ const fullPath = join(entry.dir, child.name);
71
+ if (child.isDirectory()) {
72
+ // Skip the symlinked mozbuild cache tree which contains full copies
73
+ // and would dominate the scan on macOS.
74
+ if (child.name.startsWith('.'))
75
+ continue;
76
+ stack.push({ dir: fullPath, depth: entry.depth + 1 });
77
+ continue;
78
+ }
79
+ if (child.name === name) {
80
+ return fullPath;
81
+ }
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Decides whether a source path should be packaged. Returns true for paths
88
+ * whose extension or directory fragment matches a known-packaged pattern.
89
+ * @param sourcePath Engine-relative POSIX path (for example browser/app/profile/pref.js).
90
+ * @returns True when the path implies packaging.
91
+ */
92
+ export function isPackageablePath(sourcePath) {
93
+ for (const fragment of IGNORE_PATH_FRAGMENTS) {
94
+ if (sourcePath.includes(fragment))
95
+ return false;
96
+ }
97
+ for (const ext of PACKAGEABLE_EXTENSIONS) {
98
+ if (sourcePath.endsWith(ext))
99
+ return true;
100
+ }
101
+ for (const fragment of PACKAGEABLE_PATH_FRAGMENTS) {
102
+ if (sourcePath.includes(fragment))
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ /**
108
+ * Collects engine-relative paths changed since the baseline's HEAD SHA.
109
+ * Always includes modified + untracked workdir paths. When the baseline is
110
+ * missing or the engine has no HEAD yet, falls back to workdir-only diffs.
111
+ */
112
+ async function collectChangedFiles(engineDir, baseline) {
113
+ const collected = new Set();
114
+ if (baseline?.engineHeadSha) {
115
+ try {
116
+ const output = await git(['diff', '--name-only', `${baseline.engineHeadSha}..HEAD`], engineDir);
117
+ for (const line of output.split('\n')) {
118
+ const trimmed = line.trim();
119
+ if (trimmed)
120
+ collected.add(trimmed);
121
+ }
122
+ }
123
+ catch (error) {
124
+ if (!isMissingHeadError(error)) {
125
+ verbose(`Audit: could not diff against baseline SHA — ${toError(error).message}`);
126
+ }
127
+ }
128
+ }
129
+ try {
130
+ if (await hasChanges(engineDir)) {
131
+ const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
132
+ for (const line of worktreeDiff.split('\n')) {
133
+ const trimmed = line.trim();
134
+ if (trimmed)
135
+ collected.add(trimmed);
136
+ }
137
+ const untracked = await getUntrackedFiles(engineDir);
138
+ for (const file of untracked) {
139
+ collected.add(file);
140
+ }
141
+ }
142
+ }
143
+ catch (error) {
144
+ verbose(`Audit: could not enumerate workdir changes — ${toError(error).message}`);
145
+ }
146
+ return [...collected].sort();
147
+ }
148
+ /*
149
+ * Finds the unique obj-star directory with a dist subtree, or undefined
150
+ * when zero or multiple match. The ambiguous case is already rejected
151
+ * by pre-flight in build.ts, so the auditor only has to handle
152
+ * one-or-none.
153
+ */
154
+ async function resolveDistRoot(engineDir) {
155
+ const { readdir } = await import('node:fs/promises');
156
+ let entries;
157
+ try {
158
+ entries = await readdir(engineDir);
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ const objDirs = entries.filter((e) => e.startsWith('obj-'));
164
+ for (const objDir of objDirs) {
165
+ const distPath = join(engineDir, objDir, 'dist');
166
+ if (await pathExists(distPath)) {
167
+ return distPath;
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * Runs the post-build audit. Emits per-file warnings for missing or
174
+ * stale artifacts and a summary info line at the end. Always returns
175
+ * the summary; never throws on audit failure (the audit itself must
176
+ * never fail a successful build).
177
+ * @param projectRoot Root of the project (reserved for future fork-specific rules).
178
+ * @param engineDir Path to the engine directory.
179
+ * @param baseline Optional previous-build baseline marker.
180
+ * @returns Summary of artifact status counts.
181
+ */
182
+ export async function auditBuildArtifacts(projectRoot, engineDir, baseline) {
183
+ void projectRoot;
184
+ const summary = {
185
+ updated: 0,
186
+ stale: 0,
187
+ missing: 0,
188
+ skipped: 0,
189
+ entries: [],
190
+ };
191
+ const distRoot = await resolveDistRoot(engineDir);
192
+ if (!distRoot) {
193
+ verbose('Audit skipped: no dist tree found under obj-*/dist/.');
194
+ return summary;
195
+ }
196
+ const changed = await collectChangedFiles(engineDir, baseline);
197
+ if (changed.length === 0) {
198
+ return summary;
199
+ }
200
+ for (const source of changed) {
201
+ if (!isPackageablePath(source)) {
202
+ summary.skipped += 1;
203
+ summary.entries.push({ source, artifact: undefined, status: 'skipped' });
204
+ continue;
205
+ }
206
+ const sourcePath = join(engineDir, source);
207
+ let sourceMtime;
208
+ try {
209
+ const sourceStat = await stat(sourcePath);
210
+ sourceMtime = sourceStat.mtimeMs;
211
+ }
212
+ catch {
213
+ // File was deleted since the diff was computed. Skip — a deletion
214
+ // that didn't propagate to the dist tree is a distinct class of bug
215
+ // we don't audit yet.
216
+ summary.skipped += 1;
217
+ summary.entries.push({ source, artifact: undefined, status: 'skipped' });
218
+ continue;
219
+ }
220
+ const artifact = await findArtifactByBasename(distRoot, basename(source));
221
+ if (!artifact) {
222
+ summary.missing += 1;
223
+ summary.entries.push({ source, artifact: undefined, status: 'missing' });
224
+ warn(`Audit: engine/${source} was touched but no packaged artifact with basename "${basename(source)}" was found under ${distRoot}. Missing moz.build / jar.mn / package-manifest.in registration?`);
225
+ continue;
226
+ }
227
+ let artifactMtime;
228
+ try {
229
+ const artifactStat = await stat(artifact);
230
+ artifactMtime = artifactStat.mtimeMs;
231
+ }
232
+ catch {
233
+ // Disappeared after the directory scan; treat as missing.
234
+ summary.missing += 1;
235
+ summary.entries.push({ source, artifact, status: 'missing' });
236
+ warn(`Audit: engine/${source} has no readable packaged artifact at ${artifact} (disappeared during audit).`);
237
+ continue;
238
+ }
239
+ if (artifactMtime + 1 < sourceMtime) {
240
+ summary.stale += 1;
241
+ summary.entries.push({ source, artifact, status: 'stale' });
242
+ warn(`Audit: engine/${source} is newer than its packaged artifact ${artifact}. Build reported success but the file's path may not flow through packaging — check moz.build / jar.mn entries.`);
243
+ continue;
244
+ }
245
+ summary.updated += 1;
246
+ summary.entries.push({ source, artifact, status: 'updated' });
247
+ }
248
+ info(`Packaged: ${summary.updated} updated, ${summary.stale} stale, ${summary.missing} missing, ${summary.skipped} skipped`);
249
+ return summary;
250
+ }
251
+ //# sourceMappingURL=build-audit.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Persists a marker describing the state of the engine tree at the time of
3
+ * the last successful `fireforge build`. Two downstream consumers share this
4
+ * marker:
5
+ *
6
+ * - `build-audit`: after a build succeeds, compare engine files touched
7
+ * since the baseline against the dist bundle to flag silent
8
+ * packaging drops (e.g. a pref file never registered in moz.build).
9
+ * - `build-prepare`: before a build starts, detect whether any
10
+ * `moz.build` / `moz.configure` / `Makefile.in` changed since the
11
+ * baseline and run `mach configure` before the build step so the
12
+ * recursive-make backend isn't stale.
13
+ *
14
+ * The marker lives under `.fireforge/last-build.json`. It is written only
15
+ * on successful build completion; a failed build does not update it, so a
16
+ * subsequent run still audits against the last known-good tree.
17
+ */
18
+ /** Shape of the on-disk baseline marker. */
19
+ export interface BuildBaseline {
20
+ /** SHA of engine HEAD at the time the build succeeded. */
21
+ engineHeadSha: string;
22
+ /**
23
+ * ISO-8601 timestamp of when the baseline was recorded. Informational —
24
+ * downstream code keys off `engineHeadSha` for diffs, but the timestamp
25
+ * helps operators reason about stale markers.
26
+ */
27
+ builtAt: string;
28
+ /**
29
+ * The binaryName used at build time. Captured so the dist-tree audit
30
+ * can resolve the expected bundle root under obj-star/dist/ even when
31
+ * the project has since been renamed.
32
+ */
33
+ binaryName: string;
34
+ }
35
+ /** Name of the last-build marker file under `.fireforge/`. */
36
+ export declare const BUILD_BASELINE_FILENAME = "last-build.json";
37
+ /**
38
+ * Resolves the on-disk path of the build baseline marker.
39
+ * @param projectRoot - Root directory of the project
40
+ * @returns Absolute path of the marker file
41
+ */
42
+ export declare function getBuildBaselinePath(projectRoot: string): string;
43
+ /**
44
+ * Reads the last-build baseline if present. Returns undefined when no
45
+ * previous successful build has been recorded — callers must tolerate that
46
+ * path (first build, cleaned workspace).
47
+ * @param projectRoot - Root directory of the project
48
+ */
49
+ export declare function readBuildBaseline(projectRoot: string): Promise<BuildBaseline | undefined>;
50
+ /**
51
+ * Records a successful build by writing a fresh baseline marker. Captures
52
+ * engine HEAD SHA (or an empty string when the engine has no HEAD yet) and
53
+ * the current binaryName. Caller is responsible for only invoking this
54
+ * after the build exit code was zero.
55
+ * @param projectRoot - Root directory of the project
56
+ * @param engineDir - Path to the engine directory
57
+ * @param binaryName - Current `binaryName` from fireforge.json
58
+ */
59
+ export declare function writeBuildBaseline(projectRoot: string, engineDir: string, binaryName: string): Promise<void>;
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Persists a marker describing the state of the engine tree at the time of
4
+ * the last successful `fireforge build`. Two downstream consumers share this
5
+ * marker:
6
+ *
7
+ * - `build-audit`: after a build succeeds, compare engine files touched
8
+ * since the baseline against the dist bundle to flag silent
9
+ * packaging drops (e.g. a pref file never registered in moz.build).
10
+ * - `build-prepare`: before a build starts, detect whether any
11
+ * `moz.build` / `moz.configure` / `Makefile.in` changed since the
12
+ * baseline and run `mach configure` before the build step so the
13
+ * recursive-make backend isn't stale.
14
+ *
15
+ * The marker lives under `.fireforge/last-build.json`. It is written only
16
+ * on successful build completion; a failed build does not update it, so a
17
+ * subsequent run still audits against the last known-good tree.
18
+ */
19
+ import { join } from 'node:path';
20
+ import { pathExists, readJson, writeJson } from '../utils/fs.js';
21
+ import { FIREFORGE_DIR } from './config-paths.js';
22
+ import { getHead, isMissingHeadError } from './git.js';
23
+ /** Name of the last-build marker file under `.fireforge/`. */
24
+ export const BUILD_BASELINE_FILENAME = 'last-build.json';
25
+ /**
26
+ * Resolves the on-disk path of the build baseline marker.
27
+ * @param projectRoot - Root directory of the project
28
+ * @returns Absolute path of the marker file
29
+ */
30
+ export function getBuildBaselinePath(projectRoot) {
31
+ return join(projectRoot, FIREFORGE_DIR, BUILD_BASELINE_FILENAME);
32
+ }
33
+ /**
34
+ * Reads the last-build baseline if present. Returns undefined when no
35
+ * previous successful build has been recorded — callers must tolerate that
36
+ * path (first build, cleaned workspace).
37
+ * @param projectRoot - Root directory of the project
38
+ */
39
+ export async function readBuildBaseline(projectRoot) {
40
+ const path = getBuildBaselinePath(projectRoot);
41
+ if (!(await pathExists(path))) {
42
+ return undefined;
43
+ }
44
+ try {
45
+ return await readJson(path);
46
+ }
47
+ catch {
48
+ // A corrupt marker is equivalent to no marker — the audit/auto-configure
49
+ // will treat it as "first build" rather than block on the inconsistency.
50
+ return undefined;
51
+ }
52
+ }
53
+ /**
54
+ * Records a successful build by writing a fresh baseline marker. Captures
55
+ * engine HEAD SHA (or an empty string when the engine has no HEAD yet) and
56
+ * the current binaryName. Caller is responsible for only invoking this
57
+ * after the build exit code was zero.
58
+ * @param projectRoot - Root directory of the project
59
+ * @param engineDir - Path to the engine directory
60
+ * @param binaryName - Current `binaryName` from fireforge.json
61
+ */
62
+ export async function writeBuildBaseline(projectRoot, engineDir, binaryName) {
63
+ let engineHeadSha = '';
64
+ try {
65
+ engineHeadSha = await getHead(engineDir);
66
+ }
67
+ catch (error) {
68
+ // Engine may be an unborn branch (freshly cloned + reset, or mid-import)
69
+ // — record an empty SHA and let downstream fall back to "no prior state"
70
+ // behavior. Any other git failure is bubbled up; we don't want to
71
+ // silently write a garbage marker.
72
+ if (!isMissingHeadError(error)) {
73
+ throw error;
74
+ }
75
+ }
76
+ const baseline = {
77
+ engineHeadSha,
78
+ builtAt: new Date().toISOString(),
79
+ binaryName,
80
+ };
81
+ await writeJson(getBuildBaselinePath(projectRoot), baseline);
82
+ }
83
+ //# sourceMappingURL=build-baseline.js.map
@@ -3,13 +3,32 @@
3
3
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
4
4
  */
5
5
  import type { FireForgeConfig, ProjectPaths } from '../types/config.js';
6
+ import type { BuildBaseline } from './build-baseline.js';
6
7
  /**
7
8
  * Result of the build preparation phase.
8
9
  */
9
10
  export interface BuildPreparation {
10
11
  /** Number of Furnace components applied (0 if none or no furnace.json) */
11
12
  furnaceApplied: number;
13
+ /** True when `mach configure` was auto-run to refresh a stale backend. */
14
+ reconfigured: boolean;
12
15
  }
16
+ /** Options for {@link prepareBuildEnvironment}. */
17
+ export interface PrepareBuildOptions {
18
+ /**
19
+ * Previous successful-build baseline, used to detect `moz.build` /
20
+ * `moz.configure` / `Makefile.in` changes that require a fresh
21
+ * `mach configure` before the build. When undefined, the auto-configure
22
+ * step is skipped — there's no reference point for what "changed since"
23
+ * means.
24
+ */
25
+ previousBaseline?: BuildBaseline | undefined;
26
+ }
27
+ /**
28
+ * Returns true when the file path matches a pattern that forces
29
+ * `mach configure` to regenerate the backend. Exported for testing.
30
+ */
31
+ export declare function isBackendInvalidatingFile(path: string): boolean;
13
32
  /**
14
33
  * Runs the shared pre-flight steps for build and package commands:
15
34
  * 1. Cleans Furnace stories from engine (prevents leaking into production)
@@ -22,4 +41,4 @@ export interface BuildPreparation {
22
41
  * @param config - Loaded FireForge configuration
23
42
  * @returns Preparation results
24
43
  */
25
- export declare function prepareBuildEnvironment(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig): Promise<BuildPreparation>;
44
+ export declare function prepareBuildEnvironment(projectRoot: string, paths: ProjectPaths, config: FireForgeConfig, options?: PrepareBuildOptions): Promise<BuildPreparation>;
@@ -4,14 +4,72 @@
4
4
  * story cleanup, branding setup, Furnace component application, and mozconfig generation.
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
+ import { toError } from '../utils/errors.js';
7
8
  import { pathExists } from '../utils/fs.js';
8
- import { info, spinner, warn } from '../utils/logger.js';
9
+ import { info, spinner, verbose, warn } from '../utils/logger.js';
9
10
  import { isBrandingSetup, setupBranding } from './branding.js';
10
11
  import { applyAllComponents } from './furnace-apply.js';
11
12
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
12
13
  import { runFurnaceMutation } from './furnace-operation.js';
13
14
  import { cleanStories } from './furnace-stories.js';
14
- import { generateMozconfig } from './mach.js';
15
+ import { hasChanges, isMissingHeadError } from './git.js';
16
+ import { git } from './git-base.js';
17
+ import { getUntrackedFiles } from './git-status.js';
18
+ import { generateMozconfig, runMach } from './mach.js';
19
+ /** Path fragments of files whose edits invalidate the recursive-make backend. */
20
+ const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
21
+ /**
22
+ * Returns true when the file path matches a pattern that forces
23
+ * `mach configure` to regenerate the backend. Exported for testing.
24
+ */
25
+ export function isBackendInvalidatingFile(path) {
26
+ for (const suffix of BACKEND_INVALIDATING_SUFFIXES) {
27
+ if (path === suffix || path.endsWith(`/${suffix}`))
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Collects engine-relative paths of files changed since the baseline's HEAD
34
+ * SHA plus any workdir modifications. Defensive — git failures surface as
35
+ * verbose lines and return the files collected so far. An empty result
36
+ * means "no drift we can prove" rather than "no drift occurred".
37
+ */
38
+ async function collectBackendRelevantChanges(engineDir, baseline) {
39
+ const collected = new Set();
40
+ if (baseline.engineHeadSha) {
41
+ try {
42
+ const diff = await git(['diff', '--name-only', `${baseline.engineHeadSha}..HEAD`], engineDir);
43
+ for (const line of diff.split('\n')) {
44
+ const trimmed = line.trim();
45
+ if (trimmed)
46
+ collected.add(trimmed);
47
+ }
48
+ }
49
+ catch (error) {
50
+ if (!isMissingHeadError(error)) {
51
+ verbose(`Auto-configure: could not diff engine against baseline — ${toError(error).message}`);
52
+ }
53
+ }
54
+ }
55
+ try {
56
+ if (await hasChanges(engineDir)) {
57
+ const worktreeDiff = await git(['diff', '--name-only', 'HEAD'], engineDir);
58
+ for (const line of worktreeDiff.split('\n')) {
59
+ const trimmed = line.trim();
60
+ if (trimmed)
61
+ collected.add(trimmed);
62
+ }
63
+ for (const file of await getUntrackedFiles(engineDir)) {
64
+ collected.add(file);
65
+ }
66
+ }
67
+ }
68
+ catch (error) {
69
+ verbose(`Auto-configure: could not enumerate workdir changes — ${toError(error).message}`);
70
+ }
71
+ return [...collected];
72
+ }
15
73
  /**
16
74
  * Runs the shared pre-flight steps for build and package commands:
17
75
  * 1. Cleans Furnace stories from engine (prevents leaking into production)
@@ -24,7 +82,7 @@ import { generateMozconfig } from './mach.js';
24
82
  * @param config - Loaded FireForge configuration
25
83
  * @returns Preparation results
26
84
  */
27
- export async function prepareBuildEnvironment(projectRoot, paths, config) {
85
+ export async function prepareBuildEnvironment(projectRoot, paths, config, options = {}) {
28
86
  // Block the build if Furnace has an unresolved repair marker. This prevents
29
87
  // building against an engine that may be in an inconsistent state after a
30
88
  // failed rollback.
@@ -36,6 +94,33 @@ export async function prepareBuildEnvironment(projectRoot, paths, config) {
36
94
  'Run "fireforge doctor --repair-furnace" to reconcile engine state before building.');
37
95
  }
38
96
  }
97
+ // Auto-configure: if any backend-invalidating file (moz.build, moz.configure,
98
+ // Makefile.in) changed since the last successful build, run `mach configure`
99
+ // before the build step. Prevents incremental builds from silently skipping
100
+ // work against a stale recursive-make backend.
101
+ let reconfigured = false;
102
+ if (options.previousBaseline) {
103
+ const changed = await collectBackendRelevantChanges(paths.engine, options.previousBaseline);
104
+ const invalidating = changed.filter(isBackendInvalidatingFile);
105
+ if (invalidating.length > 0) {
106
+ info(`Backend config changed; running mach configure first... (${invalidating.length} file${invalidating.length === 1 ? '' : 's'} touched)`);
107
+ const configureSpinner = spinner('Running mach configure...');
108
+ try {
109
+ const exitCode = await runMach(['configure'], paths.engine);
110
+ if (exitCode !== 0) {
111
+ configureSpinner.error('mach configure exited non-zero; continuing with build anyway');
112
+ }
113
+ else {
114
+ configureSpinner.stop('Backend regenerated');
115
+ reconfigured = true;
116
+ }
117
+ }
118
+ catch (error) {
119
+ configureSpinner.error('mach configure failed; continuing with build anyway');
120
+ verbose(`Auto-configure error: ${toError(error).message}`);
121
+ }
122
+ }
123
+ }
39
124
  // Clean stories before build to ensure they don't leak into production binary
40
125
  await cleanStories(paths.engine);
41
126
  // Set up custom branding directory and patch moz.configure
@@ -117,6 +202,6 @@ export async function prepareBuildEnvironment(projectRoot, paths, config) {
117
202
  mozconfigSpinner.error('Failed to generate mozconfig');
118
203
  throw error;
119
204
  }
120
- return { furnaceApplied };
205
+ return { furnaceApplied, reconfigured };
121
206
  }
122
207
  //# sourceMappingURL=build-prepare.js.map
@@ -4,7 +4,8 @@ import { type RollbackJournal } from './furnace-rollback.js';
4
4
  * The signal names the lifecycle wrapper knows how to react to. Spelled out
5
5
  * as a literal union (rather than `NodeJS.Signals`) so the public type
6
6
  * surface does not depend on the NodeJS global namespace — consumers of
7
- * `@hominis/fireforge` may compile against tsconfigs that omit `@types/node`.
7
+ * FireForge's published scoped npm package may compile against tsconfigs
8
+ * that omit `@types/node`.
8
9
  */
9
10
  export type FurnaceShutdownSignal = 'SIGINT' | 'SIGTERM';
10
11
  /**
@@ -52,16 +52,22 @@ function withTimeout(promise, ms, label) {
52
52
  * timeout so a stuck I/O operation cannot hang the process indefinitely.
53
53
  */
54
54
  export async function rollbackActiveOperationsForSignal(signal) {
55
+ // Snapshot the active operations so we don't race with `runFurnaceMutation`
56
+ // clearing slots during normal completion. Filter completed bodies so a
57
+ // body sitting in its finally-block cleanup window is not counted as live
58
+ // work — this would mis-trigger the rollback banner for plain `fireforge
59
+ // run` (which never registers a mutation but can receive SIGTERM).
60
+ const snapshot = [...activeOperations.values()].filter((op) => !op.completed);
61
+ if (snapshot.length === 0) {
62
+ // Nothing to roll back. Stay silent so commands that never mutated (run,
63
+ // watch, test, doctor) don't print an alarming "rolling back mutations"
64
+ // line on Ctrl+C / SIGTERM. Leave `signalRollbackInFlight` false so a
65
+ // subsequent registrant can still trigger the full path.
66
+ return;
67
+ }
55
68
  signalRollbackInFlight = true;
56
69
  warn(`Received ${signal}; rolling back in-flight furnace mutations…`);
57
- // Snapshot the active operations so we don't race with `runFurnaceMutation`
58
- // clearing slots during normal completion.
59
- const snapshot = [...activeOperations.values()];
60
70
  for (const op of snapshot) {
61
- // If the body completed successfully and is in its finally-block cleanup
62
- // (deleting the token), skip rollback — the mutation committed cleanly.
63
- if (op.completed)
64
- continue;
65
71
  const cleanupErrors = [];
66
72
  // Run extra cleanup callbacks first (e.g. preview's cleanStories), so the
67
73
  // engine is in its tidiest possible shape before the journal restore
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pattern-based translator for cryptic mozbuild / mach errors.
3
+ *
4
+ * Each entry maps a stderr regex to an actionable hint. The goal is not to
5
+ * parse every mach failure — it's to convert the handful of errors whose
6
+ * message is non-obvious into a one-line "here's what to change". New
7
+ * entries should only be added when a concrete diagnosis of the cryptic
8
+ * output has been established; low-confidence hints would train operators
9
+ * to ignore the translator.
10
+ */
11
+ /** A single translator entry. */
12
+ export interface MachErrorHint {
13
+ /** Pattern to search within the captured mach stderr. */
14
+ pattern: RegExp;
15
+ /** Actionable, one-line hint to surface alongside the raw mach output. */
16
+ hint: string;
17
+ }
18
+ /**
19
+ * Registered hint patterns. Order-sensitive: the first match wins per
20
+ * pattern, but multiple distinct patterns may fire for the same stderr.
21
+ */
22
+ export declare const MACH_ERROR_HINTS: MachErrorHint[];
23
+ /**
24
+ * Scans captured stderr for known mach errors and returns matching hints.
25
+ * Pure function — safe to call on any string; never throws.
26
+ * @param stderr Captured mach stderr.
27
+ * @returns Ordered, de-duplicated list of hint strings. Empty when nothing matches.
28
+ */
29
+ export declare function explainMachError(stderr: string): string[];
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Pattern-based translator for cryptic mozbuild / mach errors.
4
+ *
5
+ * Each entry maps a stderr regex to an actionable hint. The goal is not to
6
+ * parse every mach failure — it's to convert the handful of errors whose
7
+ * message is non-obvious into a one-line "here's what to change". New
8
+ * entries should only be added when a concrete diagnosis of the cryptic
9
+ * output has been established; low-confidence hints would train operators
10
+ * to ignore the translator.
11
+ */
12
+ /**
13
+ * Registered hint patterns. Order-sensitive: the first match wins per
14
+ * pattern, but multiple distinct patterns may fire for the same stderr.
15
+ */
16
+ export const MACH_ERROR_HINTS = [
17
+ {
18
+ pattern: /mozbuild\.preprocessor\.Preprocessor\.Error[\s\S]*?no preprocessor directives found/,
19
+ hint: 'A file registered under JS_PREFERENCE_PP_FILES contains no preprocessor directives. ' +
20
+ 'Use JS_PREFERENCE_FILES instead, or add at least one #filter / #expand directive to the file.',
21
+ },
22
+ ];
23
+ /**
24
+ * Scans captured stderr for known mach errors and returns matching hints.
25
+ * Pure function — safe to call on any string; never throws.
26
+ * @param stderr Captured mach stderr.
27
+ * @returns Ordered, de-duplicated list of hint strings. Empty when nothing matches.
28
+ */
29
+ export function explainMachError(stderr) {
30
+ if (!stderr) {
31
+ return [];
32
+ }
33
+ const hits = [];
34
+ const seen = new Set();
35
+ for (const { pattern, hint } of MACH_ERROR_HINTS) {
36
+ if (pattern.test(stderr) && !seen.has(hint)) {
37
+ seen.add(hint);
38
+ hits.push(hint);
39
+ }
40
+ }
41
+ return hits;
42
+ }
43
+ //# sourceMappingURL=mach-error-hints.js.map