@hominis/fireforge 0.16.5 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. package/package.json +1 -1
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Ownership-aware working-tree check for `fireforge doctor`.
3
+ *
4
+ * Partitions engine-tree dirtiness into `branding`, `patch-backed`,
5
+ * `furnace`, `conflict`, and `unmanaged` buckets, and only warns on the
6
+ * last two — everything else is tool-managed state that the operator
7
+ * did not author directly.
8
+ *
9
+ * Split out of `doctor.ts` so that file stays under the per-file LOC
10
+ * budget; see the call site in `runEngineGitChecks`.
11
+ */
12
+ import type { DoctorCheck } from '../types/commands/index.js';
13
+ import type { DoctorCheckContext } from './doctor.js';
14
+ /**
15
+ * Inspects the engine working tree and returns a single
16
+ * `DoctorCheck`. Ownership-aware: patch-backed / branding / furnace
17
+ * rows are reported as OK with an ownership summary; unmanaged drift
18
+ * warns; cross-patch conflicts warn loudly with a pointer at
19
+ * `fireforge status --ownership` + `fireforge verify`.
20
+ *
21
+ * Before 0.16.1 this check warned on every dirty row regardless of
22
+ * ownership and told the operator to export/discard/reset — advice
23
+ * that was actively destructive on a patch-backed import (eval
24
+ * Finding: a correctly imported 126-file patch stack was reported as
25
+ * unhealthy and the suggested fix would have dropped the entire
26
+ * import). Returns `undefined` when the worktree is clean so the
27
+ * caller can emit its own ok() row.
28
+ */
29
+ export declare function inspectEngineWorkingTree(ctx: DoctorCheckContext): Promise<DoctorCheck | undefined>;
@@ -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) {
@@ -314,7 +312,20 @@ const DOCTOR_CHECKS = [
314
312
  }
315
313
  try {
316
314
  const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version);
317
- return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.patches.length} patch${repaired.patches.length === 1 ? '' : 'es'}. Review recovered metadata before release.`);
315
+ // 2026-04-21 eval (Finding #17): the repair path silently
316
+ // overwrote useful human-written descriptions on recovered
317
+ // entries, leaving the queue less trustworthy as an audit
318
+ // trail. The rebuilder now returns the list of filenames
319
+ // whose metadata was entirely invented, and we name them
320
+ // explicitly here so the operator knows exactly which
321
+ // patches to review. Names that DID have a preserved entry
322
+ // (only `filesAffected` / ordering drifted) are not flagged.
323
+ if (repaired.recoveredFilenames.length > 0) {
324
+ for (const filename of repaired.recoveredFilenames) {
325
+ 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.`);
326
+ }
327
+ }
328
+ 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.`);
318
329
  }
319
330
  catch (err) {
320
331
  return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
@@ -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');
@@ -117,9 +117,16 @@ add_task(async function test_${taskSuffix}_files_packaged() {
117
117
  ["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
118
118
  "${name}.xhtml",
119
119
  );
120
+ // The scoped CSS is registered through jar.inc.mn under
121
+ // \`content/browser/<name>-chrome.css\` (see \`chromeDocJarIncMnCssEntry\`
122
+ // in \`src/commands/furnace/chrome-doc-templates.ts\`), so the packaged
123
+ // file lands under \`chrome/browser/content/browser/\`, not under
124
+ // \`skin/classic/browser/\`. The 2026-04-21 eval's first
125
+ // \`fireforge test --build\` against a scaffolded chrome-doc reported
126
+ // a false failure because the probe was looking at the skin layout.
120
127
  probeEither(
121
- ["chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
122
- ["browser", "chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
128
+ ["chrome", "browser", "content", "browser", "${name}-chrome.css"],
129
+ ["browser", "chrome", "browser", "content", "browser", "${name}-chrome.css"],
123
130
  "${name}-chrome.css",
124
131
  );
125
132
  });
@@ -76,6 +76,17 @@ export declare function mochikitTestFileName(name: string): string;
76
76
  * depend on the component's shape; operators can extend the test using
77
77
  * the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
78
78
  * rely on.
79
+ *
80
+ * The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
81
+ * `add_task` owns the test lifecycle: when every queued task resolves,
82
+ * the task harness calls `SimpleTest.finish()` on its own. Combining
83
+ * `waitForExplicitFinish()` with `add_task` *and* no explicit
84
+ * `SimpleTest.finish()` inside the task body makes the harness wait
85
+ * forever, which the 2026-04-21 eval run tripped into as an indefinite
86
+ * hang on a `fireforge test --headless` against a scaffolded widget
87
+ * test. Leaving `waitForExplicitFinish()` out matches the convention
88
+ * upstream toolkit widget tests use (see `test_moz-button.html` and
89
+ * siblings under `toolkit/content/tests/widgets/`).
79
90
  */
80
91
  export declare function generateMochikitTestContent(name: string): string;
81
92
  /**
@@ -227,6 +227,17 @@ export function mochikitTestFileName(name) {
227
227
  * depend on the component's shape; operators can extend the test using
228
228
  * the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
229
229
  * rely on.
230
+ *
231
+ * The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
232
+ * `add_task` owns the test lifecycle: when every queued task resolves,
233
+ * the task harness calls `SimpleTest.finish()` on its own. Combining
234
+ * `waitForExplicitFinish()` with `add_task` *and* no explicit
235
+ * `SimpleTest.finish()` inside the task body makes the harness wait
236
+ * forever, which the 2026-04-21 eval run tripped into as an indefinite
237
+ * hang on a `fireforge test --headless` against a scaffolded widget
238
+ * test. Leaving `waitForExplicitFinish()` out matches the convention
239
+ * upstream toolkit widget tests use (see `test_moz-button.html` and
240
+ * siblings under `toolkit/content/tests/widgets/`).
230
241
  */
231
242
  export function generateMochikitTestContent(name) {
232
243
  return `<!DOCTYPE html>
@@ -244,8 +255,6 @@ export function generateMochikitTestContent(name) {
244
255
  <script type="module">
245
256
  import "chrome://global/content/elements/${name}.mjs";
246
257
 
247
- SimpleTest.waitForExplicitFinish();
248
-
249
258
  add_task(async function test_${name.replace(/-/g, '_')}_defined() {
250
259
  const ctor = await customElements.whenDefined("${name}");
251
260
  ok(ctor, "${name} custom element should be defined");
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { dirname, isAbsolute, join, normalize } from 'node:path';
2
+ import { stat } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
3
4
  import { text } from '@clack/prompts';
4
5
  import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
5
6
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
@@ -11,12 +12,46 @@ import { toError } from '../../utils/errors.js';
11
12
  import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
12
13
  import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
13
14
  /**
14
- * Validates an FTL base path before writing it to furnace.json. Rejects
15
- * absolute paths, null bytes, and any normalised segment starting with
16
- * `..` the previous `includes('..')` substring check caught the common
17
- * case but missed `./../../` and absolute paths that are arguably worse.
15
+ * File extensions that are definitely FTL resources (not locale
16
+ * directories). A value ending in one of these is almost certainly the
17
+ * result of the operator pointing at a single FTL file instead of the
18
+ * locale directory that contains it.
19
+ *
20
+ * 2026-04-21 eval: `furnace init --ftl-base-path browser/forgefresh.ftl`
21
+ * produced a misleading success path — the subsequent
22
+ * `furnace create --localized` scaffolded an `.mjs` referencing
23
+ * `insertFTLIfNeeded("<name>.ftl")` while furnace.json had no component
24
+ * entry, leaving the scaffold orphaned. Switching to a locale directory
25
+ * (`toolkit/locales/en-US/toolkit/global`) fixed the downstream path.
26
+ * Rejecting file-shaped values up-front keeps the operator on the
27
+ * correct path before any partial state is written.
28
+ */
29
+ const FTL_FILE_EXTENSIONS = new Set(['.ftl', '.properties', '.dtd']);
30
+ function hasFtlFileExtension(value) {
31
+ const lower = value.toLowerCase();
32
+ const dotIdx = lower.lastIndexOf('.');
33
+ const slashIdx = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
34
+ if (dotIdx <= slashIdx)
35
+ return false; // No extension in the basename.
36
+ return FTL_FILE_EXTENSIONS.has(lower.slice(dotIdx));
37
+ }
38
+ /**
39
+ * Validates an FTL base path before writing it to furnace.json.
40
+ * Rejects:
41
+ * - empty values and null bytes;
42
+ * - absolute paths (POSIX or Windows-drive) that escape the engine;
43
+ * - `..` segments that escape the engine;
44
+ * - file-shaped values ending in `.ftl` / `.properties` / `.dtd`
45
+ * (these are locale resources, not directories — the operator
46
+ * almost certainly meant to name the parent directory).
47
+ *
48
+ * When {@link engineDir} is provided and exists on disk, the resolved
49
+ * `engine/${value}` path is probed: if it exists but is not a
50
+ * directory, the same file-shape error fires; if it does not exist yet,
51
+ * a non-blocking warning is logged (a fresh project that has not
52
+ * `fireforge download`-ed yet is the legitimate pre-existence case).
18
53
  */
19
- function validateFtlBasePath(value) {
54
+ async function validateFtlBasePath(value, engineDir) {
20
55
  if (value.length === 0) {
21
56
  throw new FurnaceError('ftlBasePath must not be empty.');
22
57
  }
@@ -30,6 +65,40 @@ function validateFtlBasePath(value) {
30
65
  if (normalized === '..' || normalized.startsWith('../')) {
31
66
  throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
32
67
  }
68
+ if (hasFtlFileExtension(value)) {
69
+ throw new FurnaceError(`ftlBasePath "${value}" looks like a file (basename "${basename(value)}" ends in .ftl/.properties/.dtd), but FireForge expects a locale directory such as toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser. Use the parent directory instead.`);
70
+ }
71
+ // Shape probe against the real filesystem when we have an engine
72
+ // directory to anchor against. The probe is best-effort: a missing
73
+ // engine directory or a not-yet-extracted locale tree is
74
+ // legitimate (an operator may `furnace init` before `fireforge
75
+ // download`), so we emit a warning rather than refusing.
76
+ if (engineDir) {
77
+ const resolved = join(engineDir, value);
78
+ try {
79
+ const info = await stat(resolved);
80
+ if (!info.isDirectory()) {
81
+ throw new FurnaceError(`ftlBasePath "${value}" resolves to a non-directory at ${resolved}. FireForge expects a locale directory (for example toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser).`);
82
+ }
83
+ }
84
+ catch (error) {
85
+ // FurnaceError (from the `isDirectory()` branch above) is a real
86
+ // shape failure — re-throw so the operator sees it.
87
+ if (error instanceof FurnaceError)
88
+ throw error;
89
+ // ENOENT is expected on a fresh project before `fireforge
90
+ // download` has populated engine/; only warn.
91
+ const code = typeof error === 'object' && error !== null && 'code' in error
92
+ ? error.code
93
+ : undefined;
94
+ if (code === 'ENOENT') {
95
+ warn(`ftlBasePath "${value}" does not yet exist at ${resolved}. This is fine if you have not run "fireforge download" yet; rerun "fireforge furnace init --force" after the engine is extracted to re-validate.`);
96
+ }
97
+ // Any other stat error is also best-effort ignored here — a
98
+ // permission issue or malformed engine checkout will surface on
99
+ // the next command that actually reads the FTL tree.
100
+ }
101
+ }
33
102
  }
34
103
  /**
35
104
  * Runs the furnace init command to create a default furnace.json with
@@ -42,8 +111,27 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
42
111
  if ((await furnaceConfigExists(projectRoot)) && !options.force) {
43
112
  throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
44
113
  }
45
- const config = createDefaultFurnaceConfig();
114
+ const paths = getProjectPaths(projectRoot);
115
+ // Seed the default furnace config with a tokenPrefix derived from
116
+ // fireforge.json's binaryName so `token coverage` sees real tokens on
117
+ // the very first run. The 2026-04-21 eval initialised Furnace, added
118
+ // tokens, ran coverage, and got `0 tokens / N unknown` — the prefix
119
+ // default was absent and the scan had nothing to key off. Loading
120
+ // fireforge.json here is best-effort: a project without one (e.g.
121
+ // mid-setup) falls through to the prefix-less default, and
122
+ // `token coverage` emits the existing "no tokenPrefix" warning.
123
+ let derivedBinaryName;
124
+ try {
125
+ const fireForgeConfig = await loadConfig(projectRoot);
126
+ derivedBinaryName = fireForgeConfig.binaryName;
127
+ }
128
+ catch {
129
+ // Best-effort only: initialising furnace without a fireforge.json is
130
+ // rare but not forbidden. Skip the prefix default in that case.
131
+ }
132
+ const config = createDefaultFurnaceConfig(derivedBinaryName ? { binaryName: derivedBinaryName } : {});
46
133
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
134
+ const engineForValidation = (await pathExists(paths.engine)) ? paths.engine : undefined;
47
135
  // Resolve componentPrefix
48
136
  if (options.prefix !== undefined) {
49
137
  config.componentPrefix = options.prefix;
@@ -66,7 +154,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
66
154
  }
67
155
  // Resolve ftlBasePath
68
156
  if (options.ftlBasePath !== undefined) {
69
- validateFtlBasePath(options.ftlBasePath);
157
+ await validateFtlBasePath(options.ftlBasePath, engineForValidation);
70
158
  config.ftlBasePath = options.ftlBasePath;
71
159
  }
72
160
  else if (isInteractive) {
@@ -80,7 +168,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
80
168
  }
81
169
  const ftlValue = ftlResult.trim();
82
170
  if (ftlValue) {
83
- validateFtlBasePath(ftlValue);
171
+ await validateFtlBasePath(ftlValue, engineForValidation);
84
172
  config.ftlBasePath = ftlValue;
85
173
  }
86
174
  }
@@ -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 = [];