@hominis/fireforge 0.17.0 → 0.18.1

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 (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +60 -33
  3. package/dist/src/commands/build.js +18 -4
  4. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  5. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  6. package/dist/src/commands/doctor-furnace.js +2 -0
  7. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  8. package/dist/src/commands/doctor-working-tree.js +93 -0
  9. package/dist/src/commands/doctor.js +22 -12
  10. package/dist/src/commands/export-all.js +74 -4
  11. package/dist/src/commands/export-shared.d.ts +7 -1
  12. package/dist/src/commands/export-shared.js +21 -3
  13. package/dist/src/commands/furnace/create-xpcshell.js +4 -2
  14. package/dist/src/commands/furnace/override.js +23 -13
  15. package/dist/src/commands/furnace/preview.js +38 -0
  16. package/dist/src/commands/furnace/remove.js +75 -1
  17. package/dist/src/commands/furnace/rename-xpcshell.d.ts +35 -0
  18. package/dist/src/commands/furnace/rename-xpcshell.js +97 -0
  19. package/dist/src/commands/furnace/rename.js +32 -4
  20. package/dist/src/commands/lint.js +19 -6
  21. package/dist/src/commands/patch/delete.js +4 -1
  22. package/dist/src/commands/patch/reorder.js +4 -1
  23. package/dist/src/commands/re-export-files.js +3 -1
  24. package/dist/src/commands/re-export.js +4 -1
  25. package/dist/src/commands/rebase/index.js +19 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/status.js +44 -5
  28. package/dist/src/commands/test.js +68 -16
  29. package/dist/src/commands/token-coverage.js +10 -3
  30. package/dist/src/commands/verify.js +81 -6
  31. package/dist/src/commands/watch.js +43 -7
  32. package/dist/src/commands/wire.js +16 -0
  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/furnace-constants.d.ts +14 -0
  36. package/dist/src/core/furnace-constants.js +16 -0
  37. package/dist/src/core/furnace-validate.js +67 -1
  38. package/dist/src/core/git-base.d.ts +27 -2
  39. package/dist/src/core/git-base.js +41 -3
  40. package/dist/src/core/git-diff.js +21 -2
  41. package/dist/src/core/git.js +53 -14
  42. package/dist/src/core/mach.d.ts +26 -8
  43. package/dist/src/core/mach.js +24 -8
  44. package/dist/src/core/manifest-rules.js +10 -1
  45. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  46. package/dist/src/core/manifest-tokenizers.js +28 -0
  47. package/dist/src/core/marionette-preflight.d.ts +16 -0
  48. package/dist/src/core/marionette-preflight.js +19 -0
  49. package/dist/src/core/patch-lint-diff-tag.d.ts +20 -0
  50. package/dist/src/core/patch-lint-diff-tag.js +25 -0
  51. package/dist/src/core/patch-lint.d.ts +47 -2
  52. package/dist/src/core/patch-lint.js +94 -18
  53. package/dist/src/core/patch-manifest-consistency.js +15 -2
  54. package/dist/src/core/patch-manifest-io.js +10 -0
  55. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  56. package/dist/src/core/patch-manifest-resolve.js +29 -2
  57. package/dist/src/core/patch-manifest-validate.js +25 -1
  58. package/dist/src/core/patch-registration-refs.d.ts +42 -0
  59. package/dist/src/core/patch-registration-refs.js +117 -0
  60. package/dist/src/core/token-coverage.js +24 -0
  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-init.d.ts +9 -3
  64. package/dist/src/core/wire-init.js +18 -6
  65. package/dist/src/core/wire-subscript.d.ts +7 -3
  66. package/dist/src/core/wire-subscript.js +11 -4
  67. package/dist/src/core/xpcshell-appdir.d.ts +19 -5
  68. package/dist/src/core/xpcshell-appdir.js +46 -20
  69. package/dist/src/errors/git.d.ts +20 -0
  70. package/dist/src/errors/git.js +39 -0
  71. package/dist/src/types/commands/patches.d.ts +23 -0
  72. package/dist/src/types/furnace.d.ts +9 -0
  73. package/dist/src/utils/parse.d.ts +7 -0
  74. package/dist/src/utils/parse.js +15 -0
  75. package/package.json +1 -1
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * xpcshell scaffold rename helper extracted from `rename.ts`.
4
+ *
5
+ * 2026-04-24 eval Finding 5: `furnace create --with-tests --xpcshell`
6
+ * writes a scaffold at `browser/base/content/test/<binary>-xpcshell/
7
+ * <name>/` and `furnace rename` did not update it. The helper below
8
+ * renames the directory, updates the test filename, rewrites the
9
+ * `xpcshell.toml` section header, and re-writes the test body so word-
10
+ * boundary occurrences of the old tag / underscored name map to the new
11
+ * ones.
12
+ *
13
+ * Extracted to keep `rename.ts` under the per-file LOC budget —
14
+ * `rename.ts` already carries mochikit + browser-mochitest + FTL
15
+ * handling, and tacking xpcshell onto that tree pushed the file past
16
+ * the limit.
17
+ */
18
+ import { readdir } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { loadConfig } from '../../core/config.js';
21
+ import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
22
+ import { snapshotFile } from '../../core/furnace-rollback.js';
23
+ import { toError } from '../../utils/errors.js';
24
+ import { ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
25
+ import { info, warn } from '../../utils/logger.js';
26
+ /** Escapes regex metacharacters so a user-supplied name stays literal. */
27
+ function escapeRegex(input) {
28
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+ /**
31
+ * Renames an xpcshell test scaffold in place. Moves the directory,
32
+ * rewrites the test filename, updates the `[test_name]` section header
33
+ * in `xpcshell.toml`, and word-boundary-rewrites occurrences of the
34
+ * old tag / old underscored name inside the test body.
35
+ *
36
+ * Best-effort: any failure logs a warning through the shared logger
37
+ * but never throws — the component rename itself has already succeeded
38
+ * at this point, and blocking on a test rewrite would leave the
39
+ * operator with a half-renamed component.
40
+ *
41
+ * @param engineDir - Absolute path to the engine directory under the project.
42
+ * @param projectRoot - Absolute path to the project root, used to load the binary name.
43
+ * @param oldName - Pre-rename component tag name.
44
+ * @param newName - Post-rename component tag name.
45
+ * @param journal - Rollback journal that the rename mutation writes to before touching files.
46
+ */
47
+ export async function renameXpcshellTestFiles(engineDir, projectRoot, oldName, newName, journal) {
48
+ let forgeConfig;
49
+ try {
50
+ forgeConfig = await loadConfig(projectRoot);
51
+ }
52
+ catch {
53
+ return; // Cannot determine scaffold path without config.
54
+ }
55
+ const parentDir = join(engineDir, xpcshellTestParentDir(forgeConfig.binaryName));
56
+ if (!(await pathExists(parentDir)))
57
+ return;
58
+ const oldScaffoldDir = join(parentDir, oldName);
59
+ const newScaffoldDir = join(parentDir, newName);
60
+ if (!(await pathExists(oldScaffoldDir)))
61
+ return;
62
+ const oldUnderscored = oldName.replace(/-/g, '_');
63
+ const newUnderscored = newName.replace(/-/g, '_');
64
+ const oldTestFileName = `test_${oldUnderscored}_packaged.js`;
65
+ const newTestFileName = `test_${newUnderscored}_packaged.js`;
66
+ try {
67
+ await ensureDir(newScaffoldDir);
68
+ const entries = await readdir(oldScaffoldDir, { withFileTypes: true });
69
+ for (const entry of entries) {
70
+ if (!entry.isFile())
71
+ continue;
72
+ const oldFilePath = join(oldScaffoldDir, entry.name);
73
+ const renamedFileName = entry.name === oldTestFileName ? newTestFileName : entry.name;
74
+ const newFilePath = join(newScaffoldDir, renamedFileName);
75
+ await snapshotFile(journal, oldFilePath);
76
+ const body = await readText(oldFilePath);
77
+ let updated = body;
78
+ if (entry.name === 'xpcshell.toml') {
79
+ updated = updated.replace(new RegExp(`\\[${escapeRegex(`"${oldTestFileName}"`)}\\]`, 'g'), `["${newTestFileName}"]`);
80
+ }
81
+ else if (entry.name === oldTestFileName) {
82
+ const oldTagPattern = new RegExp(`(?<![\\w-])${escapeRegex(oldName)}(?![\\w-])`, 'g');
83
+ updated = updated.replace(oldTagPattern, newName);
84
+ const oldUnderscoredPattern = new RegExp(`(?<![\\w])${escapeRegex(oldUnderscored)}(?![\\w])`, 'g');
85
+ updated = updated.replace(oldUnderscoredPattern, newUnderscored);
86
+ }
87
+ await writeText(newFilePath, updated);
88
+ await removeFile(oldFilePath);
89
+ }
90
+ await removeDir(oldScaffoldDir);
91
+ info(`Renamed xpcshell scaffold directory: ${xpcshellTestParentDir(forgeConfig.binaryName)}/${oldName} → ${xpcshellTestParentDir(forgeConfig.binaryName)}/${newName}`);
92
+ }
93
+ catch (error) {
94
+ warn(`Could not rename xpcshell scaffold — ${toError(error).message}. Rename the scaffold files manually if needed.`);
95
+ }
96
+ }
97
+ //# sourceMappingURL=rename-xpcshell.js.map
@@ -3,9 +3,9 @@ import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { getProjectPaths, loadConfig } from '../../core/config.js';
5
5
  import { getFurnacePaths, loadFurnaceConfig, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
6
- import { isComponentSourceFile, resolveFtlDir, tagNameToClassName, } from '../../core/furnace-constants.js';
6
+ import { isComponentSourceFile, resolveFtlChromeSubPath, resolveFtlDir, resolveFtlLocaleJarMnPath, tagNameToClassName, } from '../../core/furnace-constants.js';
7
7
  import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
8
- import { addCustomElementRegistration, addJarMnEntries, removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
8
+ import { addCustomElementRegistration, addJarMnEntries, addLocaleFtlJarMnEntry, removeCustomElementRegistration, removeJarMnEntries, removeLocaleFtlJarMnEntry, } from '../../core/furnace-registration.js';
9
9
  import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
10
10
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
11
11
  import { getStoriesDir } from '../../core/furnace-stories.js';
@@ -14,6 +14,7 @@ import { FurnaceError } from '../../errors/furnace.js';
14
14
  import { toError } from '../../utils/errors.js';
15
15
  import { copyFile, ensureDir, pathExists, readText, removeDir, removeFile, writeText, } from '../../utils/fs.js';
16
16
  import { info, intro, note, outro, warn } from '../../utils/logger.js';
17
+ import { renameXpcshellTestFiles } from './rename-xpcshell.js';
17
18
  /** Escapes regex metacharacters so a user-supplied name is literal inside a RegExp. */
18
19
  function escapeRegex(input) {
19
20
  return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -272,7 +273,8 @@ async function performRenameMutations(args) {
272
273
  // 3. Update engine registrations (custom components only)
273
274
  if (isCustom && config.custom[newName]?.register && (await pathExists(args.engineDir))) {
274
275
  const ftlDir = resolveFtlDir(config.ftlBasePath);
275
- await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, journal);
276
+ const isLocalized = config.custom[newName].localized;
277
+ await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, isLocalized, journal);
276
278
  }
277
279
  // 4. Re-key furnace-state.json checksums from old name to new name
278
280
  await rekeyStateChecksums(args.projectRoot, componentType, oldName, newName);
@@ -298,6 +300,14 @@ async function performRenameMutations(args) {
298
300
  // either failed the test run outright or (worse) passed for the
299
301
  // wrong component.
300
302
  await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
303
+ // 2026-04-24 eval Finding 5: xpcshell scaffolds live in yet
304
+ // another tree (`browser/base/content/test/<binary>-xpcshell/
305
+ // <name>/`). Before this call, renaming a component scaffolded
306
+ // with `--with-tests --xpcshell` left a directory whose name
307
+ // still referenced the pre-rename component, plus a test file
308
+ // whose underscored name referenced the old tag — both of
309
+ // which then failed to match the new component.
310
+ await renameXpcshellTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
301
311
  // Clear the stale deployed component directory so the next
302
312
  // `furnace apply` is the single writer of the new name's
303
313
  // deployment. Without this, eval runs showed the old widget
@@ -358,7 +368,7 @@ async function rekeyStateChecksums(projectRoot, componentType, oldName, newName)
358
368
  return result;
359
369
  });
360
370
  }
361
- async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, journal) {
371
+ async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, isLocalized, journal) {
362
372
  const customElementsPath = join(engineDir, 'toolkit/content/customElements.js');
363
373
  const jarMnPath = join(engineDir, 'toolkit/content/jar.mn');
364
374
  if (await pathExists(customElementsPath)) {
@@ -386,6 +396,24 @@ async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ft
386
396
  await writeText(newFtlPath, ftlContent);
387
397
  await removeFile(oldFtlPath);
388
398
  }
399
+ // Re-wire the locale jar.mn chrome registration when the component is
400
+ // localized. Before this, `updateEngineRegistrations` renamed the .ftl
401
+ // file on disk but left the locale jar.mn pointing at
402
+ // `locale/.../${oldName}.ftl`, so `furnace validate` passed while the
403
+ // engine still carried a stale registration for the now-missing file
404
+ // (eval finding: stale old-name registration after rename).
405
+ if (isLocalized) {
406
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
407
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
408
+ if (chromeSubPath !== undefined && localeJarRel !== undefined) {
409
+ const localeJarAbs = join(engineDir, localeJarRel);
410
+ if (await pathExists(localeJarAbs)) {
411
+ await snapshotFile(journal, localeJarAbs);
412
+ await removeLocaleFtlJarMnEntry(engineDir, localeJarRel, oldName, chromeSubPath);
413
+ await addLocaleFtlJarMnEntry(engineDir, localeJarRel, newName, chromeSubPath);
414
+ }
415
+ }
416
+ }
389
417
  }
390
418
  /**
391
419
  * Renames a custom or override component atomically: updates directory name,
@@ -5,9 +5,9 @@ import { isBrandingManagedPath } from '../core/branding.js';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
6
  import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
7
7
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
- import { getModifiedFiles, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
+ import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
9
9
  import { extractAffectedFiles } from '../core/patch-apply.js';
10
- import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
10
+ import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
11
11
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
12
12
  import { loadPatchesManifest } from '../core/patch-manifest.js';
13
13
  import { GeneralError } from '../errors/base.js';
@@ -103,10 +103,17 @@ async function resolveLintDiff(engineDir, files, binaryName) {
103
103
  // survive in the patch queue as-is. The exclusion mirrors the
104
104
  // `branding` bucket in `fireforge status` so the two views stay
105
105
  // consistent.
106
+ //
107
+ // `expandUntrackedDirectoryEntries` promotes collapsed `?? dir/`
108
+ // status rows to individual file entries before the diff pass.
109
+ // Without it, a patch that introduces a new directory shows up as
110
+ // `?? browser/modules/<fork>/` and `getDiffForFilesAgainstHead`
111
+ // crashed with EISDIR reading the directory as if it were a file
112
+ // (eval finding: aggregate lint unusable on a real imported queue).
106
113
  if (binaryName) {
107
- const modified = await getModifiedFiles(engineDir);
108
- const untracked = await getUntrackedFiles(engineDir);
109
- const allPaths = [...new Set([...modified, ...untracked])];
114
+ const rawStatus = await getWorkingTreeStatus(engineDir);
115
+ const expanded = await expandUntrackedDirectoryEntries(engineDir, rawStatus);
116
+ const allPaths = [...new Set(expanded.map((entry) => entry.file))];
110
117
  const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
111
118
  const excludedCount = allPaths.length - nonBrandingPaths.length;
112
119
  if (excludedCount > 0) {
@@ -315,7 +322,13 @@ async function lintPerPatch(projectRoot, paths) {
315
322
  if (!diff.trim())
316
323
  continue;
317
324
  const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
318
- const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore);
325
+ const decision = resolvePatchSizeTier(existing, patch.tier);
326
+ if (decision.tier === 'branding') {
327
+ info(decision.source === 'explicit'
328
+ ? `${patch.filename}: branding threshold tier applied via patches.json \`tier: "branding"\` opt-in.`
329
+ : `${patch.filename}: branding threshold tier applied (all files under browser/branding/ plus registration siblings).`);
330
+ }
331
+ const patchIssues = await lintExportedPatch(paths.engine, existing, diff, config, ctx, ignore, patch.tier);
319
332
  for (const issue of patchIssues) {
320
333
  issues.push({ ...issue, file: `${patch.filename} :: ${issue.file}` });
321
334
  }
@@ -39,7 +39,10 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
39
39
  }
40
40
  const target = resolvePatchIdentifier(identifier, manifest.patches);
41
41
  if (!target) {
42
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
42
+ const available = manifest.patches
43
+ .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
44
+ .join(', ');
45
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
43
46
  }
44
47
  // Build the full queue context once so we can scan each patch's newFiles
45
48
  // without re-parsing for the dependency check below.
@@ -270,7 +270,10 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
270
270
  }
271
271
  const target = resolvePatchIdentifier(identifier, manifest.patches);
272
272
  if (!target) {
273
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
273
+ const available = manifest.patches
274
+ .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
275
+ .join(', ');
276
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
274
277
  }
275
278
  const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
276
279
  const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
@@ -75,8 +75,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
75
75
  // `lintIgnore` threads through so a shrink of an advisory-noisy-but-
76
76
  // intentional patch (branding bundle, localised-resource pack) does not
77
77
  // have to choose between `--skip-lint` (blunt) and the full rebase path.
78
+ // `target.tier` threads the explicit branding-threshold opt-in for
79
+ // the branding patch that also touches a non-allowlisted sibling.
78
80
  const ignoreChecks = target.lintIgnore?.length ? new Set(target.lintIgnore) : undefined;
79
- await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks);
81
+ await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, target.tier);
80
82
  // Project the cross-patch context: replace the target entry with its
81
83
  // would-be shrunken self (new diff + new newFiles + new
82
84
  // modifiedFileAdditions). The projected entry must repopulate both
@@ -201,8 +201,11 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
201
201
  // intentional patch (a cohesive branding bundle, a localised-resource
202
202
  // pack) without either `--skip-lint` (too blunt) or falling through to
203
203
  // the full `rebase` flow (which internally skips the lint pipeline).
204
+ // The paired `patch.tier` threads the explicit branding-threshold
205
+ // opt-in the same way, for the branding patch that also touches a
206
+ // non-allowlisted registration sibling.
204
207
  const ignoreChecks = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
205
- await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks);
208
+ await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, patch.tier);
206
209
  if (isDryRun) {
207
210
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
208
211
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { getProjectPaths, loadConfig } from '../../core/config.js';
14
14
  import { getFurnacePaths, updateFurnaceState } from '../../core/furnace-config.js';
15
- import { getHead, isGitRepository, resetChanges } from '../../core/git.js';
15
+ import { getHead, isGitRepository, isMissingHeadError, resetChanges } from '../../core/git.js';
16
16
  import { discoverPatches } from '../../core/patch-files.js';
17
17
  import { loadPatchesManifest } from '../../core/patch-manifest.js';
18
18
  import { hasActiveRebaseSession, saveRebaseSession } from '../../core/rebase-session.js';
@@ -40,6 +40,24 @@ async function handleFreshStart(projectRoot, options) {
40
40
  if (!(await isGitRepository(paths.engine))) {
41
41
  throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
42
42
  }
43
+ // 2026-04-24 eval Finding 11: `rebase --dry-run` used to print
44
+ // "Dry run complete" without validating that the engine had a valid
45
+ // HEAD. A previous `download --force` abort could leave `.git/`
46
+ // initialized but unborn (no baseline commit); the real rebase then
47
+ // failed immediately with `fatal: ambiguous argument 'HEAD'` on the
48
+ // first `git rev-parse HEAD` call. Replicate the same baseline check
49
+ // here so dry-run mirrors the real-run preconditions and operators
50
+ // cannot mistake a broken baseline for a ready-to-rebase tree.
51
+ try {
52
+ await getHead(paths.engine);
53
+ }
54
+ catch (err) {
55
+ if (isMissingHeadError(err)) {
56
+ throw new GeneralError('Engine repository has no baseline commit yet — a previous "fireforge download" was interrupted before git created the initial Firefox source commit. ' +
57
+ 'Re-run "fireforge download --force" to recreate the baseline repository cleanly, then retry the rebase.');
58
+ }
59
+ throw err;
60
+ }
43
61
  const config = await loadConfig(projectRoot);
44
62
  const currentVersion = config.firefox.version;
45
63
  const manifest = await loadPatchesManifest(paths.patches);
@@ -45,6 +45,17 @@ export async function registerCommand(projectRoot, filePath, options = {}) {
45
45
  }
46
46
  const result = await registerFile(projectRoot, engineRelativePath, options.dryRun, options.after);
47
47
  if (options.dryRun) {
48
+ // 2026-04-21 eval (Finding #8): dry-run always said "Would
49
+ // register" even when the rule's idempotency check already knew
50
+ // the entry was present, so automation read the plan as "work to
51
+ // do" and the following real run then reported "Already
52
+ // registered". Surface the idempotency decision in dry-run too so
53
+ // the plan mirrors the real command's outcome.
54
+ if (result.skipped) {
55
+ info(`[dry-run] Already registered: ${engineRelativePath} in ${result.manifest}`);
56
+ outro('Dry run complete');
57
+ return;
58
+ }
48
59
  info(`[dry-run] Would register ${engineRelativePath}`);
49
60
  info(` manifest: ${result.manifest}`);
50
61
  info(` entry: ${result.entry}`);
@@ -70,11 +70,39 @@ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
70
70
  if (newFiles.length === 0)
71
71
  return;
72
72
  const registrableFiles = newFiles.filter((f) => matchesRegistrablePattern(f.file, binaryName));
73
- const registrationChecks = await Promise.all(registrableFiles.map(async (f) => ({
74
- file: f.file,
75
- registered: await isFileRegistered(projectRoot, f.file),
76
- })));
77
- const unregistered = registrationChecks.filter((f) => !f.registered);
73
+ // `isFileRegistered` throws `GeneralError("Manifest not found: ...")` when a
74
+ // rule sees a file whose parent manifest does not yet exist on disk — e.g.
75
+ // a brand-new `browser/modules/<binary>/` directory with no `moz.build`.
76
+ // `status` is a read-only reporter; before 0.18.1 the rejected promise
77
+ // bubbled through `Promise.all` and exited status with code 1, breaking the
78
+ // "use status --unmanaged to discover new files before running register"
79
+ // workflow. We now bucket missing-manifest cases into a distinct warning
80
+ // list while still surfacing the same actionable signal. Other error
81
+ // shapes continue to propagate (permission denied, corrupt file, etc.) so
82
+ // we do not silently hide anything surprising.
83
+ const registrationChecks = await Promise.all(registrableFiles.map(async (f) => {
84
+ try {
85
+ return {
86
+ file: f.file,
87
+ registered: await isFileRegistered(projectRoot, f.file),
88
+ manifestMissing: false,
89
+ manifestMissingMessage: undefined,
90
+ };
91
+ }
92
+ catch (err) {
93
+ if (err instanceof GeneralError && /^Manifest not found:/i.test(err.message)) {
94
+ return {
95
+ file: f.file,
96
+ registered: false,
97
+ manifestMissing: true,
98
+ manifestMissingMessage: err.message,
99
+ };
100
+ }
101
+ throw err;
102
+ }
103
+ }));
104
+ const unregistered = registrationChecks.filter((f) => !f.registered && !f.manifestMissing);
105
+ const manifestMissing = registrationChecks.filter((f) => f.manifestMissing);
78
106
  if (unregistered.length > 0) {
79
107
  info('');
80
108
  warn('Potentially unregistered files:');
@@ -82,6 +110,17 @@ async function printUnregisteredWarnings(files, projectRoot, binaryName) {
82
110
  info(` ${f.file} — run 'fireforge register ${f.file}'`);
83
111
  }
84
112
  }
113
+ if (manifestMissing.length > 0) {
114
+ info('');
115
+ warn('Files whose registration manifest does not exist yet:');
116
+ for (const f of manifestMissing) {
117
+ // `manifestMissingMessage` is always the specific
118
+ // "Manifest not found: <path>" string when manifestMissing is
119
+ // true (see the catch branch above that sets them together).
120
+ info(` ${f.file} — ${f.manifestMissingMessage}`);
121
+ info(` Create the parent manifest, then run 'fireforge register ${f.file}'.`);
122
+ }
123
+ }
85
124
  }
86
125
  /**
87
126
  * Renders raw worktree status as machine-parseable porcelain-style output.
@@ -4,13 +4,13 @@ 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
6
  import { assertMarionettePortAvailable } from '../core/marionette-port.js';
7
- import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
7
+ import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
8
8
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
9
9
  import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xpcshell-appdir.js';
10
10
  import { GeneralError } from '../errors/base.js';
11
11
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
12
12
  import { pathExists } from '../utils/fs.js';
13
- import { info, intro, outro, spinner, warn } from '../utils/logger.js';
13
+ import { info, intro, outro, spinner, success, warn } from '../utils/logger.js';
14
14
  import { pickDefined } from '../utils/options.js';
15
15
  import { stripEnginePrefix } from '../utils/paths.js';
16
16
  async function assertTestPathsExist(engineDir, testPaths) {
@@ -47,6 +47,32 @@ function hasStaleBuildArtifactsSignal(output) {
47
47
  return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
48
48
  /browser\/branding\/[^/\s]+\/moz\.build/i.test(output));
49
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
+ }
50
76
  // Detects the broader xpcshell symptom where every `resource:///modules/...`
51
77
  // import fails — the signature of xpcshell running with the wrong app-dir on
52
78
  // a manifest that sets `firefox-appdir = "browser"`. Checked AFTER the
@@ -56,17 +82,22 @@ function hasXpcshellAppdirSignal(output) {
56
82
  return /Failed to load resource:\/\/\/modules\//i.test(output);
57
83
  }
58
84
  function buildXpcshellAppdirMessage(injectionAttempted) {
85
+ const isMacos = process.platform === 'darwin';
86
+ const macosNote = isMacos
87
+ ? 'Detected: macOS host. On macOS the xpcshell harness binds `-a` to `<obj>/dist/<App>.app/Contents/Resources` by default and frequently ignores `--app-path` overrides when the `.app` bundle is present — the surest fix is the `<appname>-appdir` migration below rather than trying to force a different path.\n\n'
88
+ : '';
59
89
  const triggerLines = injectionAttempted
60
- ? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or the harness was built against a layout FireForge cannot probe (omni.ja-packed tree, alternate `dist/` shape).\n\n'
90
+ ? 'FireForge auto-injected `--app-path=<absolute>` against the resolved obj-dir before mach test ran, but the failure persists. The injected path either does not match the appdir layout your harness expects, or (on macOS) the harness bound `-a` to the `.app/Contents/Resources` default and ignored the override.\n\n'
61
91
  : 'Likely triggers:\n' +
62
92
  ' - The nearest xpcshell.toml sets `firefox-appdir = "browser"` but the harness reads `<appname>-appdir` instead — the literal `firefox-appdir` directive is silently ignored on rebranded forks (appname != "firefox").\n' +
63
93
  ' - FireForge could not find an xpcshell.toml above the test path, so the auto-injection never ran.\n\n';
64
94
  return ('xpcshell failed to load core resource:///modules/*.sys.mjs imports.\n\n' +
65
95
  'This is the canonical symptom of xpcshell running with the wrong app directory: the runtime resolves `resource:///modules/` against the parent of the expected app root, so every `ChromeUtils.importESModule("resource:///modules/…")` throws.\n\n' +
96
+ macosNote +
66
97
  triggerLines +
67
98
  'Options:\n' +
68
- ' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly.\n' +
69
- ' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection).\n' +
99
+ ' - Add `<appname>-appdir = "browser"` alongside `firefox-appdir = "browser"` in the xpcshell.toml [DEFAULT] so the harness reads the appname-keyed value directly. This is the most reliable fix on rebranded macOS builds.\n' +
100
+ ' - Pass overrides through `fireforge test <path> --mach-arg="--app-path=<absolute>"` to inject the path verbatim (operator overrides always win over auto-injection, but see the macOS caveat above).\n' +
70
101
  ' - Remove `firefox-appdir = "browser"` from the xpcshell.toml [DEFAULT] and move browser-chrome dependencies into a browser-chrome mochitest (see `fireforge furnace create --test-style=browser-chrome`).\n' +
71
102
  ' - If the test only touches toolkit chrome (chrome://global/*), drop the `firefox-appdir` setting entirely — toolkit chrome is registered without it.');
72
103
  }
@@ -87,13 +118,22 @@ function buildMochitestHttp3ServerMessage() {
87
118
  " - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
88
119
  'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
89
120
  }
90
- function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted) {
121
+ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName) {
91
122
  if (result.exitCode === 0 || result.exitCode === 130)
92
123
  return;
93
124
  const combinedOutput = `${result.stdout}\n${result.stderr}`;
94
125
  if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
95
126
  throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
96
127
  }
128
+ // Fork-owned module load failures must beat the branding stale-build
129
+ // branch: 2026-04-21 eval (Finding #14) saw a hominis test fail with
130
+ // `Failed to load resource:///modules/hominis/HominisStore.sys.mjs`
131
+ // while the harness teardown printed a branding warning that the old
132
+ // stale-build pattern matched, so the operator was told to rebuild
133
+ // when the real fix is to register the missing module.
134
+ if (hasForkModuleSignal(combinedOutput, binaryName)) {
135
+ throw new GeneralError(buildForkModuleMessage(binaryName));
136
+ }
97
137
  // Branding-specific stale-build signals keep priority over the broader
98
138
  // xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
99
139
  // fails to resolve, the fix really is "rebuild", not "pass --app-path".
@@ -157,8 +197,8 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
157
197
  if (options.build) {
158
198
  await prepareBuildEnvironment(projectRoot, paths, projectConfig);
159
199
  const s = spinner('Running incremental build...');
160
- const buildExitCode = await buildUI(paths.engine);
161
- if (buildExitCode !== 0) {
200
+ const buildResult = await buildUI(paths.engine);
201
+ if (buildResult.exitCode !== 0) {
162
202
  s.error('Pre-test build failed');
163
203
  throw new BuildError('Pre-test build failed', 'mach build faster');
164
204
  }
@@ -193,20 +233,32 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
193
233
  // no paths are supplied this is the only step — it's the fastest way to tell
194
234
  // marionette-wedged apart from test-discovery-failure.
195
235
  if (options.doctor) {
236
+ // Write the "Running marionette preflight..." banner via
237
+ // `process.stdout.write` directly before `info()` so non-TTY captures
238
+ // always see the banner even if clack's renderer defers output in
239
+ // pipe mode. `info()` is still called so TTY users keep the normal
240
+ // clack box-drawing framing.
241
+ process.stdout.write('Running marionette preflight...\n');
196
242
  info('Running marionette preflight...');
197
243
  const preflight = await runMarionettePreflight(paths.engine);
244
+ // 2026-04-24 eval Finding 7: the pre-0.18.1 code used
245
+ // `success()` + `outro()` + a direct `process.stdout.write` as a
246
+ // belt-and-suspenders but still reproducibly dropped the PASS summary
247
+ // under non-TTY capture (observed: `tee`-wrapped eval output saw only
248
+ // the intro). The fix writes the authoritative PASS/FAIL line via
249
+ // `process.stdout.write` as the very first output after the probe
250
+ // returns, so the captured stream has an unambiguous summary no
251
+ // matter what clack does on top. The clack-rendered banner
252
+ // (`info`/`warn`) is retained so TTY users keep the visual framing.
253
+ const directLine = formatMarionettePreflightLine(preflight);
254
+ process.stdout.write(`${directLine}\n`);
198
255
  reportMarionettePreflight(preflight);
199
256
  if (testPaths.length === 0) {
200
257
  if (!preflight.ok) {
201
258
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
202
259
  }
203
- // Close the intro frame explicitly. Without an outro, clack's
204
- // grouped-output mode left the PASS line hanging inside an
205
- // unclosed tree — in the eval's non-TTY capture the info line
206
- // itself failed to render, so `test --doctor` looked like it had
207
- // exited silently after the spinner start line. The outro also
208
- // gives scripts a deterministic "done" marker to parse.
209
- outro(`Marionette preflight: PASS (${preflight.durationMs}ms)`);
260
+ success(directLine);
261
+ outro('Test completed');
210
262
  return;
211
263
  }
212
264
  if (!preflight.ok) {
@@ -256,7 +308,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
256
308
  catch (error) {
257
309
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
258
310
  }
259
- handleNonZeroTestExit(result, normalizedPaths, appdirInjection);
311
+ handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
260
312
  }
261
313
  /**
262
314
  * 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