@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. 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 { toError } from '../utils/errors.js';
15
- import { FIREFORGE_TMP_PATH_PATTERN, pathExists, readText } from '../utils/fs.js';
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
- file: f.file,
260
- status: f.status.trim(),
261
- classification: f.classification,
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 unmanagedFiles = classified.filter((f) => f.classification === 'unmanaged');
398
- const patchBackedFiles = classified.filter((f) => f.classification === 'patch-backed');
399
- const brandingFiles = classified.filter((f) => f.classification === 'branding');
400
- const furnaceFiles = classified.filter((f) => f.classification === 'furnace');
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
- info(`${unmanagedFiles.length} unmanaged file${unmanagedFiles.length === 1 ? '' : 's'} (${files.length} total modified):\n`);
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
- // Default mode: three-bucket display
417
- info(`${files.length} modified file${files.length === 1 ? '' : 's'}:\n`);
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, config.binaryName);
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 (patchBackedFiles.length > 0) {
424
- if (unmanagedFiles.length > 0)
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(patchBackedFiles);
397
+ printStatusGroups(patchBacked);
428
398
  }
429
- if (brandingFiles.length > 0) {
430
- if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0)
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(brandingFiles);
404
+ printStatusGroups(branding);
434
405
  }
435
- if (furnaceFiles.length > 0) {
436
- if (unmanagedFiles.length > 0 || patchBackedFiles.length > 0 || brandingFiles.length > 0)
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(furnaceFiles);
414
+ printStatusGroups(furnace);
440
415
  }
441
- if (unmanagedFiles.length === 0 &&
442
- patchBackedFiles.length === 0 &&
443
- brandingFiles.length === 0 &&
444
- furnaceFiles.length === 0) {
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 (unmanagedFiles.length > 0)
449
- parts.push(`${unmanagedFiles.length} unmanaged`);
450
- if (patchBackedFiles.length > 0)
451
- parts.push(`${patchBackedFiles.length} patch-backed`);
452
- if (brandingFiles.length > 0)
453
- parts.push(`${brandingFiles.length} branding`);
454
- if (furnaceFiles.length > 0)
455
- parts.push(`${furnaceFiles.length} furnace`);
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
- const config = await loadConfig(projectRoot);
154
- await prepareBuildEnvironment(projectRoot, paths, config);
193
+ await prepareBuildEnvironment(projectRoot, paths, projectConfig);
155
194
  const s = spinner('Running incremental build...');
156
- const buildExitCode = await buildUI(paths.engine);
157
- if (buildExitCode !== 0) {
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
- // 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)`);
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 { getStatusWithCodes, isGitRepository } from '../core/git.js';
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
- const files = await getStatusWithCodes(paths.engine);
27
- const statusCssFiles = files
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
- const paths = getProjectPaths(projectRoot);
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>;