@hominis/fireforge 0.16.2 → 0.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -1
- package/README.md +15 -2
- package/dist/bin/fireforge.js +11 -2
- package/dist/src/commands/doctor-furnace.js +83 -1
- package/dist/src/commands/doctor.js +18 -0
- package/dist/src/commands/download.js +58 -12
- 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-templates.d.ts +21 -3
- package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
- package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
- 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 +17 -7
- package/dist/src/commands/furnace/create-templates.js +85 -31
- package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
- package/dist/src/commands/furnace/create-xpcshell.js +1 -1
- 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/import.js +63 -11
- package/dist/src/commands/patch/delete.js +10 -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/setup-support.js +60 -7
- package/dist/src/commands/status.js +28 -1
- package/dist/src/commands/test.js +28 -5
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +19 -2
- package/dist/src/commands/wire.js +22 -2
- package/dist/src/core/branding.d.ts +10 -0
- package/dist/src/core/branding.js +7 -9
- package/dist/src/core/build-prepare.js +8 -1
- package/dist/src/core/file-lock.js +49 -15
- package/dist/src/core/furnace-operation.d.ts +17 -0
- package/dist/src/core/furnace-operation.js +30 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
- package/dist/src/core/furnace-validate-helpers.js +53 -2
- package/dist/src/core/git.js +39 -10
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.js +15 -6
- package/dist/src/core/manifest-rules.js +16 -0
- package/dist/src/core/marionette-preflight.js +43 -12
- package/dist/src/core/patch-files.d.ts +12 -1
- package/dist/src/core/patch-files.js +14 -11
- package/dist/src/core/patch-lint.js +62 -11
- 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
|
@@ -196,7 +196,7 @@ export async function furnaceStatusCommand(projectRoot, name) {
|
|
|
196
196
|
warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
-
info('Tip: run `furnace status <name>` for detailed component info, or `furnace --help` for all subcommands.');
|
|
199
|
+
info('Tip: run `fireforge furnace status <name>` for detailed component info, or `fireforge furnace --help` for all subcommands.');
|
|
200
200
|
outro('Status complete');
|
|
201
201
|
}
|
|
202
202
|
//# sourceMappingURL=status.js.map
|
|
@@ -165,6 +165,32 @@ async function checkEngineDrift(engineDir, baseCommit, forceImport) {
|
|
|
165
165
|
}
|
|
166
166
|
return true;
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Builds the set of patch filenames in scope when `--until <name>` is set.
|
|
170
|
+
* Accepts either the full filename (e.g. `001-foo.patch`) or the name
|
|
171
|
+
* without the `.patch` suffix (matching `applyPatchesWithContinue`'s
|
|
172
|
+
* `untilFilename` resolver).
|
|
173
|
+
*
|
|
174
|
+
* Returns an empty set when no match is found — the caller treats that as
|
|
175
|
+
* "no scope filter applies" so the import behaves identically to an
|
|
176
|
+
* unrecognised `--until` target (which `applyPatchesWithContinue` will
|
|
177
|
+
* later surface as a normal error).
|
|
178
|
+
*/
|
|
179
|
+
function buildUntilFilenameSet(patches, until) {
|
|
180
|
+
const set = new Set();
|
|
181
|
+
if (until === undefined)
|
|
182
|
+
return set;
|
|
183
|
+
const normalized = until.endsWith('.patch') ? until : `${until}.patch`;
|
|
184
|
+
const target = patches.find((p) => p.filename === until || p.filename === normalized);
|
|
185
|
+
if (!target)
|
|
186
|
+
return set;
|
|
187
|
+
for (const patch of patches) {
|
|
188
|
+
if (patch.order <= target.order) {
|
|
189
|
+
set.add(patch.filename);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return set;
|
|
193
|
+
}
|
|
168
194
|
/**
|
|
169
195
|
* Runs the import command to apply patches.
|
|
170
196
|
* @param projectRoot - Root directory of the project
|
|
@@ -200,20 +226,41 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
200
226
|
outro('Import complete (no patches)');
|
|
201
227
|
return;
|
|
202
228
|
}
|
|
203
|
-
|
|
229
|
+
// Load manifest early so we can scope the integrity / consistency checks to
|
|
230
|
+
// the `--until` subset. The manifest-consistency check stays global because
|
|
231
|
+
// structural manifest corruption (missing / duplicate rows) should block any
|
|
232
|
+
// import regardless of scope, but per-patch integrity and files-affected
|
|
233
|
+
// issues are legitimately skippable when the operator has asked to stop at
|
|
234
|
+
// an earlier patch.
|
|
235
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
236
|
+
const untilFilenameSet = buildUntilFilenameSet(manifest?.patches ?? [], options.until);
|
|
237
|
+
const scopedPatchCount = options.until !== undefined ? untilFilenameSet.size : patchCount;
|
|
238
|
+
info(`Found ${scopedPatchCount} patch${scopedPatchCount === 1 ? '' : 'es'} to apply${options.until !== undefined ? ` (up to ${options.until})` : ''}`);
|
|
204
239
|
const manifestConsistencyIssues = await validatePatchesManifestConsistency(paths.patches);
|
|
205
|
-
|
|
206
|
-
|
|
240
|
+
const scopedManifestIssues = options.until !== undefined
|
|
241
|
+
? manifestConsistencyIssues.filter((issue) =>
|
|
242
|
+
// Global (manifest-level) issues have no specific filename to scope
|
|
243
|
+
// against — a missing or unparseable patches.json blocks any
|
|
244
|
+
// import. Per-patch issues only block when the patch is in scope.
|
|
245
|
+
issue.code === 'manifest-missing' ||
|
|
246
|
+
issue.code === 'manifest-invalid' ||
|
|
247
|
+
untilFilenameSet.has(issue.filename))
|
|
248
|
+
: manifestConsistencyIssues;
|
|
249
|
+
if (scopedManifestIssues.length > 0) {
|
|
250
|
+
const issueSummary = scopedManifestIssues.map((issue) => issue.message).join('\n ');
|
|
207
251
|
throw new GeneralError('Patch manifest consistency check failed. Repair patches/patches.json before importing.\n' +
|
|
208
252
|
` ${issueSummary}\n\n` +
|
|
209
253
|
'Run "fireforge doctor --repair-patches-manifest" to rebuild the manifest from on-disk patch files.');
|
|
210
254
|
}
|
|
211
|
-
//
|
|
212
|
-
const manifest = await loadPatchesManifest(paths.patches);
|
|
255
|
+
// Version compatibility warnings (advisory only)
|
|
213
256
|
if (manifest) {
|
|
214
257
|
const config = await loadConfig(projectRoot);
|
|
215
258
|
const currentVersion = config.firefox.version;
|
|
216
259
|
for (const patch of manifest.patches) {
|
|
260
|
+
// Scope the advisory warnings too: an operator running with --until
|
|
261
|
+
// doesn't need to see version warnings for patches outside the range.
|
|
262
|
+
if (options.until !== undefined && !untilFilenameSet.has(patch.filename))
|
|
263
|
+
continue;
|
|
217
264
|
const warning = checkVersionCompatibility(patch.sourceEsrVersion, currentVersion);
|
|
218
265
|
if (warning) {
|
|
219
266
|
warn(`${patch.filename}: ${warning}`);
|
|
@@ -225,7 +272,15 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
225
272
|
// warn-and-continue behaviour hid the real root cause because import
|
|
226
273
|
// would later fail during patch application with a secondary, unrelated
|
|
227
274
|
// error that made diagnosis harder.
|
|
228
|
-
|
|
275
|
+
//
|
|
276
|
+
// Scope the surfaced issues to the `--until` range: a later patch with
|
|
277
|
+
// integrity problems should not block importing an earlier good subset,
|
|
278
|
+
// which is exactly what operators reach for when the tail of the queue
|
|
279
|
+
// is broken and they want to keep working against an earlier checkpoint.
|
|
280
|
+
const allIntegrityIssues = await validatePatchIntegrity(paths.patches, paths.engine);
|
|
281
|
+
const integrityIssues = options.until !== undefined
|
|
282
|
+
? allIntegrityIssues.filter((issue) => untilFilenameSet.has(issue.filename))
|
|
283
|
+
: allIntegrityIssues;
|
|
229
284
|
if (integrityIssues.length > 0) {
|
|
230
285
|
warn('\nPatch integrity issues detected:');
|
|
231
286
|
for (const issue of integrityIssues) {
|
|
@@ -253,11 +308,8 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
253
308
|
// Dry-run: list patches that would be applied and exit
|
|
254
309
|
if (isDryRun) {
|
|
255
310
|
if (manifest) {
|
|
256
|
-
const patches = options.until
|
|
257
|
-
? manifest.patches.filter((p) =>
|
|
258
|
-
const untilPatch = manifest.patches.find((u) => u.filename === options.until || u.filename === `${options.until}.patch`);
|
|
259
|
-
return untilPatch ? p.order <= untilPatch.order : true;
|
|
260
|
-
})
|
|
311
|
+
const patches = options.until !== undefined
|
|
312
|
+
? manifest.patches.filter((p) => untilFilenameSet.has(p.filename))
|
|
261
313
|
: manifest.patches;
|
|
262
314
|
info(`\n[dry-run] Would apply ${patches.length} patch(es) in order:`);
|
|
263
315
|
for (const patch of patches) {
|
|
@@ -114,7 +114,16 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
|
|
|
114
114
|
}
|
|
115
115
|
const conflicts = dependents.length > 0
|
|
116
116
|
? {
|
|
117
|
-
|
|
117
|
+
// Wording deliberately clarifies the *runtime* impact: `git apply`
|
|
118
|
+
// doesn't resolve imports and will succeed even when a later patch
|
|
119
|
+
// imports a file the target created (the eval observed this
|
|
120
|
+
// directly — forcing the delete and re-importing the remaining
|
|
121
|
+
// 20-patch queue was clean). The breakage surfaces at browser
|
|
122
|
+
// startup when `ChromeUtils.importESModule` can't locate the
|
|
123
|
+
// deleted module. Operators who deliberately plan to re-introduce
|
|
124
|
+
// the imported files (rename, refactor) need to know this is the
|
|
125
|
+
// impact model, not a patch-application failure.
|
|
126
|
+
reason: `${dependents.length} later patch(es) contain import statements that reference files created by ${target.filename}. Patch application itself will still succeed, but runtime imports will fail at browser startup until those files are re-introduced.`,
|
|
118
127
|
details: dependents,
|
|
119
128
|
}
|
|
120
129
|
: null;
|
|
@@ -20,7 +20,16 @@ export { patchReorderCommand } from './reorder.js';
|
|
|
20
20
|
export function registerPatch(program, context) {
|
|
21
21
|
const patch = program
|
|
22
22
|
.command('patch')
|
|
23
|
-
.description('Manage individual patches in the queue (compact, delete, reorder)')
|
|
23
|
+
.description('Manage individual patches in the queue (compact, delete, reorder)')
|
|
24
|
+
// Match `fireforge furnace`'s no-args contract: print the group's help and
|
|
25
|
+
// exit 0. Without this default action, commander routes `fireforge patch`
|
|
26
|
+
// (no subcommand) through its own help-then-exit-1 path, so scripts that
|
|
27
|
+
// probe the CLI surface see a misleading non-zero exit for a purely
|
|
28
|
+
// informational invocation. The action prints the exact same help commander
|
|
29
|
+
// would otherwise print, but returns successfully.
|
|
30
|
+
.action(() => {
|
|
31
|
+
patch.outputHelp();
|
|
32
|
+
});
|
|
24
33
|
registerPatchCompact(patch, context);
|
|
25
34
|
registerPatchDelete(patch, context);
|
|
26
35
|
registerPatchReorder(patch, context);
|
|
@@ -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;
|
|
@@ -263,6 +263,59 @@ export function buildSetupConfig(inputs) {
|
|
|
263
263
|
},
|
|
264
264
|
};
|
|
265
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Creates or updates the root `package.json` so its `license` field matches
|
|
268
|
+
* the project license selected during setup. When the file already exists we
|
|
269
|
+
* ONLY touch the `license` field — preserving `name`, `description`,
|
|
270
|
+
* `dependencies`, `scripts`, and every other author-editorial field the
|
|
271
|
+
* operator may have added. Without this sync, a `fireforge setup --force`
|
|
272
|
+
* that picked a new license left the old license in `package.json`, which
|
|
273
|
+
* then disagreed with `fireforge.json` (the motivating eval finding:
|
|
274
|
+
* setup rewrote fireforge.json but left the original package.json
|
|
275
|
+
* untouched, so the two files described different projects).
|
|
276
|
+
*
|
|
277
|
+
* Preserves the file's trailing newline state so a hand-edited
|
|
278
|
+
* `package.json` with a specific EOL convention is not silently
|
|
279
|
+
* re-normalised.
|
|
280
|
+
*/
|
|
281
|
+
async function syncRootPackageJson(projectRoot, license) {
|
|
282
|
+
const rootPackageJsonPath = join(projectRoot, 'package.json');
|
|
283
|
+
if (!(await pathExists(rootPackageJsonPath))) {
|
|
284
|
+
const rootPackageJson = {
|
|
285
|
+
private: true,
|
|
286
|
+
license,
|
|
287
|
+
};
|
|
288
|
+
await writeText(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 2) + '\n');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const raw = await readText(rootPackageJsonPath);
|
|
292
|
+
let parsed;
|
|
293
|
+
try {
|
|
294
|
+
parsed = JSON.parse(raw);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Malformed package.json is the operator's editorial responsibility to
|
|
298
|
+
// repair; rewriting it would risk clobbering hand-authored content that
|
|
299
|
+
// the parser happens to reject. Leave the file alone and rely on the
|
|
300
|
+
// doctor / lint paths that already surface invalid JSON.
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Treat the object as a typed shape with only the one field we modify.
|
|
307
|
+
// Keeping it narrowly typed rather than `Record<string, unknown>` avoids
|
|
308
|
+
// eslint's `dot-notation` / `noPropertyAccessFromIndexSignature`
|
|
309
|
+
// friction, and the rest of the package.json body is preserved via
|
|
310
|
+
// object spread at the write site so we don't lose author-editorial
|
|
311
|
+
// fields.
|
|
312
|
+
const packageJson = parsed;
|
|
313
|
+
if (packageJson.license === license) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const trailingNewline = raw.endsWith('\n') ? '\n' : '';
|
|
317
|
+
await writeText(rootPackageJsonPath, JSON.stringify({ ...packageJson, license }, null, 2) + trailingNewline);
|
|
318
|
+
}
|
|
266
319
|
/** Writes the initial project files produced by the setup workflow. */
|
|
267
320
|
export async function writeSetupProjectFiles(projectRoot, config) {
|
|
268
321
|
const paths = getProjectPaths(projectRoot);
|
|
@@ -291,13 +344,13 @@ export async function writeSetupProjectFiles(projectRoot, config) {
|
|
|
291
344
|
else {
|
|
292
345
|
await writeText(gitignorePath, requiredIgnores.join('\n') + '\n');
|
|
293
346
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
await
|
|
347
|
+
// FireForgeConfig types license as optional, but `buildSetupConfig` always
|
|
348
|
+
// fills it from the resolved setup inputs (which default to `EUPL-1.2`).
|
|
349
|
+
// Narrow explicitly so the helper takes a concrete license rather than
|
|
350
|
+
// widening its own signature for a field that is always set at this call
|
|
351
|
+
// site.
|
|
352
|
+
if (config.license !== undefined) {
|
|
353
|
+
await syncRootPackageJson(projectRoot, config.license);
|
|
301
354
|
}
|
|
302
355
|
const templatesDir = getTemplatesDir();
|
|
303
356
|
if (config.license !== undefined) {
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { isBrandingManagedPath } from '../core/branding.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
5
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
|
-
import { getStatusWithCodes, isGitRepository } from '../core/git.js';
|
|
6
|
+
import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
7
7
|
import { getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
8
|
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
9
9
|
import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
|
|
@@ -262,6 +262,32 @@ async function renderJsonStatus(files, paths, projectRoot, binaryName) {
|
|
|
262
262
|
}));
|
|
263
263
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
264
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Detects the "unborn HEAD" aftermath of an interrupted `fireforge download`
|
|
267
|
+
* — git init succeeded but the initial Firefox source commit was never
|
|
268
|
+
* created, so every file in engine/ reads as untracked. On a ~600 MB
|
|
269
|
+
* Firefox tree this would flood the output with hundreds of thousands of
|
|
270
|
+
* entries and a truncation warning, which is technically correct but not
|
|
271
|
+
* actionable. Throws a `GeneralError` with a single recovery banner
|
|
272
|
+
* pointing at `fireforge download --force`. `raw` / `json` modes skip the
|
|
273
|
+
* banner so their consumers see the structural failure in error form
|
|
274
|
+
* only.
|
|
275
|
+
*/
|
|
276
|
+
async function assertEngineHasBaselineCommit(engineDir, options) {
|
|
277
|
+
try {
|
|
278
|
+
await getHead(engineDir);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (!isMissingHeadError(err))
|
|
282
|
+
throw err;
|
|
283
|
+
const guidance = 'Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. Re-run "fireforge download --force" to recreate the baseline repository cleanly.';
|
|
284
|
+
if (!options.raw && !options.json) {
|
|
285
|
+
warn(guidance);
|
|
286
|
+
outro('Engine baseline missing — re-run download --force');
|
|
287
|
+
}
|
|
288
|
+
throw new GeneralError(guidance);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
265
291
|
/**
|
|
266
292
|
* Runs the status command to show modified files.
|
|
267
293
|
* @param projectRoot - Root directory of the project
|
|
@@ -331,6 +357,7 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
331
357
|
if (!(await isGitRepository(paths.engine))) {
|
|
332
358
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
333
359
|
}
|
|
360
|
+
await assertEngineHasBaselineCommit(paths.engine, options);
|
|
334
361
|
const rawFiles = await getStatusWithCodes(paths.engine);
|
|
335
362
|
const { entries: expanded, truncations } = await expandDirectoryEntries(rawFiles, paths.engine);
|
|
336
363
|
// Strip atomic-write temp files (Finding #18) before every mode
|
|
@@ -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) {
|
|
@@ -36,8 +36,14 @@ function buildStaleBuildMessage() {
|
|
|
36
36
|
'Re-run "fireforge build --ui" or "fireforge test --build" and then retry.');
|
|
37
37
|
}
|
|
38
38
|
function hasStaleBuildArtifactsSignal(output) {
|
|
39
|
+
// Deliberately narrow: only fire on branding-specific resource paths
|
|
40
|
+
// that are always a stale-artifact symptom. The earlier pattern also
|
|
41
|
+
// matched `resource:///modules/distribution.sys.mjs`, which surfaced on
|
|
42
|
+
// real packaging / module-resolution failures too (e.g. a fork's
|
|
43
|
+
// `HominisStore.sys.mjs` missing from the installed app dir after a
|
|
44
|
+
// successful build). That false-positive pushed operators toward
|
|
45
|
+
// "rebuild" advice for what was actually a module-registration issue.
|
|
39
46
|
return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
|
|
40
|
-
/resource:\/\/\/modules\/distribution\.sys\.mjs/i.test(output) ||
|
|
41
47
|
/browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
|
|
42
48
|
}
|
|
43
49
|
// Detects the broader xpcshell symptom where every `resource:///modules/...`
|
|
@@ -87,15 +93,25 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
87
93
|
if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
|
|
88
94
|
throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
|
|
89
95
|
}
|
|
96
|
+
// Branding-specific stale-build signals keep priority over the broader
|
|
97
|
+
// xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
|
|
98
|
+
// fails to resolve, the fix really is "rebuild", not "pass --app-path".
|
|
99
|
+
// But the stale-build check is now narrower — it no longer matches
|
|
100
|
+
// `resource:///modules/distribution.sys.mjs` alone, which was producing
|
|
101
|
+
// false-positive rebuild advice on fork-custom module-load failures
|
|
102
|
+
// (the eval saw this for `HominisStore.sys.mjs`). Cases that once
|
|
103
|
+
// landed on `distribution.sys.mjs` fall through to xpcshell-appdir,
|
|
104
|
+
// which is the more useful diagnosis in practice for `Failed to load
|
|
105
|
+
// resource:///modules/…`.
|
|
90
106
|
if (hasStaleBuildArtifactsSignal(combinedOutput)) {
|
|
91
107
|
throw new GeneralError(buildStaleBuildMessage());
|
|
92
108
|
}
|
|
93
|
-
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
94
|
-
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
95
|
-
}
|
|
96
109
|
if (hasXpcshellAppdirSignal(combinedOutput)) {
|
|
97
110
|
throw new GeneralError(buildXpcshellAppdirMessage(appdirInjectionAttempted));
|
|
98
111
|
}
|
|
112
|
+
if (hasMochitestHttp3ServerSignal(combinedOutput)) {
|
|
113
|
+
throw new GeneralError(buildMochitestHttp3ServerMessage());
|
|
114
|
+
}
|
|
99
115
|
if (/invalid filename/i.test(combinedOutput) ||
|
|
100
116
|
/chrome:\/\/mochitests.*not found/i.test(combinedOutput)) {
|
|
101
117
|
info('Hint: The test file may not be registered in browser.toml or jar.mn.');
|
|
@@ -171,6 +187,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
171
187
|
if (!preflight.ok) {
|
|
172
188
|
throw new GeneralError('Marionette preflight reported FAIL — see output above.');
|
|
173
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)`);
|
|
174
197
|
return;
|
|
175
198
|
}
|
|
176
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
|