@hominis/fireforge 0.16.2 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +15 -2
  3. package/dist/bin/fireforge.js +11 -2
  4. package/dist/src/commands/doctor-furnace.js +83 -1
  5. package/dist/src/commands/doctor.js +18 -0
  6. package/dist/src/commands/download.js +58 -12
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  13. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  14. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  15. package/dist/src/commands/furnace/create-readback.js +34 -0
  16. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  17. package/dist/src/commands/furnace/create-templates.js +85 -31
  18. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  19. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  20. package/dist/src/commands/furnace/create.js +2 -0
  21. package/dist/src/commands/furnace/preview.d.ts +12 -0
  22. package/dist/src/commands/furnace/preview.js +34 -2
  23. package/dist/src/commands/furnace/status.js +1 -1
  24. package/dist/src/commands/import.js +63 -11
  25. package/dist/src/commands/patch/delete.js +10 -1
  26. package/dist/src/commands/patch/index.js +10 -1
  27. package/dist/src/commands/re-export.js +79 -6
  28. package/dist/src/commands/resolve.js +15 -1
  29. package/dist/src/commands/run.js +27 -5
  30. package/dist/src/commands/setup-support.js +60 -7
  31. package/dist/src/commands/status.js +28 -1
  32. package/dist/src/commands/test.js +28 -5
  33. package/dist/src/commands/token-coverage.js +55 -1
  34. package/dist/src/commands/token.js +19 -2
  35. package/dist/src/commands/wire.js +22 -2
  36. package/dist/src/core/branding.d.ts +10 -0
  37. package/dist/src/core/branding.js +7 -9
  38. package/dist/src/core/build-prepare.js +8 -1
  39. package/dist/src/core/file-lock.js +49 -15
  40. package/dist/src/core/furnace-operation.d.ts +17 -0
  41. package/dist/src/core/furnace-operation.js +30 -1
  42. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  43. package/dist/src/core/furnace-validate-helpers.js +53 -2
  44. package/dist/src/core/git.js +39 -10
  45. package/dist/src/core/mach-error-hints.js +16 -0
  46. package/dist/src/core/mach.js +15 -6
  47. package/dist/src/core/manifest-rules.js +16 -0
  48. package/dist/src/core/marionette-preflight.js +43 -12
  49. package/dist/src/core/patch-files.d.ts +12 -1
  50. package/dist/src/core/patch-files.js +14 -11
  51. package/dist/src/core/patch-lint.js +62 -11
  52. package/dist/src/core/wire-destroy.js +18 -5
  53. package/dist/src/core/wire-init.js +20 -5
  54. package/dist/src/core/wire-utils.d.ts +15 -0
  55. package/dist/src/core/wire-utils.js +17 -0
  56. package/dist/src/types/commands/options.d.ts +7 -0
  57. package/package.json +1 -1
@@ -110,6 +110,16 @@ export async function runMarionettePreflight(engineDir, options = {}) {
110
110
  cwd: engineDir,
111
111
  env: { ...process.env, MOZ_HEADLESS: '1' },
112
112
  stdio: ['ignore', 'ignore', 'pipe'],
113
+ // `detached: true` puts the child in a new process group so we can
114
+ // signal it and every descendant (Firefox, its helpers) via
115
+ // `process.kill(-pid, …)` in the finally block. Without this, the
116
+ // child is Python running mach; a SIGTERM kills Python but the
117
+ // Firefox grandchild inherits the stderr pipe FD and keeps Node's
118
+ // event loop alive even after the preflight PASS log. The symptom
119
+ // is `fireforge test --doctor` printing `Marionette preflight:
120
+ // PASS` and then hanging indefinitely in `uv__io_poll` — see the
121
+ // eval finding.
122
+ detached: true,
113
123
  });
114
124
  }
115
125
  catch (error) {
@@ -154,24 +164,21 @@ export async function runMarionettePreflight(engineDir, options = {}) {
154
164
  }
155
165
  finally {
156
166
  if (child && !hasChildExited(child)) {
157
- try {
158
- child.kill('SIGTERM');
159
- }
160
- catch {
161
- // Already exited — nothing to do.
162
- }
167
+ killProcessGroup(child, 'SIGTERM');
163
168
  // Small escalation: if the child doesn't honour SIGTERM quickly, SIGKILL
164
169
  // so we don't leave a ghost mach process around after a failed probe.
165
170
  await delay(500);
166
171
  if (!hasChildExited(child)) {
167
- try {
168
- child.kill('SIGKILL');
169
- }
170
- catch {
171
- // Already gone.
172
- }
172
+ killProcessGroup(child, 'SIGKILL');
173
173
  }
174
174
  }
175
+ // Destroy the stderr pipe explicitly. Firefox (a grandchild of the Python
176
+ // mach wrapper we spawned) can inherit and hold the stderr FD even after
177
+ // its direct parent exits — until the pipe closes, Node's readable
178
+ // stream stays attached and `uv__io_poll` keeps the event loop alive.
179
+ // `destroy()` closes the local end regardless, so `fireforge test
180
+ // --doctor` exits cleanly after a passing preflight.
181
+ child?.stderr?.destroy();
175
182
  try {
176
183
  await rm(profileDir, { recursive: true, force: true });
177
184
  }
@@ -180,6 +187,30 @@ export async function runMarionettePreflight(engineDir, options = {}) {
180
187
  }
181
188
  }
182
189
  }
190
+ /**
191
+ * Sends `signal` to the child's whole process group when possible, falling
192
+ * back to a direct `child.kill` for environments that don't support
193
+ * `detached: true` (Windows in particular: Node still returns a pid, but
194
+ * `kill(-pid, …)` is not a supported kernel primitive).
195
+ */
196
+ function killProcessGroup(child, signal) {
197
+ if (child.pid !== undefined && process.platform !== 'win32') {
198
+ try {
199
+ process.kill(-child.pid, signal);
200
+ return;
201
+ }
202
+ catch {
203
+ // ESRCH / EPERM — fall through to the narrow kill below so we at
204
+ // least signal the direct child.
205
+ }
206
+ }
207
+ try {
208
+ child.kill(signal);
209
+ }
210
+ catch {
211
+ // Already exited — nothing to do.
212
+ }
213
+ }
183
214
  function fail(layer, message, durationMs) {
184
215
  return {
185
216
  ok: false,
@@ -7,5 +7,16 @@ export declare function countPatches(patchesDir: string): Promise<number>;
7
7
  export declare function isNewFilePatch(patchPath: string): Promise<boolean>;
8
8
  /** Returns the first target file path referenced by a patch, if any. */
9
9
  export declare function getTargetFileFromPatch(patchPath: string): Promise<string | null>;
10
- /** Returns all target file paths referenced by a multi-file patch. */
10
+ /**
11
+ * Returns all target file paths referenced by a multi-file patch.
12
+ *
13
+ * Delegates to {@link extractAffectedFiles} so `GIT binary patch` sections
14
+ * (which have no `+++ b/…` line, only a `diff --git a/… b/…` header) are
15
+ * included alongside text hunks. Before this delegation the custom regex
16
+ * only matched `+++ b/…` lines, dropping every binary file from
17
+ * `filesAffected` — verify reported `files-affected-mismatch` against
18
+ * branding patches and `doctor --repair-patches-manifest` "repaired" the
19
+ * manifest by rewriting it to the text-only subset, hiding the true scope
20
+ * of the patch.
21
+ */
11
22
  export declare function getAllTargetFilesFromPatch(patchPath: string): Promise<string[]>;
@@ -2,7 +2,7 @@
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
4
  import { pathExists, readText } from '../utils/fs.js';
5
- import { extractOrder } from './patch-parse.js';
5
+ import { extractAffectedFiles, extractOrder } from './patch-parse.js';
6
6
  /** Discovers patch files in a directory and returns them in apply order. */
7
7
  export async function discoverPatches(patchesDir) {
8
8
  if (!(await pathExists(patchesDir))) {
@@ -35,17 +35,20 @@ export async function getTargetFileFromPatch(patchPath) {
35
35
  const match = /^\+\+\+ b\/(.+)$/m.exec(content);
36
36
  return match?.[1] ?? null;
37
37
  }
38
- /** Returns all target file paths referenced by a multi-file patch. */
38
+ /**
39
+ * Returns all target file paths referenced by a multi-file patch.
40
+ *
41
+ * Delegates to {@link extractAffectedFiles} so `GIT binary patch` sections
42
+ * (which have no `+++ b/…` line, only a `diff --git a/… b/…` header) are
43
+ * included alongside text hunks. Before this delegation the custom regex
44
+ * only matched `+++ b/…` lines, dropping every binary file from
45
+ * `filesAffected` — verify reported `files-affected-mismatch` against
46
+ * branding patches and `doctor --repair-patches-manifest` "repaired" the
47
+ * manifest by rewriting it to the text-only subset, hiding the true scope
48
+ * of the patch.
49
+ */
39
50
  export async function getAllTargetFilesFromPatch(patchPath) {
40
51
  const content = await readText(patchPath);
41
- const files = [];
42
- const regex = /^\+\+\+ b\/(.+)$/gm;
43
- let match;
44
- while ((match = regex.exec(content)) !== null) {
45
- if (match[1]) {
46
- files.push(match[1]);
47
- }
48
- }
49
- return files;
52
+ return extractAffectedFiles(content);
50
53
  }
51
54
  //# sourceMappingURL=patch-files.js.map
@@ -60,7 +60,28 @@ export function countNonBinaryDiffLines(diffContent) {
60
60
  const PATCH_LINE_THRESHOLDS = {
61
61
  general: { notice: 800, warning: 1500, error: 3000 },
62
62
  test: { notice: 1500, warning: 3000, error: 6000 },
63
+ /**
64
+ * Branding patches have a legitimate reason to be large: they include
65
+ * every locale's `brand.ftl`, copied upstream CSS/PNG assets, and the
66
+ * fork-specific `configure.sh` / `brand.properties` under a single
67
+ * `browser/branding/<name>/` subtree. The general hard limit of 3000
68
+ * lines fires on even a first-export branding patch (the eval saw 15904
69
+ * lines for a freshly-setup fork), which is loud but not actionable —
70
+ * the patch already is the minimum branding diff. A dedicated tier keeps
71
+ * the size guidance while moving the hard limit to a threshold that
72
+ * corresponds to "something other than branding is bundled in here too".
73
+ */
74
+ branding: { notice: 3000, warning: 8000, error: 20000 },
63
75
  };
76
+ /**
77
+ * Returns true when every file in a patch lives under `browser/branding/`.
78
+ * Used by `lintPatchSize` to pick the branding threshold tier.
79
+ */
80
+ function isBrandingOnlyPatch(files) {
81
+ if (files.length === 0)
82
+ return false;
83
+ return files.every((file) => file.startsWith('browser/branding/'));
84
+ }
64
85
  /**
65
86
  * Returns true if the filename looks like a JS/MJS/JSM file.
66
87
  * Handles `.sys.mjs` as well.
@@ -133,10 +154,18 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
133
154
  // Strip block comments before scanning
134
155
  const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
135
156
  // Check only introduced raw color values when diff context is available.
136
- // Skip files on the raw-color allowlist (exact path or basename match).
157
+ // Skip files on the raw-color allowlist (exact path or basename match) and
158
+ // auto-exempt files under `browser/branding/` — those are the fork's
159
+ // visual identity assets (app-about dialogs, installer pages, branded
160
+ // CSS copied from Firefox's `unofficial` template) and belong to the
161
+ // design-decision layer the design-token system does not govern.
162
+ // Without this auto-exemption, every first-time setup's copied CSS
163
+ // failed `raw-color-value` with no actionable fix other than manually
164
+ // listing each path in `rawColorAllowlist`.
137
165
  const allowlist = config?.patchLint?.rawColorAllowlist;
138
166
  const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
139
- if (!isAllowlisted) {
167
+ const isBranding = file.startsWith('browser/branding/');
168
+ if (!isAllowlisted && !isBranding) {
140
169
  // Strip lines with inline fireforge-ignore: raw-color-value suppression.
141
170
  // Check against rawCss (before comment stripping) so the CSS comment marker is still present.
142
171
  const sourceForSuppression = addedLinesByFile
@@ -239,14 +268,25 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
239
268
  continue;
240
269
  const content = await readText(filePath);
241
270
  const expectedHeader = getLicenseHeader(license, style);
242
- if (!content.startsWith(expectedHeader)) {
243
- issues.push({
244
- file,
245
- check: 'missing-license-header',
246
- message: `New file is missing the required ${license} license header.`,
247
- severity: 'error',
248
- });
249
- }
271
+ // Auto-exempt `browser/branding/` when the file carries ANY recognised
272
+ // license header in the matching comment style. The setup-generated
273
+ // branding directory is copied from Firefox's `unofficial` template,
274
+ // which arrives with Mozilla MPL-2.0 headers — those are legitimate
275
+ // for copyright purposes (the assets are Mozilla's) even when the
276
+ // fork's own code is 0BSD / EUPL-1.2 / GPL-2.0-or-later. The narrower
277
+ // license-match rule would force operators to either rewrite the
278
+ // copied headers (misrepresenting authorship) or suppress the lint
279
+ // with `--skip-lint` (hiding real issues elsewhere).
280
+ if (content.startsWith(expectedHeader))
281
+ continue;
282
+ if (file.startsWith('browser/branding/') && hasAnyLicenseHeader(content, style))
283
+ continue;
284
+ issues.push({
285
+ file,
286
+ check: 'missing-license-header',
287
+ message: `New file is missing the required ${license} license header.`,
288
+ severity: 'error',
289
+ });
250
290
  }
251
291
  return issues;
252
292
  }
@@ -402,8 +442,19 @@ export function lintPatchSize(filesAffected, lineCount) {
402
442
  severity: 'warning',
403
443
  });
404
444
  }
445
+ // Tier selection: test > branding > general. Tests keep their elevated
446
+ // thresholds because a big regression test is legitimate (table-driven
447
+ // harnesses run into the thousands of lines). Branding patches get their
448
+ // own tier so a first-export of setup-generated branding doesn't fire
449
+ // the general hard limit — see `PATCH_LINE_THRESHOLDS.branding` above
450
+ // for the eval data motivating this tier.
405
451
  const allTests = filesAffected.length > 0 && filesAffected.every(isTestFile);
406
- const thresholds = allTests ? PATCH_LINE_THRESHOLDS.test : PATCH_LINE_THRESHOLDS.general;
452
+ const branding = !allTests && isBrandingOnlyPatch(filesAffected);
453
+ const thresholds = allTests
454
+ ? PATCH_LINE_THRESHOLDS.test
455
+ : branding
456
+ ? PATCH_LINE_THRESHOLDS.branding
457
+ : PATCH_LINE_THRESHOLDS.general;
407
458
  if (lineCount >= thresholds.error) {
408
459
  issues.push({
409
460
  file: '(patch)',
@@ -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.2",
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",