@hominis/fireforge 0.16.3 → 0.17.0

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 (56) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +11 -3
  3. package/dist/src/commands/build.js +16 -7
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor.js +14 -1
  6. package/dist/src/commands/download.js +44 -13
  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-tests.js +9 -2
  12. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  13. package/dist/src/commands/furnace/create-readback.js +34 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  15. package/dist/src/commands/furnace/create-templates.js +11 -2
  16. package/dist/src/commands/furnace/create.js +2 -0
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/preview.d.ts +12 -0
  19. package/dist/src/commands/furnace/preview.js +34 -2
  20. package/dist/src/commands/furnace/rename.js +110 -0
  21. package/dist/src/commands/furnace/status.js +1 -1
  22. package/dist/src/commands/lint.js +55 -4
  23. package/dist/src/commands/patch/index.js +10 -1
  24. package/dist/src/commands/re-export.js +79 -6
  25. package/dist/src/commands/resolve.d.ts +25 -1
  26. package/dist/src/commands/resolve.js +40 -16
  27. package/dist/src/commands/run.js +27 -5
  28. package/dist/src/commands/status.js +100 -122
  29. package/dist/src/commands/test.js +23 -3
  30. package/dist/src/commands/token-coverage.js +55 -1
  31. package/dist/src/commands/token.js +12 -1
  32. package/dist/src/commands/wire.js +56 -10
  33. package/dist/src/core/config.d.ts +33 -0
  34. package/dist/src/core/config.js +43 -0
  35. package/dist/src/core/furnace-config.d.ts +23 -2
  36. package/dist/src/core/furnace-config.js +26 -3
  37. package/dist/src/core/mach-error-hints.js +16 -0
  38. package/dist/src/core/mach.d.ts +31 -0
  39. package/dist/src/core/mach.js +59 -6
  40. package/dist/src/core/marionette-port.d.ts +50 -0
  41. package/dist/src/core/marionette-port.js +215 -0
  42. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  43. package/dist/src/core/patch-manifest-consistency.js +16 -1
  44. package/dist/src/core/status-classify.d.ts +54 -0
  45. package/dist/src/core/status-classify.js +134 -0
  46. package/dist/src/core/token-dark-mode.d.ts +49 -0
  47. package/dist/src/core/token-dark-mode.js +182 -0
  48. package/dist/src/core/token-manager.js +17 -33
  49. package/dist/src/core/wire-destroy.js +18 -5
  50. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  51. package/dist/src/core/wire-dom-fragment.js +40 -0
  52. package/dist/src/core/wire-init.js +20 -5
  53. package/dist/src/core/wire-utils.d.ts +15 -0
  54. package/dist/src/core/wire-utils.js +17 -0
  55. package/dist/src/types/commands/options.d.ts +7 -0
  56. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Dark-mode insertion helpers for the tokens CSS scaffold.
4
+ *
5
+ * The 2026-04-21 eval reproduced a bug where `fireforge token add
6
+ * --mode override --dark-value ...` landed the dark declaration
7
+ * AFTER the nested `:root { }` inside the
8
+ * `@media (prefers-color-scheme: dark)` block had already closed,
9
+ * producing a declaration outside any rule block. The helpers here
10
+ * scan the comment-stripped source lines to find the *inner* `:root`
11
+ * block's closing `}` and return a line index the caller can splice
12
+ * into. When the inner `:root` is missing (a scaffold that drifted
13
+ * from the default), the fallback helper returns the outer `@media`
14
+ * block's close so the caller can materialise a fresh `:root` wrapper
15
+ * rather than dropping the dark value.
16
+ */
17
+ /**
18
+ * Strips the content of `/* ... *\/` block comments from an array of
19
+ * CSS source lines while preserving each line's length. Indexed scans
20
+ * over the returned mirror line up with the original, so callers that
21
+ * compute an insertion index against the stripped array can splice
22
+ * into the original array at the same index.
23
+ *
24
+ * We blank the comment body with spaces (rather than removing it) so
25
+ * any downstream consumer that indexes by column — or derives an
26
+ * insertion index as a line number in the original array — still
27
+ * agrees on line numbers.
28
+ */
29
+ export function stripBlockCommentsInLines(lines) {
30
+ const out = [];
31
+ let inBlockComment = false;
32
+ for (const original of lines) {
33
+ let line = '';
34
+ for (let i = 0; i < original.length; i++) {
35
+ if (inBlockComment) {
36
+ if (original[i] === '*' && original[i + 1] === '/') {
37
+ line += ' ';
38
+ i += 1;
39
+ inBlockComment = false;
40
+ }
41
+ else {
42
+ line += ' ';
43
+ }
44
+ }
45
+ else if (original[i] === '/' && original[i + 1] === '*') {
46
+ line += ' ';
47
+ i += 1;
48
+ inBlockComment = true;
49
+ }
50
+ else {
51
+ // `original[i]` is provably defined here (the bounds check is
52
+ // the loop condition), but TS narrows it to `string | undefined`.
53
+ // Default to empty string so the concat stays well-typed.
54
+ line += original[i] ?? '';
55
+ }
56
+ }
57
+ out.push(line);
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Finds the closing `}` line of the nested `:root { ... }` block inside
63
+ * a `@media (prefers-color-scheme: dark)` block. Returns `-1` when the
64
+ * media block exists but the nested `:root` block is missing; returns
65
+ * `null` when the `@media` block itself is absent.
66
+ *
67
+ * Runs the scan over a comment-stripped mirror of the source lines so
68
+ * braces inside CSS comments (`/* before { after *\/`) do not offset
69
+ * the depth counter. The scan is deliberately line-indexed so callers
70
+ * can splice into the original `lines` array at the returned index.
71
+ */
72
+ export function findDarkRootInsertionIndex(lines) {
73
+ const stripped = stripBlockCommentsInLines(lines);
74
+ let darkMediaLine = -1;
75
+ for (let i = 0; i < stripped.length; i++) {
76
+ if (/prefers-color-scheme:\s*dark/.test(stripped[i] ?? '')) {
77
+ darkMediaLine = i;
78
+ break;
79
+ }
80
+ }
81
+ if (darkMediaLine === -1)
82
+ return null;
83
+ // Walk the comment-stripped lines after the @media header and find
84
+ // the first `:root {` opener inside the block. The opening brace of
85
+ // the selector may live on the same line as the selector name or on
86
+ // the following line; either shape is tolerated.
87
+ let rootOpenLine = -1;
88
+ for (let i = darkMediaLine; i < stripped.length; i++) {
89
+ const line = stripped[i] ?? '';
90
+ if (/(^|[\s,{])\s*:root\b/.test(line)) {
91
+ // Brace on the same line?
92
+ if (/:root[^{}]*\{/.test(line)) {
93
+ rootOpenLine = i;
94
+ break;
95
+ }
96
+ // Otherwise scan forward for the opening brace, stopping at the
97
+ // first `}` or second selector that would mean the `:root`
98
+ // declaration never opened a block.
99
+ for (let j = i + 1; j < stripped.length; j++) {
100
+ const next = stripped[j] ?? '';
101
+ if (/\{/.test(next)) {
102
+ rootOpenLine = j;
103
+ break;
104
+ }
105
+ if (/[};]/.test(next))
106
+ break;
107
+ }
108
+ if (rootOpenLine !== -1)
109
+ break;
110
+ }
111
+ }
112
+ if (rootOpenLine === -1)
113
+ return -1;
114
+ // Depth-count starting from the `:root` opener. The first `{`
115
+ // encountered sets the entry depth to the initial counter value; the
116
+ // closing brace that returns to that depth terminates the block.
117
+ let depth = 0;
118
+ let entryDepth = 0;
119
+ let enteredBlock = false;
120
+ for (let i = rootOpenLine; i < stripped.length; i++) {
121
+ const line = stripped[i] ?? '';
122
+ for (const ch of line) {
123
+ if (ch === '{') {
124
+ depth++;
125
+ if (!enteredBlock) {
126
+ entryDepth = depth - 1;
127
+ enteredBlock = true;
128
+ }
129
+ }
130
+ else if (ch === '}') {
131
+ depth--;
132
+ }
133
+ }
134
+ if (enteredBlock && depth === entryDepth) {
135
+ return i;
136
+ }
137
+ }
138
+ return -1;
139
+ }
140
+ /**
141
+ * Finds the closing `}` of the outermost
142
+ * `@media (prefers-color-scheme: dark)` block. Used as the fallback
143
+ * landing site when the scaffold has no nested `:root { }` — the
144
+ * insertion helper uses this index to splice a brand-new `:root`
145
+ * wrapper containing the dark declaration, rather than dropping the
146
+ * value.
147
+ */
148
+ export function findDarkMediaCloseIndex(lines) {
149
+ const stripped = stripBlockCommentsInLines(lines);
150
+ let darkMediaLine = -1;
151
+ for (let i = 0; i < stripped.length; i++) {
152
+ if (/prefers-color-scheme:\s*dark/.test(stripped[i] ?? '')) {
153
+ darkMediaLine = i;
154
+ break;
155
+ }
156
+ }
157
+ if (darkMediaLine === -1)
158
+ return -1;
159
+ let depth = 0;
160
+ let entryDepth = 0;
161
+ let enteredBlock = false;
162
+ for (let i = darkMediaLine; i < stripped.length; i++) {
163
+ const line = stripped[i] ?? '';
164
+ for (const ch of line) {
165
+ if (ch === '{') {
166
+ depth++;
167
+ if (!enteredBlock) {
168
+ entryDepth = depth - 1;
169
+ enteredBlock = true;
170
+ }
171
+ }
172
+ else if (ch === '}') {
173
+ depth--;
174
+ }
175
+ }
176
+ if (enteredBlock && depth === entryDepth) {
177
+ return i;
178
+ }
179
+ }
180
+ return -1;
181
+ }
182
+ //# sourceMappingURL=token-dark-mode.js.map
@@ -10,6 +10,7 @@ import { validateTokenName } from '../utils/validation.js';
10
10
  import { getProjectPaths, loadConfig } from './config.js';
11
11
  import { loadFurnaceConfig } from './furnace-config.js';
12
12
  import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
13
+ import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
14
  /** Returns the token CSS path relative to engine root for a given binary name. */
14
15
  export function getTokensCssPath(binaryName) {
15
16
  return `browser/themes/shared/${binaryName}-tokens.css`;
@@ -271,41 +272,24 @@ function findCategorySection(lines, category, tokensCssPath) {
271
272
  function insertDarkModeOverride(lines, options) {
272
273
  if (options.mode !== 'override' || !options.darkValue)
273
274
  return;
274
- let darkMediaLine = -1;
275
- for (let i = 0; i < lines.length; i++) {
276
- if (/prefers-color-scheme:\s*dark/.test(lines[i] ?? '')) {
277
- darkMediaLine = i;
278
- break;
279
- }
280
- }
281
- if (darkMediaLine === -1)
275
+ const insertionIndex = findDarkRootInsertionIndex(lines);
276
+ if (insertionIndex === null)
277
+ return; // No @media block at all.
278
+ const darkEntry = ` ${options.tokenName}: ${options.darkValue};`;
279
+ if (insertionIndex === -1) {
280
+ // @media block exists but has no nested :root { } — the scaffold
281
+ // drifted. Warn and fall back to appending a fresh nested :root
282
+ // block right before the @media block's closing brace so the
283
+ // generated CSS still parses, rather than dropping the dark value
284
+ // on the floor or producing a declaration outside any rule.
285
+ warn(`Dark-mode override block for "${options.tokenName}" could not find a nested ":root { }" inside @media (prefers-color-scheme: dark). Appending a fresh ":root { }" block — review the tokens CSS scaffold.`);
286
+ const outerCloseIndex = findDarkMediaCloseIndex(lines);
287
+ if (outerCloseIndex === -1)
288
+ return;
289
+ lines.splice(outerCloseIndex, 0, ' :root {', darkEntry, ' }');
282
290
  return;
283
- // Find the closing } of the @media block
284
- let darkBlockEnd = lines.length;
285
- let depth = 0;
286
- let entryDepth = 0;
287
- let enteredBlock = false;
288
- for (let i = darkMediaLine; i < lines.length; i++) {
289
- const line = lines[i] ?? '';
290
- for (const ch of line) {
291
- if (ch === '{') {
292
- depth++;
293
- if (!enteredBlock) {
294
- entryDepth = depth - 1;
295
- enteredBlock = true;
296
- }
297
- }
298
- if (ch === '}')
299
- depth--;
300
- }
301
- if (enteredBlock && depth === entryDepth) {
302
- darkBlockEnd = i;
303
- break;
304
- }
305
291
  }
306
- // Insert the dark value before the closing }
307
- const darkEntry = ` ${options.tokenName}: ${options.darkValue};`;
308
- lines.splice(darkBlockEnd, 0, darkEntry);
292
+ lines.splice(insertionIndex, 0, darkEntry);
309
293
  }
310
294
  /**
311
295
  * Adds a token declaration to the CSS file in the correct category section.
@@ -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
  }
@@ -16,6 +16,23 @@ export declare function addDomFragmentTokenized(content: string, includeDirectiv
16
16
  * Legacy line-based implementation preserved as fallback.
17
17
  */
18
18
  export declare function legacyAddDomFragment(content: string, includeDirective: string): string;
19
+ /**
20
+ * Dry-run precheck for `addDomFragment`. Reads the resolved chrome
21
+ * document and verifies it either already contains the `#include`
22
+ * directive (the idempotent-skip case) OR offers a locatable insertion
23
+ * point via {@link addDomFragmentTokenized} / {@link legacyAddDomFragment}.
24
+ * Throws the same `Could not find insertion point in chrome document`
25
+ * error the real run would throw when neither condition holds.
26
+ *
27
+ * Motivating case (2026-04-21 eval, Finding #12): `fireforge wire ...
28
+ * --dry-run` previewed a plausible mutation plan against
29
+ * `tokenHostDocuments[0]`, then `fireforge wire ...` without
30
+ * `--dry-run` threw `Could not find insertion point in chrome document`
31
+ * on the same arguments. The real run had always called the insertion
32
+ * helpers; dry-run did not. This helper runs the same check in the
33
+ * preview pass so plan and execution disagree less.
34
+ */
35
+ export declare function probeDomFragmentInsertionPoint(engineDir: string, domFilePath: string, targetPath?: string): Promise<void>;
19
36
  /**
20
37
  * Inserts a `#include` directive for an `.inc.xhtml` file into the top-level
21
38
  * chrome document (default: `browser/base/content/browser.xhtml`), before
@@ -75,6 +75,46 @@ export function legacyAddDomFragment(content, includeDirective) {
75
75
  lines.splice(insertIndex, 0, includeDirective);
76
76
  return lines.join('\n');
77
77
  }
78
+ /**
79
+ * Dry-run precheck for `addDomFragment`. Reads the resolved chrome
80
+ * document and verifies it either already contains the `#include`
81
+ * directive (the idempotent-skip case) OR offers a locatable insertion
82
+ * point via {@link addDomFragmentTokenized} / {@link legacyAddDomFragment}.
83
+ * Throws the same `Could not find insertion point in chrome document`
84
+ * error the real run would throw when neither condition holds.
85
+ *
86
+ * Motivating case (2026-04-21 eval, Finding #12): `fireforge wire ...
87
+ * --dry-run` previewed a plausible mutation plan against
88
+ * `tokenHostDocuments[0]`, then `fireforge wire ...` without
89
+ * `--dry-run` threw `Could not find insertion point in chrome document`
90
+ * on the same arguments. The real run had always called the insertion
91
+ * helpers; dry-run did not. This helper runs the same check in the
92
+ * preview pass so plan and execution disagree less.
93
+ */
94
+ export async function probeDomFragmentInsertionPoint(engineDir, domFilePath, targetPath = DEFAULT_DOM_TARGET) {
95
+ const targetAbsPath = join(engineDir, targetPath);
96
+ if (!(await pathExists(targetAbsPath))) {
97
+ // The callers in `wire.ts` run their own existence probe before
98
+ // invoking this helper, but a well-behaved probe is paranoid — if
99
+ // something changed between the two checks, fail with the same
100
+ // error the real run would surface.
101
+ throw new GeneralError(`${targetPath} not found in engine`);
102
+ }
103
+ const safeDomFilePath = toRootRelativePath(engineDir, domFilePath);
104
+ const targetDir = dirname(targetPath);
105
+ const includePath = relative(targetDir, safeDomFilePath).replace(/\\/g, '/');
106
+ const includeDirective = `#include ${includePath}`;
107
+ const content = await readText(targetAbsPath);
108
+ if (new RegExp(`^${escapeRegex(includeDirective)}$`, 'm').test(content)) {
109
+ // Already wired — the real run would idempotent-skip here, so
110
+ // dry-run is allowed to proceed too.
111
+ return;
112
+ }
113
+ // Check the tokenised and legacy insertion paths symmetrically with
114
+ // the real run. Either helper returning without throwing is sufficient
115
+ // evidence that the real run can land the directive.
116
+ withParserFallback(() => addDomFragmentTokenized(content, includeDirective), () => legacyAddDomFragment(content, includeDirective), targetPath);
117
+ }
78
118
  /**
79
119
  * Inserts a `#include` directive for an `.inc.xhtml` file into the top-level
80
120
  * chrome document (default: `browser/base/content/browser.xhtml`), before
@@ -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.17.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",