@hominis/fireforge 0.17.0 → 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 (51) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +40 -20
  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 +9 -11
  10. package/dist/src/commands/export-all.js +11 -3
  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/override.js +23 -13
  14. package/dist/src/commands/furnace/remove.js +8 -0
  15. package/dist/src/commands/furnace/rename.js +23 -4
  16. package/dist/src/commands/lint.js +19 -6
  17. package/dist/src/commands/patch/delete.js +4 -1
  18. package/dist/src/commands/patch/reorder.js +4 -1
  19. package/dist/src/commands/re-export-files.js +3 -1
  20. package/dist/src/commands/re-export.js +4 -1
  21. package/dist/src/commands/register.js +11 -0
  22. package/dist/src/commands/test.js +53 -12
  23. package/dist/src/commands/token-coverage.js +10 -3
  24. package/dist/src/commands/wire.js +16 -0
  25. package/dist/src/core/browser-wire.js +21 -4
  26. package/dist/src/core/build-audit.js +10 -0
  27. package/dist/src/core/git-diff.js +21 -2
  28. package/dist/src/core/mach.d.ts +12 -6
  29. package/dist/src/core/mach.js +12 -6
  30. package/dist/src/core/manifest-rules.js +10 -1
  31. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  32. package/dist/src/core/manifest-tokenizers.js +28 -0
  33. package/dist/src/core/patch-lint.d.ts +47 -2
  34. package/dist/src/core/patch-lint.js +89 -14
  35. package/dist/src/core/patch-manifest-consistency.js +15 -2
  36. package/dist/src/core/patch-manifest-io.js +10 -0
  37. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  38. package/dist/src/core/patch-manifest-resolve.js +29 -2
  39. package/dist/src/core/patch-manifest-validate.js +25 -1
  40. package/dist/src/core/token-coverage.js +24 -0
  41. package/dist/src/core/wire-destroy.d.ts +7 -3
  42. package/dist/src/core/wire-destroy.js +11 -6
  43. package/dist/src/core/wire-init.d.ts +9 -3
  44. package/dist/src/core/wire-init.js +18 -6
  45. package/dist/src/core/wire-subscript.d.ts +7 -3
  46. package/dist/src/core/wire-subscript.js +11 -4
  47. package/dist/src/types/commands/patches.d.ts +23 -0
  48. package/dist/src/types/furnace.d.ts +9 -0
  49. package/dist/src/utils/parse.d.ts +7 -0
  50. package/dist/src/utils/parse.js +15 -0
  51. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { getProjectPaths, loadConfig } from '../core/config.js';
5
5
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
6
  import { hasChanges, isGitRepository } from '../core/git.js';
7
7
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
- import { getWorkingTreeStatus } from '../core/git-status.js';
8
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
9
9
  import { extractAffectedFiles } from '../core/patch-apply.js';
10
10
  import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
11
11
  import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
@@ -41,7 +41,14 @@ async function resolveFurnaceExclusionPolicy(paths, projectRoot, excludeFurnace)
41
41
  const prefixes = await collectFurnaceManagedPrefixes(projectRoot);
42
42
  if (prefixes.size === 0)
43
43
  return new Set();
44
- const changedFiles = await getWorkingTreeStatus(paths.engine);
44
+ // Expand collapsed `?? dir/` entries before matching against Furnace
45
+ // prefixes — otherwise a Furnace-introduced directory slips past the
46
+ // filter and later lands in the non-Furnace path list that feeds the
47
+ // aggregate diff, where `getDiffForFilesAgainstHead` crashes with
48
+ // EISDIR (eval finding: export-all unusable on a fresh project with
49
+ // Furnace scaffolding).
50
+ const rawStatus = await getWorkingTreeStatus(paths.engine);
51
+ const changedFiles = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
45
52
  const furnaceManagedFiles = changedFiles
46
53
  .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
47
54
  .filter((file) => [...prefixes].some((prefix) => file.startsWith(prefix)));
@@ -129,7 +136,8 @@ export async function exportAllCommand(projectRoot, options = {}) {
129
136
  // output shape aligned with the single-file `export` command.
130
137
  let diff;
131
138
  if (furnaceExcluded.size > 0) {
132
- const allChanged = await getWorkingTreeStatus(paths.engine);
139
+ const rawChanged = await getWorkingTreeStatus(paths.engine);
140
+ const allChanged = await expandUntrackedDirectoryEntries(paths.engine, rawChanged);
133
141
  const nonFurnacePaths = [
134
142
  ...new Set(allChanged
135
143
  .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
@@ -17,8 +17,14 @@ import type { SpinnerHandle } from '../utils/logger.js';
17
17
  * `--skip-lint` when exactly one advisory rule does not apply to a
18
18
  * specific patch — e.g. `large-patch-lines` on a cohesive branding
19
19
  * bundle that genuinely cannot be split.
20
+ * @param patchTier - Optional explicit tier override (threaded from
21
+ * `PatchMetadata.tier`). Forces the branding-tier thresholds when
22
+ * set, independent of the auto-detect allowlist. When the branding
23
+ * tier is applied (either via this opt-in or the auto-detect), a
24
+ * single `info()` line surfaces the choice so the tier decision is
25
+ * visible rather than silent.
20
26
  */
21
- export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>): Promise<void>;
27
+ export declare function runPatchLint(engineDir: string, filesAffected: string[], diffContent: string, config: FireForgeConfig, skipLint?: boolean, patchQueueCtx?: import('../core/patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>, patchTier?: 'branding'): Promise<void>;
22
28
  /**
23
29
  * Resolves patch metadata interactively or from flags, with shared validation.
24
30
  * @param options - Export command options
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { confirm, select, text } from '@clack/prompts';
4
4
  import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-headers.js';
5
5
  import { findAllPatchesForFiles } from '../core/patch-export.js';
6
- import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, } from '../core/patch-lint.js';
6
+ import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
7
  import { loadPatchesManifest } from '../core/patch-manifest.js';
8
8
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
9
9
  import { pathExists, readText } from '../utils/fs.js';
@@ -24,9 +24,27 @@ import { isValidPatchCategory, PATCH_CATEGORIES, validatePatchName } from '../ut
24
24
  * `--skip-lint` when exactly one advisory rule does not apply to a
25
25
  * specific patch — e.g. `large-patch-lines` on a cohesive branding
26
26
  * bundle that genuinely cannot be split.
27
+ * @param patchTier - Optional explicit tier override (threaded from
28
+ * `PatchMetadata.tier`). Forces the branding-tier thresholds when
29
+ * set, independent of the auto-detect allowlist. When the branding
30
+ * tier is applied (either via this opt-in or the auto-detect), a
31
+ * single `info()` line surfaces the choice so the tier decision is
32
+ * visible rather than silent.
27
33
  */
28
- export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx, ignoreChecks) {
29
- const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks);
34
+ export async function runPatchLint(engineDir, filesAffected, diffContent, config, skipLint, patchQueueCtx, ignoreChecks, patchTier) {
35
+ // Compute the tier decision independently of the lint pipeline so the
36
+ // decision can be surfaced even when the rule body emitted no issues
37
+ // (e.g. a branding patch under the soft threshold still benefits from
38
+ // operators knowing which tier governed the run). The same helper is
39
+ // reused inside `lintPatchSize`, so the surfaced tier and the tier
40
+ // that actually drove the thresholds never drift.
41
+ const tierDecision = resolvePatchSizeTier(filesAffected, patchTier);
42
+ if (tierDecision.tier === 'branding') {
43
+ info(tierDecision.source === 'explicit'
44
+ ? 'Lint: branding threshold tier applied via patches.json `tier: "branding"` opt-in.'
45
+ : 'Lint: branding threshold tier applied (patch is all under browser/branding/ plus registration siblings).');
46
+ }
47
+ const issues = await lintExportedPatch(engineDir, filesAffected, diffContent, config, patchQueueCtx, ignoreChecks, patchTier);
30
48
  if (issues.length === 0)
31
49
  return;
32
50
  const errors = issues.filter((i) => i.severity === 'error');
@@ -79,17 +79,14 @@ async function copyOverrideFiles(engineDir, srcDir, destDir, componentName, hasF
79
79
  return copiedFiles;
80
80
  }
81
81
  /**
82
- * Writes override metadata to disk and updates furnace.json with the new override entry.
83
- * @param projectRoot - Root directory of the project
84
- * @param destDir - Override component directory
85
- * @param componentName - Component tag name
86
- * @param overrideType - Override mode that was created
87
- * @param description - Human-readable override description
88
- * @param details - Source component metadata from the engine scan
89
- * @param firefoxVersion - Firefox version recorded in the workspace config
90
- * @param config - Mutable Furnace config object to update
82
+ * Writes override metadata to disk and updates furnace.json with the new
83
+ * override entry. Re-reads the current on-disk furnace.json inside the
84
+ * operation lock and splices the new entry onto the fresh state so two
85
+ * concurrent `furnace override` commands cannot race their read-modify
86
+ * -write cycles into a single surviving entry (eval 2: parallel overrides
87
+ * both reported success but furnace.json kept only the second writer).
91
88
  */
92
- async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, config, journal, baseCommit) {
89
+ async function saveOverrideConfig(projectRoot, destDir, componentName, overrideType, description, details, firefoxVersion, journal, baseCommit) {
93
90
  const overrideJson = {
94
91
  type: overrideType,
95
92
  description,
@@ -100,14 +97,27 @@ async function saveOverrideConfig(projectRoot, destDir, componentName, overrideT
100
97
  const overrideJsonPath = join(destDir, 'override.json');
101
98
  await snapshotFile(journal, overrideJsonPath);
102
99
  await writeJson(overrideJsonPath, overrideJson);
103
- config.overrides[componentName] = {
100
+ // Re-read the current furnace.json inside the lock. The outer caller
101
+ // loaded a snapshot before entering `runFurnaceMutation`, but another
102
+ // furnace mutation (override / init / sync) may have landed in between
103
+ // — writing back the stale snapshot would drop that concurrent write.
104
+ const freshConfig = await loadAuthoringFurnaceConfig(projectRoot);
105
+ freshConfig.overrides[componentName] = {
104
106
  type: overrideType,
105
107
  description,
106
108
  basePath: details.sourcePath,
107
109
  baseVersion: firefoxVersion,
108
110
  ...(baseCommit ? { baseCommit } : {}),
109
111
  };
110
- await writeFurnaceConfig(projectRoot, config);
112
+ // Promote from the stock bucket here, against the fresh state, so the
113
+ // stock→override transition survives even when another concurrent
114
+ // override already rewrote furnace.json between the outer read and
115
+ // this write.
116
+ const stockIndex = freshConfig.stock.indexOf(componentName);
117
+ if (stockIndex !== -1) {
118
+ freshConfig.stock.splice(stockIndex, 1);
119
+ }
120
+ await writeFurnaceConfig(projectRoot, freshConfig);
111
121
  }
112
122
  /**
113
123
  * Performs the transactional mutation phase of furnace override under the
@@ -123,7 +133,7 @@ async function performOverrideMutations(args) {
123
133
  try {
124
134
  const filesCopied = await copyOverrideFiles(args.engineDir, args.srcDir, args.destDir, args.componentName, args.details.hasFTL, args.overrideType, args.ftlDir, journal);
125
135
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
126
- await saveOverrideConfig(args.projectRoot, args.destDir, args.componentName, args.overrideType, args.description, args.details, args.firefoxVersion, args.config, journal, args.baseCommit);
136
+ await saveOverrideConfig(args.projectRoot, args.destDir, args.componentName, args.overrideType, args.description, args.details, args.firefoxVersion, journal, args.baseCommit);
127
137
  return filesCopied;
128
138
  }
129
139
  catch (error) {
@@ -3,6 +3,7 @@ import { readdir, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { confirm } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig } from '../../core/config.js';
6
+ import { removeCustomFtlJarMnEntry } from '../../core/furnace-apply-ftl.js';
6
7
  import { extractComponentChecksums, getOverrideEngineTargetPath, isOverrideCopyCandidate, restoreOverrideFileToBaseline, } from '../../core/furnace-apply-helpers.js';
7
8
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, writeFurnaceConfig, } from '../../core/furnace-config.js';
8
9
  import { resolveFtlDir } from '../../core/furnace-constants.js';
@@ -353,6 +354,13 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
353
354
  await removeFile(ftlPath);
354
355
  info(`Deleted localized file engine/${ftlRel}`);
355
356
  }
357
+ // Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
358
+ // wrote during deploy — otherwise the engine is left with a
359
+ // `locale/.../${name}.ftl` entry pointing at a file we just
360
+ // deleted. 2026-04-21 eval (Finding #1): `furnace remove` left
361
+ // `browser/locales/jar.mn` referencing the missing FTL, which
362
+ // would break the next package-manifest validation.
363
+ await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
356
364
  }
357
365
  }
358
366
  let testCleanupFailures = [];
@@ -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';
@@ -272,7 +272,8 @@ async function performRenameMutations(args) {
272
272
  // 3. Update engine registrations (custom components only)
273
273
  if (isCustom && config.custom[newName]?.register && (await pathExists(args.engineDir))) {
274
274
  const ftlDir = resolveFtlDir(config.ftlBasePath);
275
- await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, journal);
275
+ const isLocalized = config.custom[newName].localized;
276
+ await updateEngineRegistrations(args.engineDir, oldName, newName, newDir, ftlDir, isLocalized, journal);
276
277
  }
277
278
  // 4. Re-key furnace-state.json checksums from old name to new name
278
279
  await rekeyStateChecksums(args.projectRoot, componentType, oldName, newName);
@@ -358,7 +359,7 @@ async function rekeyStateChecksums(projectRoot, componentType, oldName, newName)
358
359
  return result;
359
360
  });
360
361
  }
361
- async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, journal) {
362
+ async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ftlDir, isLocalized, journal) {
362
363
  const customElementsPath = join(engineDir, 'toolkit/content/customElements.js');
363
364
  const jarMnPath = join(engineDir, 'toolkit/content/jar.mn');
364
365
  if (await pathExists(customElementsPath)) {
@@ -386,6 +387,24 @@ async function updateEngineRegistrations(engineDir, oldName, newName, newDir, ft
386
387
  await writeText(newFtlPath, ftlContent);
387
388
  await removeFile(oldFtlPath);
388
389
  }
390
+ // Re-wire the locale jar.mn chrome registration when the component is
391
+ // localized. Before this, `updateEngineRegistrations` renamed the .ftl
392
+ // file on disk but left the locale jar.mn pointing at
393
+ // `locale/.../${oldName}.ftl`, so `furnace validate` passed while the
394
+ // engine still carried a stale registration for the now-missing file
395
+ // (eval finding: stale old-name registration after rename).
396
+ if (isLocalized) {
397
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
398
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
399
+ if (chromeSubPath !== undefined && localeJarRel !== undefined) {
400
+ const localeJarAbs = join(engineDir, localeJarRel);
401
+ if (await pathExists(localeJarAbs)) {
402
+ await snapshotFile(journal, localeJarAbs);
403
+ await removeLocaleFtlJarMnEntry(engineDir, localeJarRel, oldName, chromeSubPath);
404
+ await addLocaleFtlJarMnEntry(engineDir, localeJarRel, newName, chromeSubPath);
405
+ }
406
+ }
407
+ }
389
408
  }
390
409
  /**
391
410
  * 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
  }
@@ -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}`);
@@ -10,7 +10,7 @@ import { operatorAlreadySetAppPath, resolveXpcshellAppdirArg, } from '../core/xp
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
@@ -87,13 +113,22 @@ function buildMochitestHttp3ServerMessage() {
87
113
  " - The `BROWSER_CHROME_MANIFESTS` entry for your fork's chrome.manifest is registered.\n\n" +
88
114
  'This is an upstream Firefox harness interaction; FireForge can only diagnose it.');
89
115
  }
90
- function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted) {
116
+ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted, binaryName) {
91
117
  if (result.exitCode === 0 || result.exitCode === 130)
92
118
  return;
93
119
  const combinedOutput = `${result.stdout}\n${result.stderr}`;
94
120
  if (/UNKNOWN TEST\b/i.test(combinedOutput)) {
95
121
  throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
96
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
+ }
97
132
  // Branding-specific stale-build signals keep priority over the broader
98
133
  // xpcshell-appdir hint: when `chrome://branding/locale/brand.properties`
99
134
  // fails to resolve, the fix really is "rebuild", not "pass --app-path".
@@ -157,8 +192,8 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
157
192
  if (options.build) {
158
193
  await prepareBuildEnvironment(projectRoot, paths, projectConfig);
159
194
  const s = spinner('Running incremental build...');
160
- const buildExitCode = await buildUI(paths.engine);
161
- if (buildExitCode !== 0) {
195
+ const buildResult = await buildUI(paths.engine);
196
+ if (buildResult.exitCode !== 0) {
162
197
  s.error('Pre-test build failed');
163
198
  throw new BuildError('Pre-test build failed', 'mach build faster');
164
199
  }
@@ -200,13 +235,19 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
200
235
  if (!preflight.ok) {
201
236
  throw new GeneralError('Marionette preflight reported FAIL — see output above.');
202
237
  }
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)`);
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`);
210
251
  return;
211
252
  }
212
253
  if (!preflight.ok) {
@@ -256,7 +297,7 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
256
297
  catch (error) {
257
298
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
258
299
  }
259
- handleNonZeroTestExit(result, normalizedPaths, appdirInjection);
300
+ handleNonZeroTestExit(result, normalizedPaths, appdirInjection, projectConfig.binaryName);
260
301
  }
261
302
  /**
262
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
@@ -226,6 +226,15 @@ export async function wireCommand(projectRoot, name, options = {}) {
226
226
  // dry-run is meant to preview the mutation plan without requiring
227
227
  // the subscript to already exist, matching the "plan before write"
228
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.
229
238
  if (!options.dryRun) {
230
239
  const paths = getProjectPaths(projectRoot);
231
240
  const subscriptPath = join(paths.engine, subscriptDir, `${name}.js`);
@@ -234,6 +243,13 @@ export async function wireCommand(projectRoot, name, options = {}) {
234
243
  'Create the file in engine/ before wiring.', 'name');
235
244
  }
236
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
+ }
237
253
  if (options.dryRun) {
238
254
  printWireDryRun(getProjectPaths(projectRoot).engine, name, subscriptDir, domFilePath, domTargetPath, options);
239
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;