@hominis/fireforge 0.16.3 → 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.
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { dirname, join } from 'node:path';
3
- import { multiselect } from '@clack/prompts';
3
+ import { confirm, multiselect } from '@clack/prompts';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { isGitRepository } from '../core/git.js';
6
6
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
@@ -11,6 +11,14 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { pathExists } from '../utils/fs.js';
13
13
  import { cancel, info, intro, isCancel, outro, spinner, success, warn } from '../utils/logger.js';
14
+ /**
15
+ * Threshold above which `--scan` must be explicitly confirmed. Values were
16
+ * picked so the common "refresh after one-or-two-file tweak" case stays
17
+ * frictionless while catching the eval finding #13 scenario where `--scan`
18
+ * silently pulled in an entire sibling feature (xhtml + tests + theme CSS).
19
+ */
20
+ const SCAN_ADD_COUNT_THRESHOLD = 3;
21
+ const SCAN_DIR_COUNT_THRESHOLD = 2;
14
22
  import { pickDefined } from '../utils/options.js';
15
23
  import { runPatchLint } from './export-shared.js';
16
24
  import { reExportFilesInPlace } from './re-export-files.js';
@@ -39,25 +47,90 @@ async function scanPatchFiles(currentFilesAffected, engineDir, manifest, patchFi
39
47
  removed.push(f);
40
48
  }
41
49
  }
42
- for (const f of added.sort()) {
50
+ const sortedAdded = [...added].sort();
51
+ const sortedRemoved = [...removed].sort();
52
+ for (const f of sortedAdded) {
43
53
  info(` + ${f}`);
44
54
  }
45
- for (const f of removed.sort()) {
55
+ for (const f of sortedRemoved) {
46
56
  info(` - ${f}`);
47
57
  }
48
58
  if (added.length > 0 || removed.length > 0) {
49
59
  const removedSet = new Set(removed);
50
60
  const updated = [...currentFilesAffected.filter((f) => !removedSet.has(f)), ...added].sort();
51
61
  info(` ${isDryRun ? 'Would update' : 'Updated'} ${patchFilename}: +${added.length} / -${removed.length} files`);
52
- return updated;
62
+ return { updated, added: sortedAdded, removed: sortedRemoved };
53
63
  }
54
- return currentFilesAffected;
64
+ return { updated: currentFilesAffected, added: [], removed: [] };
65
+ }
66
+ /**
67
+ * Returns true when the caller-confirmed threshold is exceeded for this
68
+ * scan's additions. The heuristic treats "small, same-directory" additions
69
+ * as friction-free (the common refresh case) and flags larger or
70
+ * multi-directory expansions so operators see them before they land.
71
+ *
72
+ * Pre-0.16.0 `--scan` silently broadened patches to include any modified or
73
+ * untracked file that shared a parent directory with the existing
74
+ * filesAffected — in practice, pulling adjacent feature code into a patch
75
+ * that had nothing to do with it. The gate below turns the broadening into
76
+ * an explicit opt-in.
77
+ */
78
+ function scanAdditionsNeedConfirmation(added) {
79
+ if (added.length === 0)
80
+ return false;
81
+ if (added.length > SCAN_ADD_COUNT_THRESHOLD)
82
+ return true;
83
+ const dirs = new Set(added.map((f) => dirname(f)));
84
+ return dirs.size >= SCAN_DIR_COUNT_THRESHOLD;
85
+ }
86
+ /**
87
+ * Gate for broad `--scan` additions. Enforces explicit acknowledgement when
88
+ * the scan would pull in more files than a narrow refresh. Dry-run always
89
+ * proceeds (the preview is the whole point).
90
+ *
91
+ * @returns true if the caller should proceed; false if the user cancelled.
92
+ */
93
+ async function confirmBroadScanAdditions(args) {
94
+ const { patchFilename, added, isDryRun, yes, isInteractive } = args;
95
+ if (isDryRun)
96
+ return true;
97
+ if (!scanAdditionsNeedConfirmation(added))
98
+ return true;
99
+ if (yes)
100
+ return true;
101
+ warn(`${patchFilename}: --scan would add ${String(added.length)} file(s) that span ${String(new Set(added.map((f) => dirname(f))).size)} director${new Set(added.map((f) => dirname(f))).size === 1 ? 'y' : 'ies'}. ` +
102
+ 'Broad scans can silently pull adjacent features into a patch — review the diff before continuing.');
103
+ if (!isInteractive) {
104
+ throw new GeneralError(`Refusing to broaden "${patchFilename}" via --scan in non-interactive mode. ` +
105
+ 'Pass --yes to acknowledge the expansion, or run with --dry-run first to review.');
106
+ }
107
+ const confirmed = await confirm({
108
+ message: `Proceed and broaden ${patchFilename} with ${String(added.length)} newly discovered file(s)?`,
109
+ initialValue: false,
110
+ });
111
+ if (isCancel(confirmed) || !confirmed) {
112
+ cancel(`Skipped ${patchFilename}`);
113
+ return false;
114
+ }
115
+ return true;
55
116
  }
56
117
  async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, config) {
57
118
  let currentFilesAffected = [...patch.filesAffected];
58
119
  // --- Scan for new/removed files ---
59
120
  if (options.scan) {
60
- currentFilesAffected = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
121
+ const scanResult = await scanPatchFiles(currentFilesAffected, paths.engine, manifest, patch.filename, isDryRun);
122
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
123
+ const proceed = await confirmBroadScanAdditions({
124
+ patchFilename: patch.filename,
125
+ added: scanResult.added,
126
+ isDryRun,
127
+ yes: options.yes === true,
128
+ isInteractive,
129
+ });
130
+ if (!proceed) {
131
+ return false;
132
+ }
133
+ currentFilesAffected = scanResult.updated;
61
134
  }
62
135
  else if (options.files === undefined) {
63
136
  // Finding #16: when neither `--scan` nor `--files` is set and some
@@ -5,6 +5,7 @@ import { getProjectPaths, loadConfig, loadState, updateState } from '../core/con
5
5
  import { isGitRepository } from '../core/git.js';
6
6
  import { getStagedDiffForFiles } from '../core/git-diff.js';
7
7
  import { stageFiles, unstageFiles } from '../core/git-file-ops.js';
8
+ import { extractAffectedFiles } from '../core/patch-apply.js';
8
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
9
10
  import { loadPatchesManifest } from '../core/patch-manifest.js';
10
11
  import { GeneralError, ResolutionError } from '../errors/base.js';
@@ -106,9 +107,22 @@ export async function resolveCommand(projectRoot) {
106
107
  // import / export / re-export / patch reorder / patch compact could
107
108
  // interleave with and leave the manifest disagreeing with the
108
109
  // freshly-written patch body.
110
+ //
111
+ // Always recompute `filesAffected` from the diff content itself. The
112
+ // eval finding #16 scenario: the user's manual fix removed every
113
+ // hunk for one file while the file still existed on disk, so the
114
+ // pre-0.16.0 gate of "update filesAffected only when files were
115
+ // deleted from disk" left the manifest claiming a file the patch
116
+ // body no longer targeted. The next `fireforge import` then failed
117
+ // the patch-manifest consistency check even though resolve reported
118
+ // success. `extractAffectedFiles` already owns the canonical
119
+ // "parse a diff, return its target paths" logic used by export and
120
+ // consistency — using it here keeps resolve in agreement with every
121
+ // other writer.
122
+ const diffFilesAffected = extractAffectedFiles(diffContent);
109
123
  const config = await loadConfig(projectRoot);
110
124
  await updatePatchAndMetadata(paths.patches, patchFilename, diffContent, {
111
- ...(activeFiles.length < existingFiles.length ? { filesAffected: activeFiles } : {}),
125
+ filesAffected: diffFilesAffected,
112
126
  sourceEsrVersion: config.firefox.version,
113
127
  });
114
128
  // Cleanup: Clear pendingResolution from state.json transactionally so
@@ -183,17 +183,31 @@ async function runSmokeExit(engineDir, options) {
183
183
  warn(`--capture-console stream error: ${err.message}`);
184
184
  });
185
185
  const findings = [];
186
- let allowlistedHits = 0;
186
+ let allowlistedErrorHits = 0;
187
+ let allowlistedTotalHits = 0;
187
188
  const handleLine = (stream, line) => {
188
189
  // Mirror raw output to the terminal so operators watching the smoke
189
190
  // run still see what the browser is printing. Stream selection on the
190
191
  // mirror preserves stdout/stderr separation for downstream piping.
191
192
  const sink = stream === 'stdout' ? process.stdout : process.stderr;
192
193
  sink.write(`${line}\n`);
194
+ // Count allowlist hits up-front, regardless of error-pattern match.
195
+ // Pre-0.16.0 the counter only incremented when the line ALSO matched
196
+ // an error pattern — so an allowlist regex that visibly matched
197
+ // `console.warn: RSLoader:` still reported 0 hits because
198
+ // `console.warn:` is not a smoke error class, confusing operators
199
+ // who were tuning their allowlist. We now surface two numbers: the
200
+ // total set of allowlisted lines (what the operator sees in the
201
+ // console) and the subset that were error-class (what the smoke
202
+ // exit contract cares about). The exit contract itself is unchanged.
203
+ const isAllowlisted = allowlist.length > 0 && matchesAllowlist(line, allowlist);
204
+ if (isAllowlisted) {
205
+ allowlistedTotalHits += 1;
206
+ }
193
207
  if (!matchesSmokeError(line))
194
208
  return;
195
- if (matchesAllowlist(line, allowlist)) {
196
- allowlistedHits += 1;
209
+ if (isAllowlisted) {
210
+ allowlistedErrorHits += 1;
197
211
  return;
198
212
  }
199
213
  findings.push({ stream, line });
@@ -221,7 +235,8 @@ async function runSmokeExit(engineDir, options) {
221
235
  smokeTimeoutMs,
222
236
  elapsedMs,
223
237
  timedOut: result.timedOut,
224
- allowlistedHits,
238
+ allowlistedErrorHits,
239
+ allowlistedTotalHits,
225
240
  findings,
226
241
  exitCode: result.exitCode,
227
242
  });
@@ -278,7 +293,14 @@ function reportSmokeSummary(args) {
278
293
  info('');
279
294
  info(`Smoke run complete: ${seconds}s elapsed of ${windowSeconds}s window${suffix}`);
280
295
  info(` Unallowed errors: ${String(args.findings.length)}`);
281
- info(` Allowlisted hits: ${String(args.allowlistedHits)}`);
296
+ // The "suppressed errors" count is what the exit contract cares about —
297
+ // it is the subset of allowlisted hits that would otherwise have been
298
+ // tallied as findings. The "all allowlisted lines" count answers the
299
+ // operator's mental model ("my --console-allow pattern matched N
300
+ // console lines"), which pre-0.16.0 was missing and led to 0-hit
301
+ // reports on visibly matching regexes.
302
+ info(` Allowlisted error hits (suppressed): ${String(args.allowlistedErrorHits)}`);
303
+ info(` Allowlisted lines total: ${String(args.allowlistedTotalHits)}`);
282
304
  info(` Child exit code: ${String(args.exitCode)}`);
283
305
  if (args.findings.length === 0)
284
306
  return;
@@ -9,7 +9,7 @@ import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xp
9
9
  import { GeneralError } from '../errors/base.js';
10
10
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
11
11
  import { pathExists } from '../utils/fs.js';
12
- import { info, intro, spinner, warn } from '../utils/logger.js';
12
+ import { info, intro, outro, spinner, warn } from '../utils/logger.js';
13
13
  import { pickDefined } from '../utils/options.js';
14
14
  import { stripEnginePrefix } from '../utils/paths.js';
15
15
  async function assertTestPathsExist(engineDir, testPaths) {
@@ -187,6 +187,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
187
187
  if (!preflight.ok) {
188
188
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
189
189
  }
190
+ // Close the intro frame explicitly. Without an outro, clack's
191
+ // grouped-output mode left the PASS line hanging inside an
192
+ // unclosed tree — in the eval's non-TTY capture the info line
193
+ // itself failed to render, so `test --doctor` looked like it had
194
+ // exited silently after the spinner start line. The outro also
195
+ // gives scripts a deterministic "done" marker to parse.
196
+ outro(`Marionette preflight: PASS (${preflight.durationMs}ms)`);
190
197
  return;
191
198
  }
192
199
  if (!preflight.ok) {
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
2
3
  import { getProjectPaths, loadConfig } from '../core/config.js';
4
+ import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
3
5
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
4
6
  import { measureTokenCoverage } from '../core/token-coverage.js';
5
7
  import { getTokensCssPath } from '../core/token-manager.js';
@@ -22,9 +24,19 @@ export async function tokenCoverageCommand(projectRoot) {
22
24
  const config = await loadConfig(projectRoot);
23
25
  const tokensCssPath = getTokensCssPath(config.binaryName);
24
26
  const files = await getStatusWithCodes(paths.engine);
25
- const cssFiles = files
27
+ const statusCssFiles = files
26
28
  .filter((f) => f.file.endsWith('.css') && f.file !== tokensCssPath)
27
29
  .map((f) => f.file);
30
+ // Also scan CSS files deployed by Furnace custom components. Deployed
31
+ // files can be committed (and therefore absent from `git status`) while
32
+ // still being the primary surface where token adoption matters. Before
33
+ // 0.16.0, coverage only looked at modified files, which silently
34
+ // undercounted projects where Furnace writes many component-CSS files
35
+ // into the engine and they are already tracked.
36
+ const furnaceCssFiles = await collectFurnaceCustomCssFiles(projectRoot, paths.engine, tokensCssPath);
37
+ // De-dupe so a file that is both a custom deploy target AND modified is
38
+ // scanned exactly once.
39
+ const cssFiles = [...new Set([...statusCssFiles, ...furnaceCssFiles])];
28
40
  if (cssFiles.length === 0) {
29
41
  info('No modified CSS files');
30
42
  outro('Nothing to measure');
@@ -54,4 +66,46 @@ export async function tokenCoverageCommand(projectRoot) {
54
66
  }
55
67
  outro(`${report.filesScanned} CSS file${report.filesScanned === 1 ? '' : 's'} scanned`);
56
68
  }
69
+ /**
70
+ * Returns engine-relative `.css` paths deployed by every Furnace custom
71
+ * component registered in `furnace.json`. Only files that actually exist
72
+ * on disk are included — a component whose deploy target is missing (e.g.
73
+ * `furnace apply` has not run yet) is skipped silently so a fresh
74
+ * `furnace init` followed immediately by `token coverage` does not error.
75
+ *
76
+ * Returns an empty array when the project has no furnace.json, no custom
77
+ * components, or when loading the config fails (a warn is emitted in the
78
+ * last case so the user can diagnose a broken furnace.json without losing
79
+ * coverage results on the non-furnace CSS files).
80
+ */
81
+ async function collectFurnaceCustomCssFiles(projectRoot, engineDir, tokensCssPath) {
82
+ if (!(await furnaceConfigExists(projectRoot))) {
83
+ return [];
84
+ }
85
+ let furnaceConfig;
86
+ try {
87
+ furnaceConfig = await loadFurnaceConfig(projectRoot);
88
+ }
89
+ catch (error) {
90
+ warn(`Could not load furnace.json for token coverage — scanning modified files only (${error.message})`);
91
+ return [];
92
+ }
93
+ const results = [];
94
+ for (const [componentName, customConfig] of Object.entries(furnaceConfig.custom)) {
95
+ // Upstream Firefox widget layout: every component lives at
96
+ // `toolkit/content/widgets/<tagName>/` and ships at least
97
+ // `<tagName>.css`. `targetPath` already resolves to that directory
98
+ // (the create command writes `toolkit/content/widgets/<name>` into
99
+ // furnace.json) so we can probe the default layout directly without
100
+ // walking the whole tree.
101
+ const candidate = `${customConfig.targetPath}/${componentName}.css`;
102
+ if (candidate === tokensCssPath)
103
+ continue;
104
+ const absolutePath = join(engineDir, candidate);
105
+ if (await pathExists(absolutePath)) {
106
+ results.push(candidate);
107
+ }
108
+ }
109
+ return results;
110
+ }
57
111
  //# sourceMappingURL=token-coverage.js.map
@@ -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')
@@ -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;
@@ -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
  }
@@ -10,7 +10,7 @@ import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { escapeRegex } from '../utils/regex.js';
11
11
  import { detectIndent, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
- import { assertBraceBalancePreserved, extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
13
+ import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findMethodBody, findMethodBraceIndex, validateWireName, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
15
  /**
16
16
  * AST-based implementation: finds onUnload()/uninit() method body and
@@ -18,6 +18,12 @@ const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
18
18
  */
19
19
  export function addDestroyAST(content, expression) {
20
20
  const name = extractNameFromExpression(expression);
21
+ // See wire-init.ts for the rationale: the template interpolates the
22
+ // expression verbatim, so a bare `Foo.bar` compiled to `Foo.bar;`
23
+ // (a property reference) instead of `Foo.bar();`. `coerceToCall`
24
+ // appends `()` when absent so the emitted block always invokes the
25
+ // teardown hook the operator asked for.
26
+ const callExpression = coerceToCall(expression);
21
27
  const ast = parseScript(content);
22
28
  const ms = new MagicString(content);
23
29
  const body = findMethodBody(ast, ['onUnload', 'uninit']);
@@ -41,7 +47,7 @@ export function addDestroyAST(content, expression) {
41
47
  `${indent}// ${name} destroy`,
42
48
  `${indent}try {`,
43
49
  `${indent} if (typeof ${name} !== "undefined") {`,
44
- `${indent} ${expression};`,
50
+ `${indent} ${callExpression};`,
45
51
  `${indent} }`,
46
52
  `${indent}} catch (e) {`,
47
53
  `${indent} console.error("${name} destroy failed:", e);`,
@@ -55,6 +61,9 @@ export function addDestroyAST(content, expression) {
55
61
  */
56
62
  export function legacyAddDestroy(content, expression) {
57
63
  const name = extractNameFromExpression(expression);
64
+ // Match the AST path on the call-coercion contract so fallback vs AST
65
+ // emits identical blocks (see wire-init.ts).
66
+ const callExpression = coerceToCall(expression);
58
67
  const lines = content.split('\n');
59
68
  const destroyRegex = /\b(?:async\s+)?(onUnload|uninit)\s*[(:]/;
60
69
  const found = findMethodBraceIndex(lines, destroyRegex, { requireBrace: true });
@@ -67,7 +76,7 @@ export function legacyAddDestroy(content, expression) {
67
76
  ` // ${name} destroy`,
68
77
  ` try {`,
69
78
  ` if (typeof ${name} !== "undefined") {`,
70
- ` ${expression};`,
79
+ ` ${callExpression};`,
71
80
  ` }`,
72
81
  ` } catch (e) {`,
73
82
  ` console.error("${name} destroy failed:", e);`,
@@ -91,8 +100,12 @@ export async function addDestroyToBrowserInit(engineDir, expression) {
91
100
  throw new GeneralError(`${BROWSER_INIT_JS} not found in engine`);
92
101
  }
93
102
  const content = await readText(filePath);
94
- // Idempotency check — use word-boundary regex to avoid substring false positives
95
- const destroyPattern = new RegExp(`(?:^|\\W)${escapeRegex(expression)}\\s*;?\\s*$`, 'm');
103
+ // Idempotency check — look for the coerced (call) form because that is
104
+ // what the emitter writes. Matching against the raw input would miss a
105
+ // previous `EvalStartup.destroy` invocation that the 0.16.0 coercion
106
+ // already persisted as `EvalStartup.destroy()`.
107
+ const callExpression = coerceToCall(expression);
108
+ const destroyPattern = new RegExp(`(?:^|\\W)${escapeRegex(callExpression)}\\s*;?\\s*$`, 'm');
96
109
  if (destroyPattern.test(content)) {
97
110
  return false;
98
111
  }
@@ -10,7 +10,7 @@ import { pathExists, readText, writeText } from '../utils/fs.js';
10
10
  import { escapeRegex } from '../utils/regex.js';
11
11
  import { detectIndent, getNodeSource, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
- import { assertBraceBalancePreserved, extractNameFromExpression, findInsertionAfterFireforgeBlocks, findMethodBody, findMethodBraceIndex, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
13
+ import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findInsertionAfterFireforgeBlocks, findMethodBody, findMethodBraceIndex, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
15
  /**
16
16
  * AST-based implementation: finds onLoad() method body, locates existing
@@ -19,6 +19,12 @@ const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
19
19
  */
20
20
  export function addInitAST(content, expression, after) {
21
21
  const name = extractNameFromExpression(expression);
22
+ // `validateWireName` accepts both `Foo.bar` and `Foo.bar()` shapes. The
23
+ // template below interpolates the value verbatim, so a bare property
24
+ // path compiles to `Foo.bar;` — a silent no-op, not a lifecycle
25
+ // invocation. `coerceToCall` normalises to the function-call form so
26
+ // the emitted block always invokes the hook the operator asked for.
27
+ const callExpression = coerceToCall(expression);
22
28
  const ast = parseScript(content);
23
29
  const ms = new MagicString(content);
24
30
  const body = findMethodBody(ast, 'onLoad');
@@ -97,7 +103,7 @@ export function addInitAST(content, expression, after) {
97
103
  `${indent}// inits that reference native UI elements we hide.`,
98
104
  `${indent}try {`,
99
105
  `${indent} if (typeof ${name} !== "undefined") {`,
100
- `${indent} ${expression};`,
106
+ `${indent} ${callExpression};`,
101
107
  `${indent} }`,
102
108
  `${indent}} catch (e) {`,
103
109
  `${indent} console.error("${name} init failed:", e);`,
@@ -111,6 +117,11 @@ export function addInitAST(content, expression, after) {
111
117
  */
112
118
  export function legacyAddInit(content, expression, after) {
113
119
  const name = extractNameFromExpression(expression);
120
+ // See `addInitAST` for the rationale — the AST and fallback paths must
121
+ // agree on whether the emitted block is a function call, otherwise
122
+ // operators would see different behaviour depending on which parser
123
+ // happened to handle their browser-init.js layout.
124
+ const callExpression = coerceToCall(expression);
114
125
  const lines = content.split('\n');
115
126
  const onLoadRegex = /\b(?:async\s+)?onLoad\s*[(:]/;
116
127
  const found = findMethodBraceIndex(lines, onLoadRegex, { requireBrace: true });
@@ -167,7 +178,7 @@ export function legacyAddInit(content, expression, after) {
167
178
  `${baseIndent}// inits that reference native UI elements we hide.`,
168
179
  `${baseIndent}try {`,
169
180
  `${inner}if (typeof ${name} !== "undefined") {`,
170
- `${inner2}${expression};`,
181
+ `${inner2}${callExpression};`,
171
182
  `${inner}}`,
172
183
  `${baseIndent}} catch (e) {`,
173
184
  `${inner}console.error("${name} init failed:", e);`,
@@ -192,8 +203,12 @@ export async function addInitToBrowserInit(engineDir, expression, after) {
192
203
  throw new GeneralError(`${BROWSER_INIT_JS} not found in engine`);
193
204
  }
194
205
  const content = await readText(filePath);
195
- // Idempotency check — use word-boundary regex to avoid substring false positives
196
- const initPattern = new RegExp(`(?:^|\\W)${escapeRegex(expression)}\\s*;?\\s*$`, 'm');
206
+ // Idempotency check — look for the coerced (call) form because that is
207
+ // what the emitter writes. Matching against the raw input would miss a
208
+ // previous `EvalStartup.init` invocation that the 0.16.0 coercion
209
+ // already persisted as `EvalStartup.init()`.
210
+ const callExpression = coerceToCall(expression);
211
+ const initPattern = new RegExp(`(?:^|\\W)${escapeRegex(callExpression)}\\s*;?\\s*$`, 'm');
197
212
  if (initPattern.test(content)) {
198
213
  return false;
199
214
  }
@@ -5,6 +5,21 @@ import { type AcornESTreeNode } from './ast-utils.js';
5
5
  * Rejects strings containing characters that could break out of JS strings or inject code.
6
6
  */
7
7
  export declare function validateWireName(value: string, label: string): void;
8
+ /**
9
+ * Coerces an init/destroy expression into a function call by appending `()`
10
+ * when the caller passed a bare property chain. Idempotent: an expression
11
+ * already ending in `()` is returned unchanged, so operators can pass either
12
+ * `EvalStartup.init` or `EvalStartup.init()` and get the same wired output.
13
+ *
14
+ * Motivation (eval finding 8): `validateWireName` accepts both shapes, but
15
+ * the generated block interpolated the expression verbatim inside
16
+ * `${expression};`. When a caller passed `EvalStartup.init`, the emitted
17
+ * code was `EvalStartup.init;` — a plain property reference that never
18
+ * invoked the lifecycle hook. The symptom was silent: `wire` reported
19
+ * success and the browser-init block looked plausible, but the hook
20
+ * never fired at runtime. Coercion at the template site closes that gap.
21
+ */
22
+ export declare function coerceToCall(expression: string): string;
8
23
  /**
9
24
  * Counts net brace depth change in a single line, ignoring braces inside
10
25
  * string literals (single, double, template), line comments (`//`), and
@@ -17,6 +17,23 @@ export function validateWireName(value, label) {
17
17
  }
18
18
  }
19
19
  }
20
+ /**
21
+ * Coerces an init/destroy expression into a function call by appending `()`
22
+ * when the caller passed a bare property chain. Idempotent: an expression
23
+ * already ending in `()` is returned unchanged, so operators can pass either
24
+ * `EvalStartup.init` or `EvalStartup.init()` and get the same wired output.
25
+ *
26
+ * Motivation (eval finding 8): `validateWireName` accepts both shapes, but
27
+ * the generated block interpolated the expression verbatim inside
28
+ * `${expression};`. When a caller passed `EvalStartup.init`, the emitted
29
+ * code was `EvalStartup.init;` — a plain property reference that never
30
+ * invoked the lifecycle hook. The symptom was silent: `wire` reported
31
+ * success and the browser-init block looked plausible, but the hook
32
+ * never fired at runtime. Coercion at the template site closes that gap.
33
+ */
34
+ export function coerceToCall(expression) {
35
+ return expression.endsWith('()') ? expression : `${expression}()`;
36
+ }
20
37
  /**
21
38
  * Counts net brace depth change in a single line, ignoring braces inside
22
39
  * string literals (single, double, template), line comments (`//`), and
@@ -86,6 +86,13 @@ export interface ExportOptions {
86
86
  forceUnsafe?: boolean;
87
87
  /** Exclude furnace-managed file paths from the export. */
88
88
  excludeFurnace?: boolean;
89
+ /**
90
+ * Acknowledge that the export will create cross-patch ownership overlap
91
+ * with existing non-superseded patches. Without this flag, `export`
92
+ * refuses when one or more `filesAffected` are already claimed by
93
+ * another patch, because the resulting queue fails `verify` immediately.
94
+ */
95
+ allowOverlap?: boolean;
89
96
  }
90
97
  /**
91
98
  * Options for the reset command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",