@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.
- package/CHANGELOG.md +20 -1
- package/README.md +6 -0
- 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/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.js +15 -1
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/test.js +8 -1
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +22 -2
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.js +15 -6
- package/dist/src/core/wire-destroy.js +18 -5
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
filesAffected: diffFilesAffected,
|
|
112
126
|
sourceEsrVersion: config.firefox.version,
|
|
113
127
|
});
|
|
114
128
|
// Cleanup: Clear pendingResolution from state.json transactionally so
|
package/dist/src/commands/run.js
CHANGED
|
@@ -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
|
|
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 (
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/src/core/mach.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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} ${
|
|
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
|
}
|
|
@@ -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.
|