@hominis/fireforge 0.16.3 → 0.17.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 (56) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +11 -3
  3. package/dist/src/commands/build.js +16 -7
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor.js +14 -1
  6. package/dist/src/commands/download.js +44 -13
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  12. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  13. package/dist/src/commands/furnace/create-readback.js +34 -0
  14. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  15. package/dist/src/commands/furnace/create-templates.js +11 -2
  16. package/dist/src/commands/furnace/create.js +2 -0
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/preview.d.ts +12 -0
  19. package/dist/src/commands/furnace/preview.js +34 -2
  20. package/dist/src/commands/furnace/rename.js +110 -0
  21. package/dist/src/commands/furnace/status.js +1 -1
  22. package/dist/src/commands/lint.js +55 -4
  23. package/dist/src/commands/patch/index.js +10 -1
  24. package/dist/src/commands/re-export.js +79 -6
  25. package/dist/src/commands/resolve.d.ts +25 -1
  26. package/dist/src/commands/resolve.js +40 -16
  27. package/dist/src/commands/run.js +27 -5
  28. package/dist/src/commands/status.js +100 -122
  29. package/dist/src/commands/test.js +23 -3
  30. package/dist/src/commands/token-coverage.js +55 -1
  31. package/dist/src/commands/token.js +12 -1
  32. package/dist/src/commands/wire.js +56 -10
  33. package/dist/src/core/config.d.ts +33 -0
  34. package/dist/src/core/config.js +43 -0
  35. package/dist/src/core/furnace-config.d.ts +23 -2
  36. package/dist/src/core/furnace-config.js +26 -3
  37. package/dist/src/core/mach-error-hints.js +16 -0
  38. package/dist/src/core/mach.d.ts +31 -0
  39. package/dist/src/core/mach.js +59 -6
  40. package/dist/src/core/marionette-port.d.ts +50 -0
  41. package/dist/src/core/marionette-port.js +215 -0
  42. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  43. package/dist/src/core/patch-manifest-consistency.js +16 -1
  44. package/dist/src/core/status-classify.d.ts +54 -0
  45. package/dist/src/core/status-classify.js +134 -0
  46. package/dist/src/core/token-dark-mode.d.ts +49 -0
  47. package/dist/src/core/token-dark-mode.js +182 -0
  48. package/dist/src/core/token-manager.js +17 -33
  49. package/dist/src/core/wire-destroy.js +18 -5
  50. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  51. package/dist/src/core/wire-dom-fragment.js +40 -0
  52. package/dist/src/core/wire-init.js +20 -5
  53. package/dist/src/core/wire-utils.d.ts +15 -0
  54. package/dist/src/core/wire-utils.js +17 -0
  55. package/dist/src/types/commands/options.d.ts +7 -0
  56. package/package.json +1 -1
@@ -10,7 +10,7 @@ import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff
10
10
  import { isBinaryFile } from '../core/git-file-ops.js';
11
11
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
12
12
  import { extractAffectedFiles } from '../core/patch-apply.js';
13
- import { commitExportedPatch } from '../core/patch-export.js';
13
+ import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
14
14
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
15
15
  import { toError } from '../utils/errors.js';
16
16
  import { ensureDir, pathExists } from '../utils/fs.js';
@@ -19,7 +19,7 @@ import { pickDefined } from '../utils/options.js';
19
19
  import { stripEnginePrefix } from '../utils/paths.js';
20
20
  import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
21
21
  import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
22
- import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
22
+ import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
23
23
  async function collectExportFiles(paths, files) {
24
24
  const collectedFiles = new Set();
25
25
  let fileStatuses;
@@ -286,6 +286,26 @@ export async function exportCommand(projectRoot, files, options) {
286
286
  const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
287
287
  if (!shouldProceed)
288
288
  return;
289
+ // Overlap gate: pre-0.16.0 `export` only caught FULL-coverage
290
+ // supersedes, so a second export targeting a shared file like
291
+ // `browser/themes/shared/jar.inc.mn` happily created a queue where
292
+ // two patches both listed the same file in `filesAffected`. `verify`
293
+ // then failed immediately on "cross-patch filesAffected conflicts".
294
+ // `confirmSupersedePatches` might already have confirmed full
295
+ // supersedes above; pass their filenames through so we do not flag
296
+ // a file claimed by a patch that is about to be removed.
297
+ const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
298
+ const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
299
+ const shouldProceedPastOverlap = await guardOwnershipOverlap({
300
+ patchesDir: paths.patches,
301
+ filesAffected,
302
+ supersedingFilenames,
303
+ allowOverlap: options.allowOverlap === true,
304
+ isInteractive,
305
+ s,
306
+ });
307
+ if (!shouldProceedPastOverlap)
308
+ return;
289
309
  const { patchFilename, superseded } = await commitExportedPatch({
290
310
  patchesDir: paths.patches,
291
311
  category: selectedCategory,
@@ -327,6 +347,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
327
347
  .option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
328
348
  .option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
329
349
  .option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
350
+ .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
330
351
  .action(withErrorHandling(async (paths, options) => {
331
352
  const { category, ...rest } = options;
332
353
  await exportCommand(getProjectRoot(), paths, {
@@ -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
  });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Defensive read-back helper for `furnace create`. Extracted from
3
+ * `create.ts` so the authoring command stays under the per-file LOC
4
+ * budget.
5
+ */
6
+ /**
7
+ * Asserts that the just-written furnace.json contains the expected
8
+ * custom component entry. The eval run's finding #9 observed a
9
+ * scenario where `furnace create --allow-prefix-mismatch` reported
10
+ * success and wrote the component files, but the subsequent
11
+ * `furnace status` found `custom: {}` in furnace.json — an invariant
12
+ * violation with no clear smoking gun in the code path. Local repros
13
+ * do not trigger it, so the defensive readback is the safest recovery
14
+ * contract we can offer: if the new entry is not visible on the next
15
+ * load, throw a `FurnaceError` so the rollback journal restores the
16
+ * pre-command state and the operator sees the failure instead of a
17
+ * phantom success.
18
+ *
19
+ * @param projectRoot - Root of the FireForge project
20
+ * @param componentName - Custom-element tag name that must be present
21
+ * in `config.custom` after the write. Throws when absent.
22
+ */
23
+ export declare function assertCustomEntryPersisted(projectRoot: string, componentName: string): Promise<void>;
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Defensive read-back helper for `furnace create`. Extracted from
4
+ * `create.ts` so the authoring command stays under the per-file LOC
5
+ * budget.
6
+ */
7
+ import { loadFurnaceConfig } from '../../core/furnace-config.js';
8
+ import { FurnaceError } from '../../errors/furnace.js';
9
+ /**
10
+ * Asserts that the just-written furnace.json contains the expected
11
+ * custom component entry. The eval run's finding #9 observed a
12
+ * scenario where `furnace create --allow-prefix-mismatch` reported
13
+ * success and wrote the component files, but the subsequent
14
+ * `furnace status` found `custom: {}` in furnace.json — an invariant
15
+ * violation with no clear smoking gun in the code path. Local repros
16
+ * do not trigger it, so the defensive readback is the safest recovery
17
+ * contract we can offer: if the new entry is not visible on the next
18
+ * load, throw a `FurnaceError` so the rollback journal restores the
19
+ * pre-command state and the operator sees the failure instead of a
20
+ * phantom success.
21
+ *
22
+ * @param projectRoot - Root of the FireForge project
23
+ * @param componentName - Custom-element tag name that must be present
24
+ * in `config.custom` after the write. Throws when absent.
25
+ */
26
+ export async function assertCustomEntryPersisted(projectRoot, componentName) {
27
+ const persisted = await loadFurnaceConfig(projectRoot);
28
+ if (!(componentName in persisted.custom)) {
29
+ throw new FurnaceError(`Wrote furnace.json but "${componentName}" is missing from config.custom on read-back. ` +
30
+ 'This should not happen — please report the issue. As a workaround, ' +
31
+ 're-run the command, or add the entry to furnace.json by hand.', componentName);
32
+ }
33
+ }
34
+ //# sourceMappingURL=create-readback.js.map
@@ -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");
@@ -19,6 +19,7 @@ import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils
19
19
  import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
20
20
  import { resolveCreateFeatures } from './create-features.js';
21
21
  import { scaffoldMochikitTestFiles } from './create-mochikit.js';
22
+ import { assertCustomEntryPersisted } from './create-readback.js';
22
23
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
23
24
  import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
24
25
  async function loadAuthoringFurnaceConfig(projectRoot) {
@@ -266,6 +267,7 @@ async function performCreateMutations(args) {
266
267
  args.config.custom[args.componentName] = customEntry;
267
268
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
268
269
  await writeFurnaceConfig(args.projectRoot, args.config);
270
+ await assertCustomEntryPersisted(args.projectRoot, args.componentName);
269
271
  if (args.testStyle === 'browser-chrome') {
270
272
  const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
271
273
  testFiles.push(...scafFiles);
@@ -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
  }
@@ -1,4 +1,16 @@
1
1
  import type { FurnacePreviewOptions } from '../../types/commands/index.js';
2
+ /**
3
+ * Builds a targeted Storybook failure message from captured mach output.
4
+ *
5
+ * Exported for the test suite: the heuristic has three branches (backend
6
+ * artifact missing, Storybook dep missing, generic) and regression
7
+ * testing each is easier when the classifier is addressable directly.
8
+ *
9
+ * @param output - Combined stdout and stderr from the Storybook command
10
+ * @param installRequested - Whether the caller requested a dependency reinstall first
11
+ * @returns User-facing guidance for the specific failure mode
12
+ */
13
+ export declare function buildStorybookFailureMessage(output: string, installRequested: boolean): string;
2
14
  /**
3
15
  * Runs the furnace preview command to start Storybook for component preview.
4
16
  * @param projectRoot - Root directory of the project
@@ -72,17 +72,49 @@ function reportPreviewStagingFailures(stageResult) {
72
72
  const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
73
73
  throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to stage for preview`);
74
74
  }
75
+ /**
76
+ * Filenames emitted by the Firefox build backend (not by Storybook's npm
77
+ * package set) — their absence means `mach build` has not produced its
78
+ * post-configure artifacts, which is a different failure mode from a
79
+ * missing Storybook workspace dependency tree. The eval log for finding
80
+ * #11 reported `FileNotFoundError: [...] chrome-map.json` *after* a
81
+ * successful Storybook `npm install`, and the pre-0.16 heuristic
82
+ * misdiagnosed it as a dep failure and sent the operator back to
83
+ * `--install`. Pattern list is narrow on purpose so we only surface the
84
+ * backend-rebuild hint when we are confident.
85
+ */
86
+ const BACKEND_ARTIFACT_PATTERNS = [
87
+ /chrome-map\.json/i,
88
+ /config\.status/i,
89
+ /obj-[^\s/]+\/dist\/bin\/\.lldbinit/i,
90
+ ];
75
91
  /**
76
92
  * Builds a targeted Storybook failure message from captured mach output.
93
+ *
94
+ * Exported for the test suite: the heuristic has three branches (backend
95
+ * artifact missing, Storybook dep missing, generic) and regression
96
+ * testing each is easier when the classifier is addressable directly.
97
+ *
77
98
  * @param output - Combined stdout and stderr from the Storybook command
78
99
  * @param installRequested - Whether the caller requested a dependency reinstall first
79
100
  * @returns User-facing guidance for the specific failure mode
80
101
  */
81
- function buildStorybookFailureMessage(output, installRequested) {
102
+ export function buildStorybookFailureMessage(output, installRequested) {
82
103
  const installHint = installRequested
83
104
  ? 'Try running "python3 ./mach storybook upgrade" manually in the engine directory.'
84
105
  : 'Run "fireforge furnace preview --install" to bootstrap Storybook dependencies, or run "python3 ./mach storybook upgrade" manually in engine/.';
85
- if (/(ENOENT|No such file or directory)/i.test(output) && /storybook|backend/i.test(output)) {
106
+ const hasFileNotFoundSignal = /(ENOENT|No such file or directory|FileNotFoundError)/i.test(output);
107
+ // Check backend-artifact signal first — a missing chrome-map.json looks
108
+ // like any other "No such file" error to a naïve regex, but the fix is
109
+ // to rerun `fireforge build`, not to reinstall Storybook dependencies.
110
+ if (hasFileNotFoundSignal && BACKEND_ARTIFACT_PATTERNS.some((p) => p.test(output))) {
111
+ return ('Storybook failed because the Firefox build backend artifacts are missing or stale ' +
112
+ '(chrome-map.json / config.status / obj-*/dist/bin/.lldbinit). ' +
113
+ 'This is a Firefox-build completeness issue, not a Storybook dependency issue.\n\n' +
114
+ 'Rerun "fireforge build" and let it finish, then retry "fireforge furnace preview". ' +
115
+ 'A full rebuild regenerates the backend artifacts Storybook reads.');
116
+ }
117
+ if (hasFileNotFoundSignal && /storybook|backend/i.test(output)) {
86
118
  return ('Storybook failed because the Firefox checkout appears to be missing Storybook workspace files or backend dependencies.\n\n' +
87
119
  installHint);
88
120
  }
@@ -122,6 +122,94 @@ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal
122
122
  }
123
123
  }
124
124
  }
125
+ /**
126
+ * Removes the deployed custom-widget directory at the old target path so
127
+ * a subsequent `furnace apply` is the single writer of the new name's
128
+ * deployment. Best-effort: logs a warning but never blocks the rename.
129
+ *
130
+ * 2026-04-21 eval: renaming `ff-chip-row` → `ff-chip-stack` registered
131
+ * and deployed the new name correctly but left `engine/toolkit/content/
132
+ * widgets/ff-chip-row/` in place. Subsequent `furnace sync` runs could
133
+ * not clear the stale widget, and packaging would have pulled in both
134
+ * copies. The snapshot is taken before the remove so the rollback
135
+ * journal restores the old directory if any later step in
136
+ * `performRenameMutations` fails.
137
+ */
138
+ async function removeStaleDeployedComponentDir(engineDir, oldTargetPath, journal) {
139
+ const oldDeployed = join(engineDir, oldTargetPath);
140
+ if (!(await pathExists(oldDeployed)))
141
+ return;
142
+ try {
143
+ await snapshotDir(journal, oldDeployed);
144
+ await removeDir(oldDeployed);
145
+ info(`Removed stale deployed widget directory: ${oldTargetPath}`);
146
+ }
147
+ catch (error) {
148
+ warn(`Could not remove stale deployed widget directory at ${oldTargetPath}: ${toError(error).message}. Remove it manually if needed.`);
149
+ }
150
+ }
151
+ /**
152
+ * Renames the mochikit test scaffold produced by `furnace create
153
+ * --with-tests` when the default test style is used. The scaffold lives
154
+ * at `engine/toolkit/content/tests/widgets/test_<name>.html`, and the
155
+ * accompanying `chrome.toml` entry names the same file. Neither piece
156
+ * was handled by the pre-0.16.0 rename, so operators were left with a
157
+ * `test_<old>.html` file that still imported `chrome://global/content/
158
+ * elements/<old>.mjs` and referenced `customElements.whenDefined("<old>")`
159
+ * — the test ran against a component that no longer existed under that
160
+ * name and either failed or (if the old component was still deployed)
161
+ * passed for the wrong reason.
162
+ *
163
+ * Best-effort: individual failures log a warning. The same journal used
164
+ * for the rest of the rename snapshots every touched file so a later
165
+ * failure rolls the pair back together.
166
+ */
167
+ async function renameMochikitTestFiles(engineDir, oldName, newName, journal) {
168
+ const testDir = join(engineDir, 'toolkit/content/tests/widgets');
169
+ if (!(await pathExists(testDir)))
170
+ return;
171
+ const oldTestFileName = `test_${oldName}.html`;
172
+ const newTestFileName = `test_${newName}.html`;
173
+ const oldTestPath = join(testDir, oldTestFileName);
174
+ const newTestPath = join(testDir, newTestFileName);
175
+ if (await pathExists(oldTestPath)) {
176
+ try {
177
+ await snapshotFile(journal, oldTestPath);
178
+ const content = await readText(oldTestPath);
179
+ const updatedContent = content
180
+ .replace(new RegExp(`chrome://global/content/elements/${escapeRegex(oldName)}\\.mjs`, 'g'), `chrome://global/content/elements/${newName}.mjs`)
181
+ .replace(new RegExp(`customElements\\.whenDefined\\("${escapeRegex(oldName)}"\\)`, 'g'), `customElements.whenDefined("${newName}")`)
182
+ .replace(new RegExp(`Test the ${escapeRegex(oldName)} `, 'g'), `Test the ${newName} `)
183
+ .replace(new RegExp(`add_task\\(async function test_${escapeRegex(oldName.replace(/-/g, '_'))}_defined\\(`, 'g'), `add_task(async function test_${newName.replace(/-/g, '_')}_defined(`)
184
+ .replace(new RegExp(`"${escapeRegex(oldName)} custom element`, 'g'), `"${newName} custom element`);
185
+ await writeText(newTestPath, updatedContent);
186
+ await removeFile(oldTestPath);
187
+ info(`Renamed mochikit test: ${oldTestFileName} → ${newTestFileName}`);
188
+ }
189
+ catch (error) {
190
+ warn(`Could not rename mochikit test file — ${toError(error).message}. Rename it manually if needed.`);
191
+ }
192
+ }
193
+ // Update `chrome.toml` entry if present. The file may live in the
194
+ // same widgets/tests directory as the test file itself; upstream
195
+ // convention places exactly one `chrome.toml` there for all widget
196
+ // scaffolds.
197
+ const chromeTomlPath = join(testDir, 'chrome.toml');
198
+ if (await pathExists(chromeTomlPath)) {
199
+ try {
200
+ const toml = await readText(chromeTomlPath);
201
+ if (toml.includes(`["${oldTestFileName}"]`)) {
202
+ await snapshotFile(journal, chromeTomlPath);
203
+ const updated = toml.replace(`["${oldTestFileName}"]`, `["${newTestFileName}"]`);
204
+ await writeText(chromeTomlPath, updated);
205
+ info(`Updated chrome.toml: ${oldTestFileName} → ${newTestFileName}`);
206
+ }
207
+ }
208
+ catch (error) {
209
+ warn(`Could not update widgets chrome.toml — ${toError(error).message}. Update it manually if needed.`);
210
+ }
211
+ }
212
+ }
125
213
  /**
126
214
  * Performs the transactional rename mutation inside a furnace lock.
127
215
  */
@@ -129,6 +217,11 @@ async function performRenameMutations(args) {
129
217
  const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
130
218
  const oldClassName = tagNameToClassName(oldName);
131
219
  const newClassName = tagNameToClassName(newName);
220
+ // Capture the pre-rename deployed target path so we know what to
221
+ // clean up in the engine tree. `updateConfigForCustomRename` rewrites
222
+ // `targetPath` in-place once the mutation enters phase 2, so we read
223
+ // it here while it still points at the old name's deployment.
224
+ const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
132
225
  await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
133
226
  const journal = createRollbackJournal();
134
227
  ctx.registerJournal(journal);
@@ -197,6 +290,23 @@ async function performRenameMutations(args) {
197
290
  // 7. Rename test files created by `furnace create --with-tests` (custom only).
198
291
  if (isCustom && (await pathExists(args.engineDir))) {
199
292
  await renameTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
293
+ // Mochikit scaffold + widgets/chrome.toml live in a different
294
+ // tree than browser.toml-registered browser-chrome tests, so
295
+ // renameTestFiles doesn't reach them. 2026-04-21 eval: a rename
296
+ // left `engine/toolkit/content/tests/widgets/test_<old>.html`
297
+ // and its `chrome.toml` entry pointing at the old name, which
298
+ // either failed the test run outright or (worse) passed for the
299
+ // wrong component.
300
+ await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
301
+ // Clear the stale deployed component directory so the next
302
+ // `furnace apply` is the single writer of the new name's
303
+ // deployment. Without this, eval runs showed the old widget
304
+ // still living at `engine/toolkit/content/widgets/<old>/`
305
+ // alongside the newly-deployed `engine/toolkit/content/
306
+ // widgets/<new>/`, with no signal to `status` / `verify`.
307
+ if (oldCustomTargetPath) {
308
+ await removeStaleDeployedComponentDir(args.engineDir, oldCustomTargetPath, journal);
309
+ }
200
310
  }
201
311
  info(`Renamed ${componentType} component: ${oldName} → ${newName}`);
202
312
  }
@@ -196,7 +196,7 @@ export async function furnaceStatusCommand(projectRoot, name) {
196
196
  warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
197
197
  }
198
198
  }
199
- info('Tip: run `furnace status <name>` for detailed component info, or `furnace --help` for all subcommands.');
199
+ info('Tip: run `fireforge furnace status <name>` for detailed component info, or `fireforge furnace --help` for all subcommands.');
200
200
  outro('Status complete');
201
201
  }
202
202
  //# sourceMappingURL=status.js.map
@@ -1,10 +1,11 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { isBrandingManagedPath } from '../core/branding.js';
4
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
6
  import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
6
7
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
7
- import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
+ import { getModifiedFiles, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
8
9
  import { extractAffectedFiles } from '../core/patch-apply.js';
9
10
  import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue } from '../core/patch-lint.js';
10
11
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
@@ -22,8 +23,19 @@ import { stripEnginePrefix } from '../utils/paths.js';
22
23
  * per-function LOC budget as the command grows; the two file-mode and
23
24
  * aggregate-mode branches share no state with the post-lint reporting
24
25
  * pipeline, so the split is a pure rename rather than a refactor.
26
+ *
27
+ * When `binaryName` is provided, the aggregate-mode branch (no
28
+ * explicit file list) excludes paths under `browser/branding/<binaryName>/`
29
+ * from the diff. `status` classifies those paths as `branding` —
30
+ * tool-managed material the operator did not author directly — and
31
+ * the 2026-04-21 eval (Finding #2) reported that `fireforge lint` on
32
+ * a fresh project immediately failed `large-patch-lines` /
33
+ * `large-patch-files` / `missing-license-header` on the generated
34
+ * branding tree. File-list mode (explicit paths) preserves the
35
+ * previous behaviour: passing a branding file explicitly still lints
36
+ * it, so operators who need to audit branding content can do so.
25
37
  */
26
- async function resolveLintDiff(engineDir, files) {
38
+ async function resolveLintDiff(engineDir, files, binaryName) {
27
39
  if (files.length > 0) {
28
40
  const collectedFiles = new Set();
29
41
  let fileStatuses;
@@ -83,6 +95,40 @@ async function resolveLintDiff(engineDir, files) {
83
95
  outro('Nothing to lint');
84
96
  return null;
85
97
  }
98
+ // Aggregate-mode branding exclusion. A fresh-setup workspace (after
99
+ // `fireforge setup` + `download` + `bootstrap` + `build`) carries a
100
+ // large tool-managed branding diff that the operator did not
101
+ // author; running the default lint against it fires size and
102
+ // license-header rules on content that was never intended to
103
+ // survive in the patch queue as-is. The exclusion mirrors the
104
+ // `branding` bucket in `fireforge status` so the two views stay
105
+ // consistent.
106
+ if (binaryName) {
107
+ const modified = await getModifiedFiles(engineDir);
108
+ const untracked = await getUntrackedFiles(engineDir);
109
+ const allPaths = [...new Set([...modified, ...untracked])];
110
+ const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
111
+ const excludedCount = allPaths.length - nonBrandingPaths.length;
112
+ if (excludedCount > 0) {
113
+ info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
114
+ }
115
+ if (nonBrandingPaths.length === 0) {
116
+ info('No non-branding changes to lint.');
117
+ outro('Nothing to lint');
118
+ return null;
119
+ }
120
+ const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
121
+ if (!diff.trim()) {
122
+ info('No diff content to lint.');
123
+ outro('Nothing to lint');
124
+ return null;
125
+ }
126
+ return diff;
127
+ }
128
+ // Fallback path: no binaryName available (e.g. a legacy caller
129
+ // without a loaded config). Retain the pre-0.16.0 behaviour of
130
+ // linting the full diff so the lint surface is at least as broad
131
+ // as before.
86
132
  const diff = await getAllDiff(engineDir);
87
133
  if (!diff.trim()) {
88
134
  info('No diff content to lint.');
@@ -126,10 +172,15 @@ export async function lintCommand(projectRoot, files, options = {}) {
126
172
  await lintPerPatch(projectRoot, paths);
127
173
  return;
128
174
  }
129
- const diff = await resolveLintDiff(paths.engine, files);
175
+ // Load the config before resolving the diff so we can pass
176
+ // `binaryName` into the aggregate-mode branding exclusion in
177
+ // `resolveLintDiff`. The config was previously loaded only after
178
+ // the diff was resolved; hoisting it is cheap and keeps the two
179
+ // call sites close together.
180
+ const config = await loadConfig(projectRoot);
181
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
130
182
  if (diff === null)
131
183
  return;
132
- const config = await loadConfig(projectRoot);
133
184
  const filesAffected = extractAffectedFiles(diff);
134
185
  // Build patch queue context once so it can be shared between the
135
186
  // per-patch ownership resolver and the cross-patch rules.