@hominis/fireforge 0.16.5 → 0.18.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 +56 -0
- package/README.md +46 -24
- package/dist/src/commands/build.js +33 -10
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
- package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
- package/dist/src/commands/doctor-furnace.js +2 -0
- package/dist/src/commands/doctor-working-tree.d.ts +29 -0
- package/dist/src/commands/doctor-working-tree.js +93 -0
- package/dist/src/commands/doctor.js +23 -12
- package/dist/src/commands/export-all.js +11 -3
- package/dist/src/commands/export-shared.d.ts +7 -1
- package/dist/src/commands/export-shared.js +21 -3
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- 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/init.js +97 -9
- package/dist/src/commands/furnace/override.js +23 -13
- package/dist/src/commands/furnace/remove.js +8 -0
- package/dist/src/commands/furnace/rename.js +133 -4
- package/dist/src/commands/lint.js +70 -6
- package/dist/src/commands/patch/delete.js +4 -1
- package/dist/src/commands/patch/reorder.js +4 -1
- package/dist/src/commands/re-export-files.js +3 -1
- package/dist/src/commands/re-export.js +4 -1
- package/dist/src/commands/register.js +11 -0
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +68 -14
- package/dist/src/commands/token-coverage.js +10 -3
- package/dist/src/commands/wire.js +50 -8
- package/dist/src/core/browser-wire.js +21 -4
- package/dist/src/core/build-audit.js +10 -0
- 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/git-diff.js +21 -2
- package/dist/src/core/mach.d.ts +43 -6
- package/dist/src/core/mach.js +57 -7
- package/dist/src/core/manifest-rules.js +10 -1
- package/dist/src/core/manifest-tokenizers.d.ts +6 -0
- package/dist/src/core/manifest-tokenizers.js +28 -0
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-lint.d.ts +47 -2
- package/dist/src/core/patch-lint.js +89 -14
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +31 -3
- package/dist/src/core/patch-manifest-io.js +10 -0
- package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
- package/dist/src/core/patch-manifest-resolve.js +29 -2
- package/dist/src/core/patch-manifest-validate.js +25 -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-coverage.js +24 -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.d.ts +7 -3
- package/dist/src/core/wire-destroy.js +11 -6
- 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.d.ts +9 -3
- package/dist/src/core/wire-init.js +18 -6
- package/dist/src/core/wire-subscript.d.ts +7 -3
- package/dist/src/core/wire-subscript.js +11 -4
- package/dist/src/types/commands/patches.d.ts +23 -0
- package/dist/src/types/furnace.d.ts +9 -0
- package/dist/src/utils/parse.d.ts +7 -0
- package/dist/src/utils/parse.js +15 -0
- package/package.json +1 -1
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { isBrandingManagedPath } from '../core/branding.js';
|
|
4
1
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
2
|
import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
|
|
6
3
|
import { getHead, getStatusWithCodes, isGitRepository, isMissingHeadError } from '../core/git.js';
|
|
7
4
|
import { getUntrackedFilesInDir } from '../core/git-status.js';
|
|
8
5
|
import { isFileRegistered, matchesRegistrablePattern } from '../core/manifest-rules.js';
|
|
9
6
|
import { buildOwnershipTable, renderOwnershipTable } from '../core/ownership-table.js';
|
|
10
|
-
import { computePatchedContent } from '../core/patch-apply.js';
|
|
11
7
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath } from '../core/patch-lint.js';
|
|
12
8
|
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
9
|
+
import { classifyFiles, } from '../core/status-classify.js';
|
|
13
10
|
import { GeneralError } from '../errors/base.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { info, intro, outro, verbose, warn } from '../utils/logger.js';
|
|
11
|
+
import { FIREFORGE_TMP_PATH_PATTERN, pathExists } from '../utils/fs.js';
|
|
12
|
+
import { info, intro, outro, warn } from '../utils/logger.js';
|
|
17
13
|
/**
|
|
18
14
|
* Status code descriptions for git status.
|
|
19
15
|
*/
|
|
@@ -179,87 +175,27 @@ async function expandDirectoryEntries(files, engineDir) {
|
|
|
179
175
|
function filterFireForgeTempFiles(files) {
|
|
180
176
|
return files.filter((entry) => !FIREFORGE_TMP_PATH_PATTERN.test(entry.file));
|
|
181
177
|
}
|
|
182
|
-
/**
|
|
183
|
-
* Classifies files into patch-backed, unmanaged, or branding buckets.
|
|
184
|
-
*/
|
|
185
|
-
async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
|
|
186
|
-
const manifest = await loadPatchesManifest(patchesDir);
|
|
187
|
-
// Build set of all patch-claimed file paths
|
|
188
|
-
const patchClaimedFiles = new Set();
|
|
189
|
-
if (manifest) {
|
|
190
|
-
for (const patch of manifest.patches) {
|
|
191
|
-
for (const f of patch.filesAffected) {
|
|
192
|
-
patchClaimedFiles.add(f);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
const results = [];
|
|
197
|
-
for (const entry of files) {
|
|
198
|
-
// Branding check first
|
|
199
|
-
if (isBrandingManagedPath(entry.file, binaryName)) {
|
|
200
|
-
results.push({ ...entry, classification: 'branding' });
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
// Furnace-managed component paths
|
|
204
|
-
if (furnacePrefixes.size > 0) {
|
|
205
|
-
let isFurnace = false;
|
|
206
|
-
for (const prefix of furnacePrefixes) {
|
|
207
|
-
if (entry.file.startsWith(prefix)) {
|
|
208
|
-
isFurnace = true;
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (isFurnace) {
|
|
213
|
-
results.push({ ...entry, classification: 'furnace' });
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Not in any patch → unmanaged
|
|
218
|
-
if (!patchClaimedFiles.has(entry.file)) {
|
|
219
|
-
results.push({ ...entry, classification: 'unmanaged' });
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
// File is claimed by a patch — compare content
|
|
223
|
-
const primaryCode = getPrimaryStatusCode(entry.status);
|
|
224
|
-
if (primaryCode === 'D') {
|
|
225
|
-
// Deleted file: patch-backed only if patch expects deletion
|
|
226
|
-
const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
|
|
227
|
-
results.push({
|
|
228
|
-
...entry,
|
|
229
|
-
classification: expected === null ? 'patch-backed' : 'unmanaged',
|
|
230
|
-
});
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
// File exists on disk — compare actual vs expected
|
|
234
|
-
try {
|
|
235
|
-
const [expected, actual] = await Promise.all([
|
|
236
|
-
computePatchedContent(patchesDir, engineDir, entry.file),
|
|
237
|
-
readText(join(engineDir, entry.file)),
|
|
238
|
-
]);
|
|
239
|
-
results.push({
|
|
240
|
-
...entry,
|
|
241
|
-
classification: actual === expected ? 'patch-backed' : 'unmanaged',
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
catch (error) {
|
|
245
|
-
verbose(`Treating ${entry.file} as unmanaged because patch-backed classification failed: ${toError(error).message}`);
|
|
246
|
-
// If we can't read the file, treat as unmanaged
|
|
247
|
-
results.push({ ...entry, classification: 'unmanaged' });
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return results;
|
|
251
|
-
}
|
|
252
178
|
/**
|
|
253
179
|
* Renders classified file status as machine-readable JSON to stdout.
|
|
254
180
|
*/
|
|
255
181
|
async function renderJsonStatus(files, paths, projectRoot, binaryName) {
|
|
256
182
|
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
257
183
|
const classified = await classifyFiles(files, paths.engine, paths.patches, binaryName, furnacePrefixes);
|
|
258
|
-
const output = classified.map((f) =>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
184
|
+
const output = classified.map((f) => {
|
|
185
|
+
const entry = {
|
|
186
|
+
file: f.file,
|
|
187
|
+
status: f.status.trim(),
|
|
188
|
+
classification: f.classification,
|
|
189
|
+
};
|
|
190
|
+
// `claimedBy` is an optional field present only on conflict
|
|
191
|
+
// entries, so non-conflict output stays byte-identical to the
|
|
192
|
+
// pre-0.16.0 shape (no unconditional schema change for the
|
|
193
|
+
// 99% of entries that are not cross-patch conflicts).
|
|
194
|
+
if (f.classification === 'conflict' && f.claimedBy && f.claimedBy.length > 0) {
|
|
195
|
+
entry.claimedBy = [...f.claimedBy];
|
|
196
|
+
}
|
|
197
|
+
return entry;
|
|
198
|
+
});
|
|
263
199
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
264
200
|
}
|
|
265
201
|
/**
|
|
@@ -394,65 +330,107 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
394
330
|
// Patch-aware classification
|
|
395
331
|
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
396
332
|
const classified = await classifyFiles(files, paths.engine, paths.patches, config.binaryName, furnacePrefixes);
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
333
|
+
const buckets = {
|
|
334
|
+
conflict: classified.filter((f) => f.classification === 'conflict'),
|
|
335
|
+
unmanaged: classified.filter((f) => f.classification === 'unmanaged'),
|
|
336
|
+
patchBacked: classified.filter((f) => f.classification === 'patch-backed'),
|
|
337
|
+
branding: classified.filter((f) => f.classification === 'branding'),
|
|
338
|
+
furnace: classified.filter((f) => f.classification === 'furnace'),
|
|
339
|
+
};
|
|
401
340
|
// --unmanaged mode: only show unmanaged
|
|
402
341
|
if (options.unmanaged) {
|
|
403
|
-
|
|
404
|
-
if (unmanagedFiles.length > 0) {
|
|
405
|
-
printStatusGroups(unmanagedFiles);
|
|
406
|
-
await printUnregisteredWarnings(unmanagedFiles, projectRoot, config.binaryName);
|
|
407
|
-
}
|
|
408
|
-
else {
|
|
409
|
-
info('No unmanaged changes');
|
|
410
|
-
}
|
|
411
|
-
outro(unmanagedFiles.length === 0
|
|
412
|
-
? 'No unmanaged changes'
|
|
413
|
-
: `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
|
|
342
|
+
await renderUnmanagedOnly(buckets.unmanaged, files.length, projectRoot, config.binaryName);
|
|
414
343
|
return;
|
|
415
344
|
}
|
|
416
|
-
|
|
417
|
-
|
|
345
|
+
await renderDefaultStatus(files.length, buckets, projectRoot, config.binaryName);
|
|
346
|
+
}
|
|
347
|
+
async function renderUnmanagedOnly(unmanagedFiles, totalModified, projectRoot, binaryName) {
|
|
348
|
+
info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${totalModified} total modified):\n`);
|
|
418
349
|
if (unmanagedFiles.length > 0) {
|
|
419
|
-
warn('Unmanaged changes:');
|
|
420
350
|
printStatusGroups(unmanagedFiles);
|
|
421
|
-
await printUnregisteredWarnings(unmanagedFiles, projectRoot,
|
|
351
|
+
await printUnregisteredWarnings(unmanagedFiles, projectRoot, binaryName);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
info('No unmanaged changes');
|
|
355
|
+
}
|
|
356
|
+
outro(unmanagedFiles.length === 0
|
|
357
|
+
? 'No unmanaged changes'
|
|
358
|
+
: `${unmanagedFiles.length} unmanaged change${unmanagedFiles.length === 1 ? '' : 's'}`);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Renders the default five-bucket status display: conflicts first
|
|
362
|
+
* (they block export/import/rebase), then unmanaged, patch-backed,
|
|
363
|
+
* branding, and furnace-managed sections. Cross-bucket separators
|
|
364
|
+
* ensure the sections are visually distinct without trailing empty
|
|
365
|
+
* groups. Empty buckets are omitted — the very-empty case surfaces a
|
|
366
|
+
* single `No changes` line.
|
|
367
|
+
*/
|
|
368
|
+
async function renderDefaultStatus(totalModified, buckets, projectRoot, binaryName) {
|
|
369
|
+
const { conflict, unmanaged, patchBacked, branding, furnace } = buckets;
|
|
370
|
+
info(`${totalModified} modified file${totalModified === 1 ? '' : 's'}:\n`);
|
|
371
|
+
if (conflict.length > 0) {
|
|
372
|
+
// Surface cross-patch ownership conflicts at the top of the default
|
|
373
|
+
// output — they block export/import/rebase and want immediate
|
|
374
|
+
// attention. The `--ownership` view already renders the full table;
|
|
375
|
+
// here we just name the files and point the operator at the
|
|
376
|
+
// canonical recovery path.
|
|
377
|
+
warn('Cross-patch ownership conflicts (same file claimed by multiple patches):');
|
|
378
|
+
printStatusGroups(conflict);
|
|
379
|
+
for (const entry of conflict) {
|
|
380
|
+
if (entry.claimedBy && entry.claimedBy.length > 0) {
|
|
381
|
+
info(` ${entry.file} — claimed by ${entry.claimedBy.join(', ')}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
info('Run "fireforge status --ownership" for the full conflict table, then repartition with "fireforge re-export --files <paths> <patch>".');
|
|
422
385
|
}
|
|
423
|
-
if (
|
|
424
|
-
if (
|
|
386
|
+
if (unmanaged.length > 0) {
|
|
387
|
+
if (conflict.length > 0)
|
|
388
|
+
info('');
|
|
389
|
+
warn('Unmanaged changes:');
|
|
390
|
+
printStatusGroups(unmanaged);
|
|
391
|
+
await printUnregisteredWarnings(unmanaged, projectRoot, binaryName);
|
|
392
|
+
}
|
|
393
|
+
if (patchBacked.length > 0) {
|
|
394
|
+
if (conflict.length > 0 || unmanaged.length > 0)
|
|
425
395
|
info('');
|
|
426
396
|
warn('Patch-backed materialized changes:');
|
|
427
|
-
printStatusGroups(
|
|
397
|
+
printStatusGroups(patchBacked);
|
|
428
398
|
}
|
|
429
|
-
if (
|
|
430
|
-
if (
|
|
399
|
+
if (branding.length > 0) {
|
|
400
|
+
if (conflict.length > 0 || unmanaged.length > 0 || patchBacked.length > 0) {
|
|
431
401
|
info('');
|
|
402
|
+
}
|
|
432
403
|
warn('Tool-managed branding changes:');
|
|
433
|
-
printStatusGroups(
|
|
404
|
+
printStatusGroups(branding);
|
|
434
405
|
}
|
|
435
|
-
if (
|
|
436
|
-
if (
|
|
406
|
+
if (furnace.length > 0) {
|
|
407
|
+
if (conflict.length > 0 ||
|
|
408
|
+
unmanaged.length > 0 ||
|
|
409
|
+
patchBacked.length > 0 ||
|
|
410
|
+
branding.length > 0) {
|
|
437
411
|
info('');
|
|
412
|
+
}
|
|
438
413
|
warn('Furnace-managed component changes:');
|
|
439
|
-
printStatusGroups(
|
|
414
|
+
printStatusGroups(furnace);
|
|
440
415
|
}
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
416
|
+
if (conflict.length === 0 &&
|
|
417
|
+
unmanaged.length === 0 &&
|
|
418
|
+
patchBacked.length === 0 &&
|
|
419
|
+
branding.length === 0 &&
|
|
420
|
+
furnace.length === 0) {
|
|
445
421
|
info('No changes');
|
|
446
422
|
}
|
|
447
423
|
const parts = [];
|
|
448
|
-
if (
|
|
449
|
-
parts.push(`${
|
|
450
|
-
if (
|
|
451
|
-
parts.push(`${
|
|
452
|
-
if (
|
|
453
|
-
parts.push(`${
|
|
454
|
-
if (
|
|
455
|
-
parts.push(`${
|
|
424
|
+
if (conflict.length > 0)
|
|
425
|
+
parts.push(`${conflict.length} conflict`);
|
|
426
|
+
if (unmanaged.length > 0)
|
|
427
|
+
parts.push(`${unmanaged.length} unmanaged`);
|
|
428
|
+
if (patchBacked.length > 0)
|
|
429
|
+
parts.push(`${patchBacked.length} patch-backed`);
|
|
430
|
+
if (branding.length > 0)
|
|
431
|
+
parts.push(`${branding.length} branding`);
|
|
432
|
+
if (furnace.length > 0)
|
|
433
|
+
parts.push(`${furnace.length} furnace`);
|
|
456
434
|
outro(parts.join(', '));
|
|
457
435
|
}
|
|
458
436
|
/** Registers the status command on the CLI program. */
|
|
@@ -3,13 +3,14 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
5
|
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
|
|
6
|
+
import { assertMarionettePortAvailable } from '../core/marionette-port.js';
|
|
6
7
|
import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
|
|
7
8
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
8
9
|
import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
|
|
9
10
|
import { GeneralError } from '../errors/base.js';
|
|
10
11
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
11
12
|
import { pathExists } from '../utils/fs.js';
|
|
12
|
-
import { info, intro, outro, spinner, warn } from '../utils/logger.js';
|
|
13
|
+
import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
|
|
13
14
|
import { pickDefined } from '../utils/options.js';
|
|
14
15
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
15
16
|
async function assertTestPathsExist(engineDir, testPaths) {
|
|
@@ -46,6 +47,32 @@ function hasStaleBuildArtifactsSignal(output) {
|
|
|
46
47
|
return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
|
|
47
48
|
/browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Fork-module-not-registered signal. 2026-04-21 eval Finding #14:
|
|
52
|
+
* a hominis test failed with `Failed to load resource:///modules/hominis/
|
|
53
|
+
* HominisStore.sys.mjs`. The branding pattern happened to also match
|
|
54
|
+
* because the test harness printed a branding warning during its
|
|
55
|
+
* teardown, and the stale-build branch won by precedence — telling the
|
|
56
|
+
* operator to rebuild when the real fix is to register the module in
|
|
57
|
+
* the fork's `browser/modules/<binary>/moz.build`. Match a
|
|
58
|
+
* `resource:///modules/<binaryName>/` pattern so fork-owned module
|
|
59
|
+
* failures surface the right diagnosis.
|
|
60
|
+
*/
|
|
61
|
+
function hasForkModuleSignal(output, binaryName) {
|
|
62
|
+
const pattern = new RegExp(`Failed to load resource:\\/\\/\\/modules\\/${binaryName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\/`, 'i');
|
|
63
|
+
return pattern.test(output);
|
|
64
|
+
}
|
|
65
|
+
function buildForkModuleMessage(binaryName) {
|
|
66
|
+
return (`Test failed to load a fork-owned module at resource:///modules/${binaryName}/*.sys.mjs.\n\n` +
|
|
67
|
+
'This is almost always a module-registration issue, not a stale build. The fork module directory is missing an entry that maps its file into the resource URI tree, so `ChromeUtils.importESModule` cannot resolve it.\n\n' +
|
|
68
|
+
'Check that:\n' +
|
|
69
|
+
` - browser/modules/${binaryName}/moz.build lists the missing module in EXTRA_JS_MODULES.\n` +
|
|
70
|
+
` - browser/modules/moz.build references the ${binaryName}/ subdirectory (DIRS += [...]).\n` +
|
|
71
|
+
' - The last `fireforge build` (or `fireforge build --ui`) completed successfully against the current manifests. If the registration is new, the UI-faster build path may not pick it up — a full build may be required.\n\n' +
|
|
72
|
+
'Use `fireforge register browser/modules/' +
|
|
73
|
+
binaryName +
|
|
74
|
+
'/<file>.sys.mjs` to add the EXTRA_JS_MODULES entry if it is missing.');
|
|
75
|
+
}
|
|
49
76
|
// Detects the broader xpcshell symptom where every `resource:///modules/...`
|
|
50
77
|
// import fails — the signature of xpcshell running with the wrong app-dir on
|
|
51
78
|
// a manifest that sets `firefox-appdir = "browser"`. Checked AFTER the
|
|
@@ -86,13 +113,22 @@ function buildMochitestHttp3ServerMessage() {
|
|
|
86
113
|
" - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
|
|
87
114
|
'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
|
|
88
115
|
}
|
|
89
|
-
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted) {
|
|
116
|
+
function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName) {
|
|
90
117
|
if (result.exitCode === 0 || result.exitCode === 130)
|
|
91
118
|
return;
|
|
92
119
|
const combinedOutput = `${result.stdout}\n${result.stderr}`;
|
|
93
120
|
if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
|
|
94
121
|
throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
|
|
95
122
|
}
|
|
123
|
+
// Fork-owned module load failures must beat the branding stale-build
|
|
124
|
+
// branch: 2026-04-21 eval (Finding #14) saw a hominis test fail with
|
|
125
|
+
// `Failed to load resource:///modules/hominis/HominisStore.sys.mjs`
|
|
126
|
+
// while the harness teardown printed a branding warning that the old
|
|
127
|
+
// stale-build pattern matched, so the operator was told to rebuild
|
|
128
|
+
// when the real fix is to register the missing module.
|
|
129
|
+
if (hasForkModuleSignal(combinedOutput, binaryName)) {
|
|
130
|
+
throw new GeneralError(buildForkModuleMessage(binaryName));
|
|
131
|
+
}
|
|
96
132
|
// Branding-specific stale-build signals keep priority over the broader
|
|
97
133
|
// xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
|
|
98
134
|
// fails to resolve, the fix really is "rebuild", not "pass --app-path".
|
|
@@ -148,13 +184,16 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
148
184
|
throw new GeneralError(`Tests require a completed build. ${detail}\n\n` +
|
|
149
185
|
"Run 'fireforge build' first, then run 'fireforge test'.");
|
|
150
186
|
}
|
|
187
|
+
// Load the project config once so both the build and the port
|
|
188
|
+
// probe have access to `binaryName` (the port probe uses it to
|
|
189
|
+
// recognise a fork-branded browser holding the Marionette port).
|
|
190
|
+
const projectConfig = await loadConfig(projectRoot);
|
|
151
191
|
// Run incremental build if requested
|
|
152
192
|
if (options.build) {
|
|
153
|
-
|
|
154
|
-
await prepareBuildEnvironment(projectRoot, paths, config);
|
|
193
|
+
await prepareBuildEnvironment(projectRoot, paths, projectConfig);
|
|
155
194
|
const s = spinner('Running incremental build...');
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
195
|
+
const buildResult = await buildUI(paths.engine);
|
|
196
|
+
if (buildResult.exitCode !== 0) {
|
|
158
197
|
s.error('Pre-test build failed');
|
|
159
198
|
throw new BuildError('Pre-test build failed', 'mach build faster');
|
|
160
199
|
}
|
|
@@ -175,6 +214,15 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
175
214
|
warn(formatStaleBuildWarning(stale));
|
|
176
215
|
}
|
|
177
216
|
}
|
|
217
|
+
// Stale-browser probe: an interrupted earlier test run can leave a
|
|
218
|
+
// Firefox/ForgeFresh/Hominis instance listening on the Marionette
|
|
219
|
+
// control port, which breaks the next mach test launch with a
|
|
220
|
+
// bind error that points nowhere near the real cause. Raise a
|
|
221
|
+
// targeted refusal up front instead of letting mach surface the
|
|
222
|
+
// generic bind failure. 2026-04-21 eval (Finding #20): a stale
|
|
223
|
+
// `-marionette` process from `fresh/` poisoned a later test run in
|
|
224
|
+
// the sibling `hominis/` workspace.
|
|
225
|
+
await assertMarionettePortAvailable(undefined, { binaryName: projectConfig.binaryName });
|
|
178
226
|
// `--doctor` runs a short marionette handshake probe. When test paths are
|
|
179
227
|
// supplied the probe gates the mach test invocation (a FAIL bails out). When
|
|
180
228
|
// no paths are supplied this is the only step — it's the fastest way to tell
|
|
@@ -187,13 +235,19 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
187
235
|
if (!preflight.ok) {
|
|
188
236
|
throw new GeneralError('Marionette preflight reported FAIL — see output above.');
|
|
189
237
|
}
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
238
|
+
// Belt-and-suspenders: write the PASS footer via `success()`
|
|
239
|
+
// AND `outro()` AND a direct stdout write. The eval
|
|
240
|
+
// reproducibly captured the intro + info line but nothing
|
|
241
|
+
// after the preflight returned, which we believe is a
|
|
242
|
+
// non-TTY clack rendering quirk that occasionally swallows
|
|
243
|
+
// the last log line before process exit. `success()` routes
|
|
244
|
+
// through a different clack entry point than `info()`, and
|
|
245
|
+
// `process.stdout.write` bypasses clack entirely so the
|
|
246
|
+
// PASS status is always visible in the captured output.
|
|
247
|
+
const summary = `Marionette preflight: PASS (${preflight.durationMs}ms)`;
|
|
248
|
+
success(summary);
|
|
249
|
+
outro('Test completed');
|
|
250
|
+
process.stdout.write(`${summary}\n`);
|
|
197
251
|
return;
|
|
198
252
|
}
|
|
199
253
|
if (!preflight.ok) {
|
|
@@ -243,7 +297,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
243
297
|
catch (error) {
|
|
244
298
|
throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
|
|
245
299
|
}
|
|
246
|
-
handleNonZeroTestExit(result, normalizedPaths, appdirInjection);
|
|
300
|
+
handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
|
|
247
301
|
}
|
|
248
302
|
/**
|
|
249
303
|
* Resolves and (when applicable) appends an `--app-path=<abs>` arg to
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
4
4
|
import { furnaceConfigExists, loadFurnaceConfig } from '../core/furnace-config.js';
|
|
5
|
-
import {
|
|
5
|
+
import { isGitRepository } from '../core/git.js';
|
|
6
|
+
import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
|
|
6
7
|
import { measureTokenCoverage } from '../core/token-coverage.js';
|
|
7
8
|
import { getTokensCssPath } from '../core/token-manager.js';
|
|
8
9
|
import { GeneralError } from '../errors/base.js';
|
|
@@ -23,8 +24,14 @@ export async function tokenCoverageCommand(projectRoot) {
|
|
|
23
24
|
}
|
|
24
25
|
const config = await loadConfig(projectRoot);
|
|
25
26
|
const tokensCssPath = getTokensCssPath(config.binaryName);
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// Expand collapsed `?? dir/` untracked entries so untracked CSS files
|
|
28
|
+
// inside a new patch-added directory are included in coverage. Before
|
|
29
|
+
// this, an imported fork that added a new CSS tree saw "No modified
|
|
30
|
+
// CSS files" because `git status --porcelain` collapsed the directory
|
|
31
|
+
// and the file-extension filter could not see the .css inside.
|
|
32
|
+
const rawStatus = await getWorkingTreeStatus(paths.engine);
|
|
33
|
+
const expandedStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
|
|
34
|
+
const statusCssFiles = expandedStatus
|
|
28
35
|
.filter((f) => f.file.endsWith('.css') && f.file !== tokensCssPath)
|
|
29
36
|
.map((f) => f.file);
|
|
30
37
|
// Also scan CSS files deployed by Furnace custom components. Deployed
|
|
@@ -4,7 +4,7 @@ import { DEFAULT_BROWSER_SUBSCRIPT_DIR, wireSubscript } from '../core/browser-wi
|
|
|
4
4
|
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
|
-
import { DEFAULT_DOM_TARGET } from '../core/wire-dom-fragment.js';
|
|
7
|
+
import { DEFAULT_DOM_TARGET, probeDomFragmentInsertionPoint } from '../core/wire-dom-fragment.js';
|
|
8
8
|
import { coerceToCall, validateWireName as validateWireExpression } from '../core/wire-utils.js';
|
|
9
9
|
import { InvalidArgumentError } from '../errors/base.js';
|
|
10
10
|
import { toError } from '../utils/errors.js';
|
|
@@ -83,6 +83,34 @@ function validateWireName(name) {
|
|
|
83
83
|
'Path separators and parent-directory segments are not permitted.', 'name');
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Asserts that the resolved chrome document both exists on disk AND
|
|
88
|
+
* exposes an insertion anchor (`#include browser-sets.inc` or
|
|
89
|
+
* `<html:body>`) that `addDomFragment` can splice into. Fires the same
|
|
90
|
+
* check in dry-run and real-run mode, so the preview and execution
|
|
91
|
+
* agree on whether the target is wireable before any disk mutations
|
|
92
|
+
* happen. Before 0.16.0 this check only ran on the real branch, which
|
|
93
|
+
* let the dry-run produce a plausible-looking plan that the real run
|
|
94
|
+
* then refused with `Could not find insertion point in chrome document`.
|
|
95
|
+
*/
|
|
96
|
+
async function assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath) {
|
|
97
|
+
const paths = getProjectPaths(projectRoot);
|
|
98
|
+
if (!(await pathExists(join(paths.engine, domTargetPath)))) {
|
|
99
|
+
throw new InvalidArgumentError(`Chrome document not found in engine: ${domTargetPath}\n` +
|
|
100
|
+
'Set "tokenHostDocuments" in furnace.json (first entry is used by wire) ' +
|
|
101
|
+
'or pass --target <path>.', 'target');
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await probeDomFragmentInsertionPoint(paths.engine, domFilePath, domTargetPath);
|
|
105
|
+
}
|
|
106
|
+
catch (probeError) {
|
|
107
|
+
throw new InvalidArgumentError(`${probeError instanceof Error ? probeError.message : String(probeError)}\n` +
|
|
108
|
+
`The resolved chrome document ${domTargetPath} does not expose an insertion anchor ` +
|
|
109
|
+
'that `fireforge wire` recognises (`#include browser-sets.inc` or `<html:body>`). ' +
|
|
110
|
+
'Add one of those anchors to the chrome doc, or target a document that has them via ' +
|
|
111
|
+
'`--target <path>`.', 'target');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
86
114
|
/**
|
|
87
115
|
* Wires a chrome subscript into the browser.
|
|
88
116
|
*
|
|
@@ -192,14 +220,21 @@ export async function wireCommand(projectRoot, name, options = {}) {
|
|
|
192
220
|
}
|
|
193
221
|
const domTargetPath = await resolveDomTargetPath(projectRoot, normalizedTarget);
|
|
194
222
|
if (domFilePath) {
|
|
195
|
-
|
|
196
|
-
if (!options.dryRun && !(await pathExists(join(paths.engine, domTargetPath)))) {
|
|
197
|
-
throw new InvalidArgumentError(`Chrome document not found in engine: ${domTargetPath}\n` +
|
|
198
|
-
'Set "tokenHostDocuments" in furnace.json (first entry is used by wire) ' +
|
|
199
|
-
'or pass --target <path>.', 'target');
|
|
200
|
-
}
|
|
223
|
+
await assertDomTargetIsWireable(projectRoot, domFilePath, domTargetPath);
|
|
201
224
|
}
|
|
202
|
-
// Verify the subscript file exists in engine/ (skip for dry-run
|
|
225
|
+
// Verify the subscript file exists in engine/ (skip for dry-run:
|
|
226
|
+
// dry-run is meant to preview the mutation plan without requiring
|
|
227
|
+
// the subscript to already exist, matching the "plan before write"
|
|
228
|
+
// pattern operators rely on for setup scripts).
|
|
229
|
+
//
|
|
230
|
+
// Dry-run keeps the existence check advisory rather than fatal: the
|
|
231
|
+
// "wire first, create file after" workflow is a legitimate use of
|
|
232
|
+
// preview, but operators who run dry-run over a typo were surprised
|
|
233
|
+
// when the real command then refused with `Subscript file not
|
|
234
|
+
// found`. 2026-04-23 eval (Finding in eval 2): dry-run produced a
|
|
235
|
+
// plausible plan and the non-dry-run invocation then errored. The
|
|
236
|
+
// info line surfaces the mismatch in preview mode so the operator
|
|
237
|
+
// can act on the warning before re-running without --dry-run.
|
|
203
238
|
if (!options.dryRun) {
|
|
204
239
|
const paths = getProjectPaths(projectRoot);
|
|
205
240
|
const subscriptPath = join(paths.engine, subscriptDir, `${name}.js`);
|
|
@@ -208,6 +243,13 @@ export async function wireCommand(projectRoot, name, options = {}) {
|
|
|
208
243
|
'Create the file in engine/ before wiring.', 'name');
|
|
209
244
|
}
|
|
210
245
|
}
|
|
246
|
+
else {
|
|
247
|
+
const paths = getProjectPaths(projectRoot);
|
|
248
|
+
const subscriptPath = join(paths.engine, subscriptDir, `${name}.js`);
|
|
249
|
+
if (!(await pathExists(subscriptPath))) {
|
|
250
|
+
info(`Note: ${subscriptDir}/${name}.js does not exist yet — the real wire command will require it before writing. Create the file before re-running without --dry-run.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
211
253
|
if (options.dryRun) {
|
|
212
254
|
printWireDryRun(getProjectPaths(projectRoot).engine, name, subscriptDir, domFilePath, domTargetPath, options);
|
|
213
255
|
return;
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { GeneralError } from '../errors/base.js';
|
|
4
4
|
import { toError } from '../utils/errors.js';
|
|
5
|
+
import { verbose } from '../utils/logger.js';
|
|
5
6
|
import { toRootRelativePath } from '../utils/paths.js';
|
|
6
|
-
import { getProjectPaths } from './config.js';
|
|
7
|
+
import { getProjectPaths, loadConfig } from './config.js';
|
|
7
8
|
import { createRollbackJournal, restoreRollbackJournal, snapshotFile } from './furnace-rollback.js';
|
|
8
9
|
import { registerBrowserContent } from './manifest-register.js';
|
|
9
10
|
import { DEFAULT_DOM_TARGET } from './wire-dom-fragment.js';
|
|
@@ -63,18 +64,34 @@ export async function wireSubscript(root, name, options = {}) {
|
|
|
63
64
|
await snapshotFile(journal, join(engineDir, effectiveDomTargetPath));
|
|
64
65
|
}
|
|
65
66
|
await snapshotFile(journal, join(engineDir, 'browser/base/jar.mn'));
|
|
67
|
+
// Compute the project-scoped patch-lint marker (`// <BINARY>:`) so
|
|
68
|
+
// every wire mutator can stamp it into the emitted comment block.
|
|
69
|
+
// Without this, `lintModificationComments` trips
|
|
70
|
+
// `missing-modification-comment` on wire-generated edits the next
|
|
71
|
+
// time the operator exports — the same tool wrote the code and a
|
|
72
|
+
// sibling tool then rejected it (eval 1 Finding #9). A broken config
|
|
73
|
+
// should not block the wire, so the fallback marker keeps the
|
|
74
|
+
// previous lint-friendly default when the config cannot be loaded.
|
|
75
|
+
let marker = 'FIREFORGE:';
|
|
76
|
+
try {
|
|
77
|
+
const config = await loadConfig(root);
|
|
78
|
+
marker = `${config.binaryName.toUpperCase()}:`;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
verbose(`Using default wire marker because fireforge.json could not be loaded: ${toError(error).message}`);
|
|
82
|
+
}
|
|
66
83
|
try {
|
|
67
84
|
// 1. Add subscript to browser-main.js
|
|
68
|
-
const subscriptAdded = await addSubscriptToBrowserMain(engineDir, name);
|
|
85
|
+
const subscriptAdded = await addSubscriptToBrowserMain(engineDir, name, marker);
|
|
69
86
|
// 2. Add init expression to browser-init.js (if provided)
|
|
70
87
|
let initAdded = false;
|
|
71
88
|
if (options.init) {
|
|
72
|
-
initAdded = await addInitToBrowserInit(engineDir, options.init, options.after);
|
|
89
|
+
initAdded = await addInitToBrowserInit(engineDir, options.init, options.after, marker);
|
|
73
90
|
}
|
|
74
91
|
// 3. Add destroy expression to browser-init.js onUnload() (if provided)
|
|
75
92
|
let destroyAdded = false;
|
|
76
93
|
if (options.destroy) {
|
|
77
|
-
destroyAdded = await addDestroyToBrowserInit(engineDir, options.destroy);
|
|
94
|
+
destroyAdded = await addDestroyToBrowserInit(engineDir, options.destroy, marker);
|
|
78
95
|
}
|
|
79
96
|
// 4. Add #include directive to the top-level chrome document (if provided)
|
|
80
97
|
let domInserted = false;
|
|
@@ -94,6 +94,16 @@ export function isPackageablePath(sourcePath) {
|
|
|
94
94
|
}
|
|
95
95
|
if (BUILD_INPUT_BASENAMES.has(basename(sourcePath)))
|
|
96
96
|
return false;
|
|
97
|
+
// `.inc.xhtml` fragments are consumed via `#include` from a registered
|
|
98
|
+
// chrome document and resolved at packaging time — they never ship as
|
|
99
|
+
// a standalone packaged artifact. 2026-04-21 eval (Finding #11):
|
|
100
|
+
// `fireforge build --ui` after `wire --dom` flagged the wired
|
|
101
|
+
// `*.inc.xhtml` as "missing packaged artifact" even though
|
|
102
|
+
// `register` correctly refuses to register it and the operator
|
|
103
|
+
// followed the documented workflow. Mirror the same carve-out the
|
|
104
|
+
// register rules apply.
|
|
105
|
+
if (sourcePath.endsWith('.inc.xhtml'))
|
|
106
|
+
return false;
|
|
97
107
|
for (const ext of PACKAGEABLE_EXTENSIONS) {
|
|
98
108
|
if (sourcePath.endsWith(ext))
|
|
99
109
|
return true;
|
|
@@ -52,5 +52,38 @@ export declare function writeConfig(root: string, config: FireForgeConfig): Prom
|
|
|
52
52
|
* Writes a raw config document to fireforge.json.
|
|
53
53
|
* This is used by CLI `config --force`, where callers may intentionally write
|
|
54
54
|
* keys or value shapes outside the validated FireForgeConfig schema.
|
|
55
|
+
*
|
|
56
|
+
* Individual writes are atomic via {@link writeJson} (temp file + rename),
|
|
57
|
+
* but atomicity alone does not prevent lost updates across concurrent
|
|
58
|
+
* writers: each writer reads an old copy, mutates its own in-memory view,
|
|
59
|
+
* and writes it back, so the second writer's rename clobbers the first
|
|
60
|
+
* writer's changes. Callers that do read → mutate → write must hold
|
|
61
|
+
* {@link withConfigFileLock} for the full round-trip to serialise
|
|
62
|
+
* against other writers.
|
|
55
63
|
*/
|
|
56
64
|
export declare function writeConfigDocument(root: string, config: FireForgeConfig | Record<string, unknown>): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Runs an operation while holding a sidecar lock on `fireforge.json`.
|
|
67
|
+
*
|
|
68
|
+
* Motivating case (2026-04-21 eval): two concurrent `fireforge config
|
|
69
|
+
* <key> <value>` invocations each ran load → mutate → writeJson against
|
|
70
|
+
* the same on-disk fireforge.json. The second rename landed after the
|
|
71
|
+
* first, silently dropping the first writer's key — both commands exited
|
|
72
|
+
* `0`, but only one change survived. This helper turns the same
|
|
73
|
+
* read-modify-write sequence into a serialised operation so a concurrent
|
|
74
|
+
* writer now waits for the lock rather than racing on the document.
|
|
75
|
+
*
|
|
76
|
+
* Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: writers
|
|
77
|
+
* always use `writeJson`'s atomic temp-file + rename, so a reader observes
|
|
78
|
+
* either the pre- or post-write document but never a torn file. The lock
|
|
79
|
+
* only serialises writers against other writers.
|
|
80
|
+
*
|
|
81
|
+
* The lock is a sidecar directory `${config}.fireforge-config.lock`, and
|
|
82
|
+
* `withFileLock` handles stale-lock recovery (PID-alive probe, age-based
|
|
83
|
+
* fallback) — a crashed writer does not permanently block future writes.
|
|
84
|
+
*
|
|
85
|
+
* @param root - Root directory of the project
|
|
86
|
+
* @param operation - Async function to run while holding the lock
|
|
87
|
+
* @returns Whatever the operation returns
|
|
88
|
+
*/
|
|
89
|
+
export declare function withConfigFileLock<T>(root: string, operation: () => Promise<T>): Promise<T>;
|