@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.
- package/CHANGELOG.md +39 -1
- package/README.md +11 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +40 -16
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +23 -3
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +56 -10
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +59 -6
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-destroy.js +18 -5
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/dist/src/core/wire-init.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- 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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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} ${
|
|
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
|
-
` ${
|
|
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 —
|
|
95
|
-
|
|
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} ${
|
|
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}${
|
|
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 —
|
|
196
|
-
|
|
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.
|