@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,93 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Ownership-aware working-tree check for `fireforge doctor`.
4
+ *
5
+ * Partitions engine-tree dirtiness into `branding`, `patch-backed`,
6
+ * `furnace`, `conflict`, and `unmanaged` buckets, and only warns on the
7
+ * last two — everything else is tool-managed state that the operator
8
+ * did not author directly.
9
+ *
10
+ * Split out of `doctor.ts` so that file stays under the per-file LOC
11
+ * budget; see the call site in `runEngineGitChecks`.
12
+ */
13
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
14
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
15
+ import { classifyFiles } from '../core/status-classify.js';
16
+ import { ok, warning } from './doctor.js';
17
+ function summarizeWorkingTreeChangeCount(changeCount) {
18
+ return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
19
+ }
20
+ function formatManagedDetail(counts) {
21
+ return [
22
+ counts.patchBacked > 0 ? `${counts.patchBacked} patch-backed` : null,
23
+ counts.branding > 0 ? `${counts.branding} branding` : null,
24
+ counts.furnace > 0 ? `${counts.furnace} furnace` : null,
25
+ ]
26
+ .filter((part) => part !== null)
27
+ .join(', ');
28
+ }
29
+ /**
30
+ * Inspects the engine working tree and returns a single
31
+ * `DoctorCheck`. Ownership-aware: patch-backed / branding / furnace
32
+ * rows are reported as OK with an ownership summary; unmanaged drift
33
+ * warns; cross-patch conflicts warn loudly with a pointer at
34
+ * `fireforge status --ownership` + `fireforge verify`.
35
+ *
36
+ * Before 0.16.1 this check warned on every dirty row regardless of
37
+ * ownership and told the operator to export/discard/reset — advice
38
+ * that was actively destructive on a patch-backed import (eval
39
+ * Finding: a correctly imported 126-file patch stack was reported as
40
+ * unhealthy and the suggested fix would have dropped the entire
41
+ * import). Returns `undefined` when the worktree is clean so the
42
+ * caller can emit its own ok() row.
43
+ */
44
+ export async function inspectEngineWorkingTree(ctx) {
45
+ const { paths } = ctx;
46
+ const rawStatus = await getWorkingTreeStatus(paths.engine);
47
+ const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
48
+ if (workingTreeStatus.length === 0) {
49
+ return ok('Engine working tree');
50
+ }
51
+ if (!ctx.config) {
52
+ return warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.');
53
+ }
54
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(ctx.projectRoot);
55
+ const classified = await classifyFiles(workingTreeStatus.map((entry) => ({ status: entry.status, file: entry.file })), paths.engine, paths.patches, ctx.config.binaryName, furnacePrefixes);
56
+ const counts = {
57
+ branding: 0,
58
+ furnace: 0,
59
+ patchBacked: 0,
60
+ conflict: 0,
61
+ unmanaged: 0,
62
+ };
63
+ for (const entry of classified) {
64
+ if (entry.classification === 'branding')
65
+ counts.branding++;
66
+ else if (entry.classification === 'furnace')
67
+ counts.furnace++;
68
+ else if (entry.classification === 'patch-backed')
69
+ counts.patchBacked++;
70
+ else if (entry.classification === 'conflict')
71
+ counts.conflict++;
72
+ else
73
+ counts.unmanaged++;
74
+ }
75
+ if (counts.conflict > 0) {
76
+ return warning('Engine working tree', `Engine working tree has ${counts.conflict} cross-patch ownership conflict${counts.conflict === 1 ? '' : 's'}. Multiple patches in patches.json claim the same file.`, 'Run "fireforge status --ownership" to see the conflicting patches, then run "fireforge verify" and resolve the overlap.');
77
+ }
78
+ const managedTotal = counts.branding + counts.furnace + counts.patchBacked;
79
+ if (counts.unmanaged === 0) {
80
+ const managedDetail = formatManagedDetail(counts);
81
+ return {
82
+ name: 'Engine working tree',
83
+ passed: true,
84
+ severity: 'ok',
85
+ message: `${managedTotal} tool-managed change${managedTotal === 1 ? '' : 's'} (${managedDetail}), 0 unmanaged. Use "fireforge status --ownership" for details.`,
86
+ };
87
+ }
88
+ const managedTail = managedTotal > 0
89
+ ? ` (${managedTotal} other change${managedTotal === 1 ? '' : 's'} are tool-managed: ${formatManagedDetail(counts)}).`
90
+ : '';
91
+ return warning('Engine working tree', `Engine working tree has ${counts.unmanaged} unmanaged change${counts.unmanaged === 1 ? '' : 's'}.${managedTail}`, 'Use "fireforge status --ownership" to separate patch-backed from unmanaged files, then export, discard, or reset only the unmanaged set.');
92
+ }
93
+ //# sourceMappingURL=doctor-working-tree.js.map
@@ -2,7 +2,6 @@ import { configExists, getProjectPaths, loadConfig, loadState } from '../core/co
2
2
  import { furnaceConfigExists as checkFurnaceConfigExists } from '../core/furnace-config.js';
3
3
  import { getCurrentBranch, getHead, isGitRepository, isMissingHeadError } from '../core/git.js';
4
4
  import { ensureGit } from '../core/git-base.js';
5
- import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
6
5
  import { ensureMach, ensurePython } from '../core/mach.js';
7
6
  import { countPatches } from '../core/patch-apply.js';
8
7
  import { rebuildPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
@@ -12,6 +11,7 @@ import { pathExists } from '../utils/fs.js';
12
11
  import { error, info, intro, outro, success, warn } from '../utils/logger.js';
13
12
  import { executableExists } from '../utils/process.js';
14
13
  import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
14
+ import { inspectEngineWorkingTree } from './doctor-working-tree.js';
15
15
  /**
16
16
  * Builds a DoctorCheck object representing a successful "OK" check.
17
17
  * Exported for sibling check modules that declare `DoctorCheckDefinition`
@@ -58,9 +58,6 @@ async function executeCheck(definition, ctx) {
58
58
  return [failure(definition.name, toError(err).message, definition.fix)];
59
59
  }
60
60
  }
61
- function summarizeWorkingTreeChangeCount(changeCount) {
62
- return `Engine working tree has ${changeCount} local change${changeCount === 1 ? '' : 's'}. Some FireForge commands assume a clean baseline and may behave differently until these are exported, discarded, or committed.`;
63
- }
64
61
  /**
65
62
  * Runs the subset of engine checks that depend on a healthy git repository
66
63
  * and HEAD. This group shares mutable state (currentHead, canValidateBranch),
@@ -89,13 +86,9 @@ async function runEngineGitChecks(ctx) {
89
86
  rows.push(ok('Engine state consistency'));
90
87
  }
91
88
  }
92
- const rawStatus = await getWorkingTreeStatus(paths.engine);
93
- const workingTreeStatus = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
94
- if (workingTreeStatus.length > 0) {
95
- rows.push(warning('Engine working tree', summarizeWorkingTreeChangeCount(workingTreeStatus.length), 'Use "fireforge status" to review changes, then export, discard, or reset them as appropriate.'));
96
- }
97
- else {
98
- rows.push(ok('Engine working tree'));
89
+ const workingTreeRow = await inspectEngineWorkingTree(ctx);
90
+ if (workingTreeRow) {
91
+ rows.push(workingTreeRow);
99
92
  }
100
93
  let branch;
101
94
  if (canValidateBranch) {
@@ -227,6 +220,11 @@ const DOCTOR_CHECKS = [
227
220
  {
228
221
  name: 'Engine is git repository',
229
222
  skipIf: (ctx) => !ctx.engineExists,
223
+ // runEngineGitChecks consults ctx.config for ownership-aware
224
+ // working-tree classification; declare the dependency so a future
225
+ // reorder doesn't silently regress the doctor back to the
226
+ // count-only fallback.
227
+ dependsOn: ['fireforge.json is valid'],
230
228
  run: async (ctx) => {
231
229
  const isRepo = await isGitRepository(ctx.paths.engine);
232
230
  if (!isRepo) {
@@ -324,7 +322,19 @@ const DOCTOR_CHECKS = [
324
322
  // (only `filesAffected` / ordering drifted) are not flagged.
325
323
  if (repaired.recoveredFilenames.length > 0) {
326
324
  for (const filename of repaired.recoveredFilenames) {
327
- warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. Edit patches.json to restore the original description if you have it backed up.`);
325
+ // 2026-04-24 eval Finding 6: the repair path used to tell the
326
+ // operator to hand-edit patches.json, which contradicts the
327
+ // README + Hominis docs that treat the manifest as
328
+ // FireForge-owned. Point at the existing `re-export` /
329
+ // `export` workflow instead so the fix stays inside the tool:
330
+ // re-exporting the same files with an explicit `--description`
331
+ // overwrites the recovered entry with operator-supplied
332
+ // metadata and supersedes the mtime-based createdAt stamp.
333
+ warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. ` +
334
+ 'Re-export the affected files with `fireforge re-export <filename> --description "<your description>"` ' +
335
+ '(or `fireforge export <paths...> --name <name> --category <category> --description "<your description>"`) ' +
336
+ 'to overwrite the reconstructed metadata, or accept the generic description if the original text is not recoverable. ' +
337
+ 'Avoid hand-editing patches.json — FireForge owns that file and will regenerate it on the next manifest consistency pass.');
328
338
  }
329
339
  }
330
340
  return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
@@ -2,13 +2,14 @@
2
2
  import { Option } from 'commander';
3
3
  import { isBrandingManagedPath } from '../core/branding.js';
4
4
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
- import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
5
+ import { collectFurnaceManagedPrefixes, furnaceConfigExists, loadFurnaceConfig, } 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';
12
+ import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
12
13
  import { GeneralError } from '../errors/base.js';
13
14
  import { ensureDir, pathExists } from '../utils/fs.js';
14
15
  import { info, intro, outro, spinner } from '../utils/logger.js';
@@ -41,7 +42,14 @@ async function resolveFurnaceExclusionPolicy(paths, projectRoot, excludeFurnace)
41
42
  const prefixes = await collectFurnaceManagedPrefixes(projectRoot);
42
43
  if (prefixes.size === 0)
43
44
  return new Set();
44
- const changedFiles = await getWorkingTreeStatus(paths.engine);
45
+ // Expand collapsed `?? dir/` entries before matching against Furnace
46
+ // prefixes — otherwise a Furnace-introduced directory slips past the
47
+ // filter and later lands in the non-Furnace path list that feeds the
48
+ // aggregate diff, where `getDiffForFilesAgainstHead` crashes with
49
+ // EISDIR (eval finding: export-all unusable on a fresh project with
50
+ // Furnace scaffolding).
51
+ const rawStatus = await getWorkingTreeStatus(paths.engine);
52
+ const changedFiles = await expandUntrackedDirectoryEntries(paths.engine, rawStatus);
45
53
  const furnaceManagedFiles = changedFiles
46
54
  .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
47
55
  .filter((file) => [...prefixes].some((prefix) => file.startsWith(prefix)));
@@ -55,6 +63,62 @@ async function resolveFurnaceExclusionPolicy(paths, projectRoot, excludeFurnace)
55
63
  'Review them with "fireforge status" or "fireforge furnace status", ' +
56
64
  'or pass --exclude-furnace to export the non-Furnace subset of the diff.');
57
65
  }
66
+ /**
67
+ * Refuses the export when the resulting patch would register furnace
68
+ * component source files it does not itself carry. 2026-04-24 eval
69
+ * Finding 1: operators running `export-all --exclude-furnace` after
70
+ * `furnace create --localized --with-tests` ended up with patches that
71
+ * added `toolkit/content/widgets/moz-qa-panel/*` via jar.mn /
72
+ * customElements.js / locale jar.mn but excluded the component source
73
+ * files themselves. The resulting patch queue was structurally broken
74
+ * and `fireforge verify` stayed silent. We now detect the condition
75
+ * pre-write and ask the operator to either include the component
76
+ * sources (skip `--exclude-furnace`) or revert the furnace changes
77
+ * before exporting.
78
+ *
79
+ * The check runs against the synthesised patch body before
80
+ * `commitExportedPatch` writes anything, so no broken patch is left on
81
+ * disk when the refusal fires.
82
+ */
83
+ async function checkDanglingFurnaceRegistrations(projectRoot, diff, furnaceExcluded) {
84
+ if (furnaceExcluded.size === 0)
85
+ return;
86
+ if (!(await furnaceConfigExists(projectRoot)))
87
+ return;
88
+ const refs = collectPatchRegistrationReferences(diff);
89
+ if (refs.length === 0)
90
+ return;
91
+ const config = await loadFurnaceConfig(projectRoot);
92
+ // Build the set of furnace-managed component names so we can tell
93
+ // "registers moz-qa-panel (furnace-managed)" apart from "registers
94
+ // moz-button (an upstream widget this patch legitimately touches)".
95
+ const furnaceComponentNames = new Set([
96
+ ...Object.keys(config.custom),
97
+ ...Object.keys(config.overrides),
98
+ ...config.stock,
99
+ ]);
100
+ const dangling = [];
101
+ for (const ref of refs) {
102
+ if (!furnaceExcluded.has(ref.targetPath))
103
+ continue;
104
+ const tagMatch = /toolkit\/content\/widgets\/([a-z][a-z0-9-]*)\//.exec(ref.targetPath);
105
+ const ftlMatch = /toolkit\/locales\/en-US\/toolkit\/global\/([a-z][a-z0-9-]*)\.ftl$/.exec(ref.targetPath);
106
+ const component = tagMatch?.[1] ?? ftlMatch?.[1];
107
+ if (!component || !furnaceComponentNames.has(component))
108
+ continue;
109
+ dangling.push({ component, targetPath: ref.targetPath, source: ref.source });
110
+ }
111
+ if (dangling.length === 0)
112
+ return;
113
+ const summary = dangling
114
+ .map((d) => ` • ${d.component} — registered via ${d.source} → ${d.targetPath}`)
115
+ .join('\n');
116
+ throw new GeneralError('Export-all --exclude-furnace would produce a patch that registers furnace-managed components without including their source files.\n\n' +
117
+ `Dangling registrations:\n${summary}\n\n` +
118
+ 'To proceed, either:\n' +
119
+ ' 1. Drop the --exclude-furnace flag so the source files are captured alongside the registration edits.\n' +
120
+ ' 2. Revert the registration hunks (or the whole furnace workflow) before re-running export-all — registrations belong with their components, and splitting them across separate patches is what "verify" catches post-hoc as a dangling-registration error.');
121
+ }
58
122
  /**
59
123
  * Refuses the export when the aggregate diff would create (new-file-mode) a
60
124
  * path that some existing patch in the queue already creates. `verify`
@@ -129,7 +193,8 @@ export async function exportAllCommand(projectRoot, options = {}) {
129
193
  // output shape aligned with the single-file `export` command.
130
194
  let diff;
131
195
  if (furnaceExcluded.size > 0) {
132
- const allChanged = await getWorkingTreeStatus(paths.engine);
196
+ const rawChanged = await getWorkingTreeStatus(paths.engine);
197
+ const allChanged = await expandUntrackedDirectoryEntries(paths.engine, rawChanged);
133
198
  const nonFurnacePaths = [
134
199
  ...new Set(allChanged
135
200
  .flatMap((entry) => [entry.file, entry.originalPath].filter((value) => !!value))
@@ -155,6 +220,11 @@ export async function exportAllCommand(projectRoot, options = {}) {
155
220
  // the aggregate would newly create, so it runs here instead of alongside
156
221
  // the branding / furnace guards that operate on the raw status list.
157
222
  await checkDuplicateNewFileCreations(paths, diff);
223
+ // Dangling-furnace-registration preflight (Finding 1). Runs after the
224
+ // diff is assembled so we can inspect the exact hunks the operator is
225
+ // about to land; runs BEFORE any write so a refusal leaves the
226
+ // patches directory untouched.
227
+ await checkDanglingFurnaceRegistrations(projectRoot, diff, furnaceExcluded);
158
228
  // Check for non-interactive mode
159
229
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
160
230
  // Auto-fix missing license headers on new files (interactive only)
@@ -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');
@@ -5,6 +5,7 @@
5
5
  * per-file LOC budget and the scaffolder is unit-testable in isolation.
6
6
  */
7
7
  import { join } from 'node:path';
8
+ import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
8
9
  import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
9
10
  import { getLicenseHeader } from '../../core/license-headers.js';
10
11
  import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
@@ -26,8 +27,9 @@ import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellT
26
27
  * auto-insertion that guessed wrong would be worse than a note.
27
28
  */
28
29
  export async function scaffoldXpcshellTestFiles(componentName, license, forgeConfig, paths, journal) {
29
- const parentDirName = `${forgeConfig.binaryName}-xpcshell`;
30
- const testDir = join(paths.engine, 'browser/base/content/test', parentDirName, componentName);
30
+ const parentRelDir = xpcshellTestParentDir(forgeConfig.binaryName);
31
+ const parentDirName = parentRelDir.split('/').slice(-1)[0] ?? `${forgeConfig.binaryName}-xpcshell`;
32
+ const testDir = join(paths.engine, parentRelDir, componentName);
31
33
  if (journal && !(await pathExists(testDir))) {
32
34
  recordCreatedDir(journal, testDir);
33
35
  }
@@ -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) {
@@ -163,6 +163,38 @@ async function assertPreviewPrerequisites(engineDir) {
163
163
  'Run "fireforge bootstrap" (or the underlying `mach bootstrap` in the engine) to populate the toolchain config, then rerun "fireforge furnace preview".');
164
164
  }
165
165
  }
166
+ /**
167
+ * Emits a framing banner when the Storybook workspace has not yet had
168
+ * its npm dependencies installed. `mach storybook` will drive the
169
+ * install internally and print ELSPROBLEMS / UNMET DEPENDENCY lines
170
+ * verbatim; without this banner operators reliably read the npm output
171
+ * as a failure (2026-04-24 eval Finding 13).
172
+ *
173
+ * Skipped when `--install` was explicitly requested — that path already
174
+ * runs `mach storybook upgrade` before the preview launches, so the npm
175
+ * output for the subsequent `mach storybook` invocation is a no-op.
176
+ */
177
+ async function announceStorybookFirstRunIfNeeded(engineDir, installRequested) {
178
+ if (installRequested)
179
+ return;
180
+ const storybookNodeModules = join(engineDir, 'browser', 'components', 'storybook', 'node_modules');
181
+ const storybookDepsMissing = !(await pathExists(storybookNodeModules));
182
+ if (!storybookDepsMissing)
183
+ return;
184
+ info('Storybook workspace dependencies are not yet installed. The next step will install ~1000 npm packages via `mach storybook`; expect npm error-style output below. This is a one-time first-run cost — Storybook will start once the install finishes.');
185
+ }
186
+ /**
187
+ * Surfaces an explicit success banner after a clean mach-storybook
188
+ * exit so the operator's scrollback visually terminates the npm noise
189
+ * from the first-run install. Only fires on expected exit codes — non-
190
+ * zero cases fall through to the existing
191
+ * `buildStorybookFailureMessage` classification.
192
+ */
193
+ function announceStorybookCleanExitIfApplicable(exitCode) {
194
+ if (exitCode === 0 || exitCode === 130 || exitCode === 143) {
195
+ info('Storybook stopped cleanly.');
196
+ }
197
+ }
166
198
  /**
167
199
  * Runs the furnace preview command to start Storybook for component preview.
168
200
  * @param projectRoot - Root directory of the project
@@ -269,10 +301,16 @@ export async function furnacePreviewCommand(projectRoot, options = {}) {
269
301
  }
270
302
  installSpinner.stop('Storybook dependencies reinstalled');
271
303
  }
304
+ // 2026-04-24 eval Finding 13: frame the npm noise that `mach
305
+ // storybook` emits on first-run as expected progress rather than a
306
+ // failure. The banner-before / banner-after helpers are extracted
307
+ // so the command body stays under the per-function LOC budget.
308
+ await announceStorybookFirstRunIfNeeded(paths.engine, options.install ?? false);
272
309
  // Start Storybook
273
310
  info('Starting Storybook...');
274
311
  info('Press Ctrl+C to stop\n');
275
312
  previewResult = await runMachCapture(['storybook'], paths.engine);
313
+ announceStorybookCleanExitIfApplicable(previewResult.exitCode);
276
314
  }
277
315
  catch (error) {
278
316
  primaryError = error;
@@ -3,9 +3,10 @@ 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
- import { resolveFtlDir } from '../../core/furnace-constants.js';
9
+ import { resolveFtlDir, xpcshellTestParentDir } from '../../core/furnace-constants.js';
9
10
  import { recordFurnaceRollbackFailure, runFurnaceMutation } from '../../core/furnace-operation.js';
10
11
  import { removeCustomElementRegistration, removeJarMnEntries, } from '../../core/furnace-registration.js';
11
12
  import { createRollbackJournal, restoreRollbackJournalOrThrow, snapshotDir, snapshotFile, } from '../../core/furnace-rollback.js';
@@ -211,6 +212,64 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
211
212
  }
212
213
  return { partialFailures };
213
214
  }
215
+ /**
216
+ * Removes generated xpcshell test scaffolds associated with a custom
217
+ * component. 2026-04-24 eval Finding 5: `furnace remove` handled
218
+ * browser mochitests via `cleanupCustomTestFiles` but never touched the
219
+ * xpcshell scaffold tree, so an operator who ran
220
+ * `furnace create --with-tests --xpcshell` followed by `furnace remove`
221
+ * was left with orphan `xpcshell.toml` + `test_<name>_packaged.js`
222
+ * files still referencing the removed component. This cleanup pass
223
+ * mirrors the mochitest one — snapshot before removal, warn-and-
224
+ * continue semantics, explicit summary when partial failures occur.
225
+ */
226
+ async function cleanupCustomXpcshellTestFiles(name, projectRoot, journal) {
227
+ const partialFailures = [];
228
+ let forgeConfig;
229
+ try {
230
+ forgeConfig = await loadConfig(projectRoot);
231
+ }
232
+ catch (error) {
233
+ const msg = `Could not load config for xpcshell test cleanup — ${toError(error).message}. Remove xpcshell test files manually if needed.`;
234
+ warn(msg);
235
+ partialFailures.push(msg);
236
+ return { partialFailures };
237
+ }
238
+ const paths = getProjectPaths(projectRoot);
239
+ const xpcshellRoot = join(paths.engine, xpcshellTestParentDir(forgeConfig.binaryName));
240
+ const componentXpcshellDir = join(xpcshellRoot, name);
241
+ if (!(await pathExists(componentXpcshellDir)))
242
+ return { partialFailures };
243
+ try {
244
+ await snapshotDir(journal, componentXpcshellDir);
245
+ await removeDir(componentXpcshellDir);
246
+ info(`Deleted xpcshell test scaffold directory: ${componentXpcshellDir.replace(paths.engine + '/', 'engine/')}`);
247
+ }
248
+ catch (error) {
249
+ const msg = `Could not delete xpcshell test scaffold — ${toError(error).message}. Remove it manually if needed.`;
250
+ warn(msg);
251
+ partialFailures.push(msg);
252
+ }
253
+ // If the xpcshell parent directory is now empty (no other components
254
+ // had scaffolds), drop it too so `furnace validate` stays quiet about
255
+ // the empty per-binary tree. Warn-and-continue on any failure.
256
+ try {
257
+ if (await pathExists(xpcshellRoot)) {
258
+ const remaining = await readdir(xpcshellRoot);
259
+ if (remaining.length === 0) {
260
+ await snapshotDir(journal, xpcshellRoot);
261
+ await removeDir(xpcshellRoot);
262
+ info(`Deleted empty xpcshell parent directory: ${xpcshellRoot.replace(paths.engine + '/', 'engine/')}`);
263
+ }
264
+ }
265
+ }
266
+ catch (error) {
267
+ const msg = `Could not clean up xpcshell parent directory — ${toError(error).message}. Remove it manually if needed.`;
268
+ warn(msg);
269
+ partialFailures.push(msg);
270
+ }
271
+ return { partialFailures };
272
+ }
214
273
  function dropChecksumsByPrefix(state, prefix) {
215
274
  const result = { ...state };
216
275
  if (state.appliedChecksums) {
@@ -353,12 +412,27 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
353
412
  await removeFile(ftlPath);
354
413
  info(`Deleted localized file engine/${ftlRel}`);
355
414
  }
415
+ // Drop the locale jar.mn chrome registration that `applyCustomFtlFile`
416
+ // wrote during deploy — otherwise the engine is left with a
417
+ // `locale/.../${name}.ftl` entry pointing at a file we just
418
+ // deleted. 2026-04-21 eval (Finding #1): `furnace remove` left
419
+ // `browser/locales/jar.mn` referencing the missing FTL, which
420
+ // would break the next package-manifest validation.
421
+ await removeCustomFtlJarMnEntry(paths.engine, `${name}.ftl`, ftlDir, customConfig, journal);
356
422
  }
357
423
  }
358
424
  let testCleanupFailures = [];
359
425
  if (type === 'custom') {
360
426
  const result = await cleanupCustomTestFiles(name, projectRoot, journal);
361
427
  testCleanupFailures = result.partialFailures;
428
+ // 2026-04-24 eval Finding 5: also clean up xpcshell scaffolds
429
+ // generated by `furnace create --with-tests --xpcshell`. The
430
+ // mochitest cleanup above covers `browser/base/content/test/
431
+ // <binary>/`, but xpcshell scaffolds live in the sibling
432
+ // `<binary>-xpcshell/` directory and were orphaned by prior
433
+ // versions.
434
+ const xpcshellResult = await cleanupCustomXpcshellTestFiles(name, projectRoot, journal);
435
+ testCleanupFailures.push(...xpcshellResult.partialFailures);
362
436
  }
363
437
  // Remove entry from furnace.json
364
438
  if (type === 'stock') {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * xpcshell scaffold rename helper extracted from `rename.ts`.
3
+ *
4
+ * 2026-04-24 eval Finding 5: `furnace create --with-tests --xpcshell`
5
+ * writes a scaffold at `browser/base/content/test/<binary>-xpcshell/
6
+ * <name>/` and `furnace rename` did not update it. The helper below
7
+ * renames the directory, updates the test filename, rewrites the
8
+ * `xpcshell.toml` section header, and re-writes the test body so word-
9
+ * boundary occurrences of the old tag / underscored name map to the new
10
+ * ones.
11
+ *
12
+ * Extracted to keep `rename.ts` under the per-file LOC budget —
13
+ * `rename.ts` already carries mochikit + browser-mochitest + FTL
14
+ * handling, and tacking xpcshell onto that tree pushed the file past
15
+ * the limit.
16
+ */
17
+ import { type RollbackJournal } from '../../core/furnace-rollback.js';
18
+ /**
19
+ * Renames an xpcshell test scaffold in place. Moves the directory,
20
+ * rewrites the test filename, updates the `[test_name]` section header
21
+ * in `xpcshell.toml`, and word-boundary-rewrites occurrences of the
22
+ * old tag / old underscored name inside the test body.
23
+ *
24
+ * Best-effort: any failure logs a warning through the shared logger
25
+ * but never throws — the component rename itself has already succeeded
26
+ * at this point, and blocking on a test rewrite would leave the
27
+ * operator with a half-renamed component.
28
+ *
29
+ * @param engineDir - Absolute path to the engine directory under the project.
30
+ * @param projectRoot - Absolute path to the project root, used to load the binary name.
31
+ * @param oldName - Pre-rename component tag name.
32
+ * @param newName - Post-rename component tag name.
33
+ * @param journal - Rollback journal that the rename mutation writes to before touching files.
34
+ */
35
+ export declare function renameXpcshellTestFiles(engineDir: string, projectRoot: string, oldName: string, newName: string, journal: RollbackJournal): Promise<void>;