@hominis/fireforge 0.18.2 → 0.18.5

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 (36) hide show
  1. package/README.md +29 -16
  2. package/dist/src/commands/build.js +27 -12
  3. package/dist/src/commands/config.js +56 -3
  4. package/dist/src/commands/discard.js +93 -1
  5. package/dist/src/commands/doctor.js +17 -4
  6. package/dist/src/commands/download.js +21 -0
  7. package/dist/src/commands/export-all.js +35 -6
  8. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +59 -8
  9. package/dist/src/commands/furnace/chrome-doc-templates.js +95 -12
  10. package/dist/src/commands/furnace/chrome-doc.js +24 -2
  11. package/dist/src/commands/furnace/deploy.js +10 -1
  12. package/dist/src/commands/furnace/init.js +28 -2
  13. package/dist/src/commands/furnace/remove.js +68 -0
  14. package/dist/src/commands/import.js +9 -1
  15. package/dist/src/commands/lint.js +78 -13
  16. package/dist/src/commands/patch/delete.js +2 -4
  17. package/dist/src/commands/patch/lint-ignore.js +2 -4
  18. package/dist/src/commands/patch/reorder.js +2 -4
  19. package/dist/src/commands/patch/tier.js +2 -4
  20. package/dist/src/commands/status.js +39 -1
  21. package/dist/src/commands/test.js +20 -1
  22. package/dist/src/commands/token.js +1 -1
  23. package/dist/src/core/furnace-apply.js +11 -3
  24. package/dist/src/core/furnace-config.js +19 -0
  25. package/dist/src/core/furnace-marker.d.ts +16 -0
  26. package/dist/src/core/furnace-marker.js +23 -0
  27. package/dist/src/core/git.js +66 -10
  28. package/dist/src/core/license-headers.d.ts +8 -0
  29. package/dist/src/core/license-headers.js +15 -1
  30. package/dist/src/core/manifest-rules.js +9 -1
  31. package/dist/src/core/patch-identifier-suggest.d.ts +25 -0
  32. package/dist/src/core/patch-identifier-suggest.js +108 -0
  33. package/dist/src/core/patch-lint.js +8 -0
  34. package/dist/src/core/register-shared-css.d.ts +28 -0
  35. package/dist/src/core/register-shared-css.js +67 -3
  36. package/package.json +1 -1
@@ -25,19 +25,49 @@ export const FURNACE_CHROME_DOC_SENTINEL = 'data-furnace-chrome-doc';
25
25
  * XHTML shell for a top-level chrome document.
26
26
  *
27
27
  * The emitted document:
28
- * - Declares `windowtype="navigator:browser"` when `withTitlebar` is true
29
- * so chrome-wide stylesheets that target the browser window still apply.
30
- * - Emits a titlebar-buttonbox placeholder when `withTitlebar` is true so
31
- * platform-native window controls render.
28
+ * - When `withTitlebar` is true, declares the `navigator:browser` minimum
29
+ * set: `windowtype`, `customtitlebar`, default `width`/`height`, and a
30
+ * `persist` allowlist for screen position + size + sizemode. Without
31
+ * these, a fork-owned chrome doc that ships as the main window opens
32
+ * at the OS intrinsic minimum size on first launch and forgets the
33
+ * user's last-known geometry across restarts. The titlebar-buttonbox
34
+ * placeholder is emitted alongside so platform-native window controls
35
+ * render with the matching CSS rules from `generateChromeDocCss`.
36
+ * - Loads `chrome://global/content/customElements.js` in `<head>` ahead
37
+ * of the per-doc subscript. Without it, every `<moz-*>` widget the
38
+ * author drops into the body silently degrades to `HTMLUnknownElement`
39
+ * and the upstream a11y/keyboard semantics that motivated the use of
40
+ * the toolkit widget in the first place are lost. Matches the
41
+ * `webrtcIndicator.xhtml` shape upstream uses for non-`browser.xhtml`
42
+ * chrome documents.
32
43
  * - Links the per-document CSS at `chrome://browser/content/<name>-chrome.css`
33
44
  * and the Fluent bundle `browser/<name>.ftl`.
45
+ * - Keeps `data-l10n-id` on the leaf `<title>` only. Binding the same key
46
+ * on the root `<window>` would cause Fluent's first-paint translation
47
+ * pass to overwrite the entire body subtree with the message's text
48
+ * value (the standard `data-l10n-id`-on-non-leaf failure mode), since
49
+ * the FTL stub gives `<name>-window-title` a value rather than an
50
+ * attribute-only message.
34
51
  * - Carries the `data-furnace-chrome-doc="<name>"` sentinel so fork-side
35
52
  * patches to upstream platform modules (DevToolsStartup, PageActions, …)
36
53
  * that assume `browser.xhtml`'s DOM can guard against it cheaply. See
37
54
  * the README "Platform module compatibility" section for the pattern.
38
55
  */
39
56
  export function generateChromeDocXhtml(name, withTitlebar, license) {
40
- const windowAttr = withTitlebar ? ' windowtype="navigator:browser"' : '';
57
+ // navigator:browser minimum set. Carrying every attribute together
58
+ // not just `windowtype` — lets a fork that uses the scaffold output
59
+ // verbatim launch as a real main window: `customtitlebar` opts into the
60
+ // platform-native title bar handling that pairs with the buttonbox
61
+ // markup below, the explicit width/height avoid the OS-minimum first
62
+ // launch, and `persist` lets the platform remember geometry across
63
+ // restarts via XULStore.
64
+ const navigatorBrowserAttrs = withTitlebar
65
+ ? ` windowtype="navigator:browser"
66
+ customtitlebar="true"
67
+ width="1024"
68
+ height="640"
69
+ persist="screenX screenY width height sizemode"`
70
+ : '';
41
71
  const titlebarMarkup = withTitlebar
42
72
  ? `
43
73
  <hbox class="titlebar-buttonbox-container">
@@ -50,9 +80,8 @@ export function generateChromeDocXhtml(name, withTitlebar, license) {
50
80
  <window
51
81
  xmlns="http://www.w3.org/1999/xhtml"
52
82
  xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
53
- id="${name}-window"${windowAttr}
83
+ id="${name}-window"${navigatorBrowserAttrs}
54
84
  ${FURNACE_CHROME_DOC_SENTINEL}="${name}"
55
- data-l10n-id="${name}-window-title"
56
85
  role="application">
57
86
  <head>
58
87
  <meta charset="utf-8" />
@@ -60,6 +89,7 @@ export function generateChromeDocXhtml(name, withTitlebar, license) {
60
89
  <link rel="localization" href="browser/${name}.ftl" />
61
90
  <link rel="stylesheet" href="chrome://global/skin/global.css" />
62
91
  <link rel="stylesheet" href="chrome://browser/content/${name}-chrome.css" />
92
+ <script src="chrome://global/content/customElements.js"></script>
63
93
  <script src="chrome://browser/content/${name}.js"></script>
64
94
  </head>
65
95
  <body>${titlebarMarkup}
@@ -105,14 +135,42 @@ window.addEventListener(
105
135
  `;
106
136
  }
107
137
  /**
108
- * Scoped CSS for a chrome document. When `withTitlebar` is false the
109
- * macOS `.titlebar-button { display: none }` carve-out is emitted so
110
- * frameless overlay-style documents don't inherit the platform window
111
- * controls that `global.css` applies by default.
138
+ * Scoped CSS for a chrome document.
139
+ *
140
+ * When `withTitlebar` is true, the matching navigator:browser minimum
141
+ * CSS is emitted alongside the layout rules: the buttonbox container is
142
+ * a draggable region (`-moz-window-dragging: drag`) so the user can drag
143
+ * the window from the title bar, and the buttonbox itself opts into the
144
+ * platform-native window-button-box appearance so the OS renders the
145
+ * traffic-light / minimize-maximize-close controls in their canonical
146
+ * positions. Without these rules the buttonbox markup still draws but
147
+ * is unstyled and non-draggable, which is the failure mode a fork that
148
+ * ships the scaffold verbatim hits on first launch.
149
+ *
150
+ * When `withTitlebar` is false the macOS `.titlebar-button { display: none }`
151
+ * carve-out is emitted so frameless overlay-style documents don't inherit
152
+ * the platform window controls that `global.css` applies by default.
112
153
  */
113
154
  export function generateChromeDocCss(name, withTitlebar, licenseHeader) {
114
155
  const titlebarOverrides = withTitlebar
115
- ? ''
156
+ ? `
157
+
158
+ /* navigator:browser minimum titlebar styling. Pairs with the
159
+ \`customtitlebar="true"\` + \`titlebar-buttonbox\` markup the XHTML
160
+ template emits when --with-titlebar is set. The container is the drag
161
+ region; the inner buttonbox opts into the platform-native traffic
162
+ light / minimize-maximize-close appearance via \`-moz-window-button-box\`. */
163
+ .titlebar-buttonbox-container {
164
+ -moz-window-dragging: drag;
165
+ display: flex;
166
+ align-items: center;
167
+ }
168
+
169
+ .titlebar-buttonbox {
170
+ appearance: auto;
171
+ -moz-default-appearance: -moz-window-button-box;
172
+ }
173
+ `
116
174
  : `
117
175
 
118
176
  /* Frameless overlay — suppress the platform titlebar buttons that
@@ -194,4 +252,29 @@ export function jarIncMnEntryForChromeDoc(name) {
194
252
  export function localeJarMnEntryForChromeDoc(name) {
195
253
  return ` locale/browser/${name}.ftl (%browser/${name}.ftl)`;
196
254
  }
255
+ /**
256
+ * Returns true when `jarMnContents` already carries a `[localization]`-style
257
+ * wildcard rooted at `%browser/` whose pattern would already pick up a
258
+ * scaffolded `browser/<name>.ftl` file. Recognises:
259
+ *
260
+ * - `(%browser/**\/*.ftl)` — recursive (the upstream shape).
261
+ * - `(%browser/*.ftl)` — flat.
262
+ *
263
+ * Forks that have migrated entirely to `[localization]` wildcards typically
264
+ * keep no per-file `locale/...` entries for FTL at all; appending one
265
+ * there is dead weight at best, and an outright build break when the fork
266
+ * has also dropped the `% locale browser …` registration. The chrome-doc
267
+ * scaffolder consults this predicate before its locales/jar.mn append and
268
+ * skips the per-file write when the wildcard already covers the scaffold's
269
+ * target path.
270
+ *
271
+ * Conservative by design: only wildcards rooted at `%browser/` count, and
272
+ * a `(%browser/foo.ftl)`-style explicit reference (no `*`) is not treated
273
+ * as a capture. A fork with a narrower wildcard (e.g. `(%browser/about/*.ftl)`)
274
+ * is correctly NOT captured by this predicate, because that wildcard would
275
+ * not pick up the top-level `browser/<name>.ftl` the scaffold writes.
276
+ */
277
+ export function localesFtlWildcardCapturesScaffoldedName(jarMnContents) {
278
+ return /\(%browser\/(?:\*\*\/)?\*\.ftl\)/.test(jarMnContents);
279
+ }
197
280
  //# sourceMappingURL=chrome-doc-templates.js.map
@@ -25,7 +25,7 @@ import { InvalidArgumentError } from '../../errors/base.js';
25
25
  import { FurnaceError } from '../../errors/furnace.js';
26
26
  import { pathExists, readText, writeText } from '../../utils/fs.js';
27
27
  import { intro, note, outro } from '../../utils/logger.js';
28
- import { generateChromeDocCss, generateChromeDocFtl, generateChromeDocJs, generateChromeDocXhtml, jarIncMnEntryForChromeDoc, jarMnEntriesForChromeDoc, localeJarMnEntryForChromeDoc, } from './chrome-doc-templates.js';
28
+ import { generateChromeDocCss, generateChromeDocFtl, generateChromeDocJs, generateChromeDocXhtml, jarIncMnEntryForChromeDoc, jarMnEntriesForChromeDoc, localeJarMnEntryForChromeDoc, localesFtlWildcardCapturesScaffoldedName, } from './chrome-doc-templates.js';
29
29
  import { chromeDocPackagingTestFileName, generateChromeDocPackagingManifest, generateChromeDocPackagingTest, } from './chrome-doc-tests.js';
30
30
  /** Chrome-doc name shape: lowercase ASCII, optional hyphens, no leading digit. */
31
31
  const CHROME_DOC_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -122,7 +122,29 @@ async function performChromeDocMutations(args) {
122
122
  await appendJarEntryIfAbsent(jarIncMnPath, jarIncMnEntryForChromeDoc(args.name), journal);
123
123
  written.push('browser/themes/shared/jar.inc.mn');
124
124
  const localeJarMnPath = join(args.engineDir, 'browser/locales/jar.mn');
125
- await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
125
+ // Forks that have migrated to a `[localization] (%browser/**/*.ftl)`
126
+ // wildcard already pick up the scaffolded FTL automatically — appending
127
+ // a per-file `locale/...` entry on top is at best dead weight and at
128
+ // worst a build error when the fork has dropped the `% locale browser`
129
+ // registration the per-file entry depends on. The wildcard predicate
130
+ // is intentionally narrow: only `%browser/`-rooted globs that end in
131
+ // `*.ftl` count as a capture.
132
+ if (await pathExists(localeJarMnPath)) {
133
+ const existingLocaleJar = await readText(localeJarMnPath);
134
+ if (localesFtlWildcardCapturesScaffoldedName(existingLocaleJar)) {
135
+ note(`Locale jar.mn already carries a [localization] wildcard that captures browser/${args.name}.ftl — skipping the per-file entry.`, args.name);
136
+ }
137
+ else {
138
+ await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
139
+ }
140
+ }
141
+ else {
142
+ // Preserve the existing "missing locale jar.mn" failure mode: pretend
143
+ // we still want to append so appendJarEntryIfAbsent surfaces the same
144
+ // FurnaceError it does for the other two jars. Forks that move the
145
+ // file deserve the same explicit complaint everywhere.
146
+ await appendJarEntryIfAbsent(localeJarMnPath, localeJarMnEntryForChromeDoc(args.name), journal);
147
+ }
126
148
  written.push('browser/locales/jar.mn');
127
149
  // --with-tests scaffolds an xpcshell packaging verification. All writes
128
150
  // go through the same rollback journal so a SIGINT here restores the
@@ -5,6 +5,7 @@ import { applyAllComponents, applyCustomComponent, applyOverrideComponent, compu
5
5
  import { logApplyResult } from '../../core/furnace-apply-output.js';
6
6
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, updateFurnaceState, } from '../../core/furnace-config.js';
7
7
  import { resolveFtlDir } from '../../core/furnace-constants.js';
8
+ import { resolveFurnaceMarkerComment } from '../../core/furnace-marker.js';
8
9
  import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
9
10
  import { createRollbackJournal, restoreRollbackJournalOrThrow, } from '../../core/furnace-rollback.js';
10
11
  import { findOverrideBaseVersionDrift, formatOverrideBaseVersionDriftError, formatOverrideBaseVersionDriftWarning, } from '../../core/furnace-version-drift.js';
@@ -305,6 +306,14 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
305
306
  // the plan before deciding whether to refresh the override or acknowledge
306
307
  // the new baseline in furnace.json.
307
308
  const forgeConfig = await loadConfig(projectRoot);
309
+ // 2026-04-26 eval Finding 6: when `markerComment` is unset in
310
+ // fireforge.json, default it to `binaryName.toUpperCase()` so the
311
+ // furnace-emitted edits to upstream files satisfy
312
+ // `lintModificationComments` on the next `lint`/`export` round-trip.
313
+ // The lint rule keys on the same uppercased binaryName, so the
314
+ // implicit default is identical to what the rule expects. Threaded
315
+ // through `applyNamedComponent` below.
316
+ const resolvedMarkerComment = resolveFurnaceMarkerComment(forgeConfig);
308
317
  const driftEntries = findOverrideBaseVersionDrift(config, forgeConfig.firefox.version);
309
318
  const force = options.force ?? false;
310
319
  const scopedDrift = name ? driftEntries.filter((entry) => entry.name === name) : driftEntries;
@@ -317,7 +326,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
317
326
  // `furnace deploy` runs only contend on the actual mutation.
318
327
  const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
319
328
  if (name) {
320
- const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, forgeConfig.markerComment);
329
+ const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, resolvedMarkerComment);
321
330
  if (namedApplyResult === 'stock') {
322
331
  return { kind: 'stock' };
323
332
  }
@@ -5,6 +5,7 @@ import { text } from '@clack/prompts';
5
5
  import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
6
6
  import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
7
7
  import { DEFAULT_LICENSE } from '../../core/license-headers.js';
8
+ import { registerSharedCSS } from '../../core/register-shared-css.js';
8
9
  import { getTokensCssPath } from '../../core/token-manager.js';
9
10
  import { generateDefaultTokensCss } from '../../core/token-scaffold.js';
10
11
  import { FurnaceError } from '../../errors/furnace.js';
@@ -184,9 +185,11 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
184
185
  }
185
186
  note(lines.join('\n'), 'Configuration');
186
187
  info('Next steps:\n' +
187
- ' fireforge furnace scan — discover engine components\n' +
188
+ ' fireforge furnace scan — discover engine components\n' +
188
189
  ' fireforge furnace create — create a new custom component\n' +
189
- ' fireforge furnace override — fork an existing component');
190
+ ' fireforge furnace override — fork an existing component\n' +
191
+ ' fireforge token add — define a token in the scaffolded tokens CSS\n' +
192
+ ' fireforge export <tokens.css> — capture the tokens CSS + its registration in a patch');
190
193
  outro('Init complete');
191
194
  }
192
195
  /**
@@ -251,6 +254,29 @@ async function scaffoldTokensCss(projectRoot) {
251
254
  warn(`Could not register tokens CSS in patchLint.rawColorAllowlist: ${toError(error).message}. ` +
252
255
  `Add "${tokensCssPath}" manually under patchLint.rawColorAllowlist in fireforge.json if lint flags its contents.`);
253
256
  }
257
+ // 2026-04-26 eval Finding 2: register the tokens CSS in
258
+ // browser/themes/shared/jar.inc.mn so the file is owned end-to-end by
259
+ // tooling. Pre-fix, `furnace init` only scaffolded + allowlisted the
260
+ // file, so the very next `fireforge status` correctly flagged it as
261
+ // unmanaged + unregistered and `furnace deploy --dry-run` reported
262
+ // nothing to deploy — a documented init command turned a clean
263
+ // project into an unclean one. The CSS lives at the canonical
264
+ // `browser/themes/shared/<binaryName>-tokens.css` path that the
265
+ // shared-CSS rule already targets, so the tokens file gets the same
266
+ // `skin/classic/browser/<name>.css (../shared/<name>.css)` jar.inc.mn
267
+ // entry as any other shared CSS. Idempotent — running
268
+ // `furnace init --force` against a registered tree is a no-op.
269
+ try {
270
+ const fileBase = `${forgeConfig.binaryName}-tokens.css`;
271
+ const result = await registerSharedCSS(paths.engine, fileBase, undefined, false);
272
+ if (!result.skipped) {
273
+ info(`Registered ${fileBase} in browser/themes/shared/jar.inc.mn`);
274
+ }
275
+ }
276
+ catch (error) {
277
+ warn(`Could not register tokens CSS in browser/themes/shared/jar.inc.mn: ${toError(error).message}. ` +
278
+ `Run "fireforge register browser/themes/shared/${forgeConfig.binaryName}-tokens.css" once jar.inc.mn is reachable.`);
279
+ }
254
280
  return { tokensCssPath };
255
281
  }
256
282
  //# sourceMappingURL=init.js.map
@@ -212,6 +212,64 @@ async function cleanupCustomTestFiles(name, projectRoot, journal) {
212
212
  }
213
213
  return { partialFailures };
214
214
  }
215
+ /**
216
+ * Removes the MochiKit test scaffold a `furnace create --with-tests
217
+ * --test-style mochikit` produced for the component (matches the rename
218
+ * counterpart in `rename.ts`). The test file is `test_<name>.html` under
219
+ * `engine/toolkit/content/tests/widgets/` and the registration is the
220
+ * `["test_<name>.html"]` entry in the same directory's `chrome.toml`.
221
+ *
222
+ * 2026-04-25 eval Finding 13: the prior cleanup only handled the
223
+ * browser-chrome mochitest layout under `browser/base/content/test/
224
+ * <binary>/`, which left mochikit-style scaffolds and their toml entries
225
+ * orphaned after `furnace remove`. The post-rename name passed in here
226
+ * is the canonical one written to disk by deploy/rename, so the file
227
+ * basenames match without needing to re-derive from the old name.
228
+ *
229
+ * Best-effort: each step warns on failure rather than throwing so the
230
+ * rest of the remove transaction proceeds. The journal still snapshots
231
+ * touched files so the outer rollback can restore them on a later
232
+ * failure in the same operation.
233
+ */
234
+ async function cleanupCustomMochikitTestFiles(name, projectRoot, journal) {
235
+ const partialFailures = [];
236
+ const paths = getProjectPaths(projectRoot);
237
+ const widgetsTestDir = join(paths.engine, 'toolkit/content/tests/widgets');
238
+ if (!(await pathExists(widgetsTestDir))) {
239
+ return { partialFailures };
240
+ }
241
+ const testFileName = `test_${name}.html`;
242
+ const testFilePath = join(widgetsTestDir, testFileName);
243
+ try {
244
+ if (await pathExists(testFilePath)) {
245
+ await snapshotFile(journal, testFilePath);
246
+ await unlink(testFilePath);
247
+ info(`Deleted mochikit test file: toolkit/content/tests/widgets/${testFileName}`);
248
+ }
249
+ }
250
+ catch (error) {
251
+ const msg = `Could not delete mochikit test file ${testFileName} — ${toError(error).message}. Remove it manually if needed.`;
252
+ warn(msg);
253
+ partialFailures.push(msg);
254
+ }
255
+ const chromeTomlPath = join(widgetsTestDir, 'chrome.toml');
256
+ try {
257
+ if (await pathExists(chromeTomlPath)) {
258
+ const toml = await readText(chromeTomlPath);
259
+ const headerLine = `["${testFileName}"]`;
260
+ if (toml.includes(headerLine)) {
261
+ await snapshotFile(journal, chromeTomlPath);
262
+ await writeText(chromeTomlPath, removeTomlSection(toml, testFileName));
263
+ }
264
+ }
265
+ }
266
+ catch (error) {
267
+ const msg = `Could not update widgets chrome.toml — ${toError(error).message}. Remove the test entry manually if needed.`;
268
+ warn(msg);
269
+ partialFailures.push(msg);
270
+ }
271
+ return { partialFailures };
272
+ }
215
273
  /**
216
274
  * Removes generated xpcshell test scaffolds associated with a custom
217
275
  * component. 2026-04-24 eval Finding 5: `furnace remove` handled
@@ -433,6 +491,16 @@ export async function furnaceRemoveCommand(projectRoot, name, options = {}) {
433
491
  // versions.
434
492
  const xpcshellResult = await cleanupCustomXpcshellTestFiles(name, projectRoot, journal);
435
493
  testCleanupFailures.push(...xpcshellResult.partialFailures);
494
+ // 2026-04-25 eval Finding 13: mochikit-style scaffolds
495
+ // (`--test-style mochikit`) live under
496
+ // `engine/toolkit/content/tests/widgets/` with `chrome.toml`
497
+ // entries — neither the browser-chrome path nor the xpcshell
498
+ // path touches them. Without this pass, a `furnace create
499
+ // --with-tests --test-style mochikit` followed by `furnace
500
+ // remove` left the test file and its toml entry referencing a
501
+ // component that no longer exists.
502
+ const mochikitResult = await cleanupCustomMochikitTestFiles(name, projectRoot, journal);
503
+ testCleanupFailures.push(...mochikitResult.partialFailures);
436
504
  }
437
505
  // Remove entry from furnace.json
438
506
  if (type === 'stock') {
@@ -74,7 +74,15 @@ async function checkUncommittedPatchFiles(engineDir, patchesDir, forceImport) {
74
74
  if (dirtyFiles.length > 0) {
75
75
  const unmanagedDirtyFiles = await getUnmanagedDirtyFiles(engineDir, patchesDir, dirtyFiles);
76
76
  if (unmanagedDirtyFiles.length === 0) {
77
- info('Patch-backed materialized files already match the stored patch stack.');
77
+ // Common path here: operator just ran `fireforge resolve` to
78
+ // regenerate a patch from manual conflict edits, so the engine
79
+ // already carries the patch's effects. The import below will
80
+ // still re-apply each patch (a no-op for files whose contents
81
+ // already match), so phrase the line as "no resync needed"
82
+ // rather than "patches already applied" — the latter contradicts
83
+ // the "Applied N patch(es)" summary `applyPatchesWithContinue`
84
+ // prints next, which the 2026-04-25 eval flagged as ambiguous.
85
+ info('Patch-touched files already match the stored patch stack — no engine resync needed before re-applying.');
78
86
  }
79
87
  else if (!forceImport) {
80
88
  warn('Uncommitted changes detected in files that patches will modify:');
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { isBrandingManagedPath } from '../core/branding.js';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
+ import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
6
7
  import { getStatusWithCodes, hasChanges, isGitRepository } from '../core/git.js';
7
8
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
9
  import { expandUntrackedDirectoryEntries, getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, getWorkingTreeStatus, } from '../core/git-status.js';
@@ -35,7 +36,7 @@ import { stripEnginePrefix } from '../utils/paths.js';
35
36
  * previous behaviour: passing a branding file explicitly still lints
36
37
  * it, so operators who need to audit branding content can do so.
37
38
  */
38
- async function resolveLintDiff(engineDir, files, binaryName) {
39
+ async function resolveLintDiff(engineDir, files, binaryName, furnacePrefixes) {
39
40
  if (files.length > 0) {
40
41
  const collectedFiles = new Set();
41
42
  let fileStatuses;
@@ -115,16 +116,31 @@ async function resolveLintDiff(engineDir, files, binaryName) {
115
116
  const expanded = await expandUntrackedDirectoryEntries(engineDir, rawStatus);
116
117
  const allPaths = [...new Set(expanded.map((entry) => entry.file))];
117
118
  const nonBrandingPaths = allPaths.filter((path) => !isBrandingManagedPath(path, binaryName));
118
- const excludedCount = allPaths.length - nonBrandingPaths.length;
119
- if (excludedCount > 0) {
120
- info(`Excluded ${excludedCount} tool-managed branding file${excludedCount === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
119
+ const brandingExcluded = allPaths.length - nonBrandingPaths.length;
120
+ // Drop Furnace-managed paths the same way branding is dropped: their
121
+ // contents are tool output (overrides, custom widgets, preview-
122
+ // generated stories) that the operator did not author and never
123
+ // intended to land on the patch queue. Without this carve-out, a
124
+ // post-`furnace preview` aggregate `lint` failed with one
125
+ // `missing-license-header` error per generated story file (eval
126
+ // Finding 19) — each story is intentionally header-less because it's
127
+ // re-generated from component metadata on every preview run.
128
+ const filteredPaths = furnacePrefixes
129
+ ? nonBrandingPaths.filter((path) => ![...furnacePrefixes].some((p) => path.startsWith(p)))
130
+ : nonBrandingPaths;
131
+ const furnaceExcluded = nonBrandingPaths.length - filteredPaths.length;
132
+ if (brandingExcluded > 0) {
133
+ info(`Excluded ${brandingExcluded} tool-managed branding file${brandingExcluded === 1 ? '' : 's'} from lint. Pass the path explicitly or use \`fireforge lint <path>\` to include them.`);
121
134
  }
122
- if (nonBrandingPaths.length === 0) {
123
- info('No non-branding changes to lint.');
135
+ if (furnaceExcluded > 0) {
136
+ info(`Excluded ${furnaceExcluded} Furnace-managed file${furnaceExcluded === 1 ? '' : 's'} from lint (deployed components and preview-generated stories). Pass the path explicitly to include them.`);
137
+ }
138
+ if (filteredPaths.length === 0) {
139
+ info('No non-branding, non-Furnace changes to lint.');
124
140
  outro('Nothing to lint');
125
141
  return null;
126
142
  }
127
- const diff = await getDiffForFilesAgainstHead(engineDir, nonBrandingPaths.sort());
143
+ const diff = await getDiffForFilesAgainstHead(engineDir, filteredPaths.sort());
128
144
  if (!diff.trim()) {
129
145
  info('No diff content to lint.');
130
146
  outro('Nothing to lint');
@@ -185,7 +201,13 @@ export async function lintCommand(projectRoot, files, options = {}) {
185
201
  // the diff was resolved; hoisting it is cheap and keeps the two
186
202
  // call sites close together.
187
203
  const config = await loadConfig(projectRoot);
188
- const diff = await resolveLintDiff(paths.engine, files, config.binaryName);
204
+ // Pull the Furnace-managed prefix set up-front so aggregate lint can
205
+ // mirror the branding exclusion for Furnace material — without it,
206
+ // preview-generated stories under `browser/components/storybook/
207
+ // stories/furnace/` show up as license-header errors on every
208
+ // post-preview lint run.
209
+ const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
210
+ const diff = await resolveLintDiff(paths.engine, files, config.binaryName, furnacePrefixes);
189
211
  if (diff === null)
190
212
  return;
191
213
  const filesAffected = extractAffectedFiles(diff);
@@ -284,7 +306,23 @@ export async function lintCommand(projectRoot, files, options = {}) {
284
306
  : '';
285
307
  throw new GeneralError(`Patch lint found ${failingErrors.length} ${options.onlyIntroduced ? 'introduced ' : ''}error(s). Fix these before exporting.${cumulativeSuppressed}`);
286
308
  }
287
- outro('Lint passed with warnings');
309
+ // Notices are advisory and don't count as warnings — emitting "passed
310
+ // with warnings" when only notices fired contradicts the preceding
311
+ // `0 warning(s)` summary line and reads as a regression. Distinguish
312
+ // the three pass states explicitly. Errors suppressed by
313
+ // --only-introduced still warrant the "with warnings" outro — they
314
+ // print as ERROR rows but no longer fail the run, which is the same
315
+ // contract the operator gets from a real warning.
316
+ const suppressedErrors = options.onlyIntroduced && errors.length > 0;
317
+ if (warnings.length > 0 || suppressedErrors) {
318
+ outro('Lint passed with warnings');
319
+ }
320
+ else if (notices.length > 0) {
321
+ outro('Lint passed with notices');
322
+ }
323
+ else {
324
+ outro('Lint passed');
325
+ }
288
326
  }
289
327
  /**
290
328
  * Lints each patch in the queue as its own isolated diff, honouring
@@ -310,17 +348,22 @@ async function lintPerPatch(projectRoot, paths) {
310
348
  const ctx = await buildPatchQueueContext(paths.patches);
311
349
  const issues = [];
312
350
  let linted = 0;
351
+ let skipped = 0;
313
352
  for (const patch of manifest.patches) {
314
353
  const existing = [];
315
354
  for (const f of patch.filesAffected) {
316
355
  if (await pathExists(join(paths.engine, f)))
317
356
  existing.push(f);
318
357
  }
319
- if (existing.length === 0)
358
+ if (existing.length === 0) {
359
+ skipped++;
320
360
  continue;
361
+ }
321
362
  const diff = await getDiffForFilesAgainstHead(paths.engine, existing);
322
- if (!diff.trim())
363
+ if (!diff.trim()) {
364
+ skipped++;
323
365
  continue;
366
+ }
324
367
  const ignore = patch.lintIgnore?.length ? new Set(patch.lintIgnore) : undefined;
325
368
  const decision = resolvePatchSizeTier(existing, patch.tier);
326
369
  if (decision.tier === 'branding') {
@@ -339,7 +382,21 @@ async function lintPerPatch(projectRoot, paths) {
339
382
  // context.
340
383
  issues.push(...lintPatchQueue(ctx));
341
384
  if (issues.length === 0) {
342
- success(`No lint issues found across ${linted} patch(es).`);
385
+ // 2026-04-26 eval Finding 7: pre-fix the success line read
386
+ // `No lint issues found across 0 patch(es).` whenever the queue
387
+ // had not been applied to the engine — every patch's
388
+ // `filesAffected` filtered out, so `existing` was empty and the
389
+ // patch was silently skipped. Operators read that as "the queue
390
+ // is clean" when in reality nothing was checked. Surface the
391
+ // skipped count and, when nothing was linted at all, point at
392
+ // `fireforge import` as the missing prerequisite.
393
+ if (linted === 0 && skipped > 0) {
394
+ info(`No patches in the queue have been applied to engine/. Run "fireforge import" first if you want lint findings against the staged hunks; otherwise this is expected.`);
395
+ }
396
+ const summary = skipped > 0
397
+ ? `No lint issues found across ${linted} patch(es) (${skipped} skipped — files not present in engine/).`
398
+ : `No lint issues found across ${linted} patch(es).`;
399
+ success(summary);
343
400
  outro('Lint passed');
344
401
  return;
345
402
  }
@@ -357,7 +414,15 @@ async function lintPerPatch(projectRoot, paths) {
357
414
  outro('Lint failed');
358
415
  throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
359
416
  }
360
- outro('Lint passed with warnings');
417
+ if (warnings.length > 0) {
418
+ outro('Lint passed with warnings');
419
+ }
420
+ else if (notices.length > 0) {
421
+ outro('Lint passed with notices');
422
+ }
423
+ else {
424
+ outro('Lint passed');
425
+ }
361
426
  }
362
427
  /** Registers the lint command on the CLI program. */
363
428
  export function registerLint(program, { getProjectRoot, withErrorHandling }) {
@@ -10,6 +10,7 @@
10
10
  import { basename } from 'node:path';
11
11
  import { getProjectPaths } from '../../core/config.js';
12
12
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
13
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
13
14
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
14
15
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
15
16
  import { loadPatchesManifest, removePatchFileAndManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
@@ -39,10 +40,7 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
39
40
  }
40
41
  const target = resolvePatchIdentifier(identifier, manifest.patches);
41
42
  if (!target) {
42
- const available = manifest.patches
43
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
44
- .join(', ');
45
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
43
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
46
44
  }
47
45
  // Build the full queue context once so we can scan each patch's newFiles
48
46
  // without re-parsing for the dependency check below.
@@ -22,6 +22,7 @@
22
22
  import { getProjectPaths } from '../../core/config.js';
23
23
  import { appendHistory } from '../../core/destructive.js';
24
24
  import { mutatePatchMetadata } from '../../core/patch-export.js';
25
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
26
  import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
26
27
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
27
28
  import { toError } from '../../utils/errors.js';
@@ -113,10 +114,7 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
113
114
  }
114
115
  const target = resolvePatchIdentifier(identifier, manifest.patches);
115
116
  if (!target) {
116
- const available = manifest.patches
117
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
118
- .join(', ');
119
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
117
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
120
118
  }
121
119
  if (isDryRun) {
122
120
  const existing = target.lintIgnore ?? [];
@@ -11,6 +11,7 @@
11
11
  import { Option } from 'commander';
12
12
  import { getProjectPaths } from '../../core/config.js';
13
13
  import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
14
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
14
15
  import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
15
16
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
16
17
  import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
@@ -270,10 +271,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
270
271
  }
271
272
  const target = resolvePatchIdentifier(identifier, manifest.patches);
272
273
  if (!target) {
273
- const available = manifest.patches
274
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
275
- .join(', ');
276
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
274
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
277
275
  }
278
276
  const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
279
277
  const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
@@ -18,6 +18,7 @@ import { Option } from 'commander';
18
18
  import { getProjectPaths } from '../../core/config.js';
19
19
  import { appendHistory } from '../../core/destructive.js';
20
20
  import { updatePatchMetadata } from '../../core/patch-export.js';
21
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
21
22
  import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
22
23
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
23
24
  import { toError } from '../../utils/errors.js';
@@ -56,10 +57,7 @@ export async function patchTierCommand(projectRoot, identifier, options = {}) {
56
57
  }
57
58
  const target = resolvePatchIdentifier(identifier, manifest.patches);
58
59
  if (!target) {
59
- const available = manifest.patches
60
- .map((p) => p.name && p.name !== p.filename ? `${p.filename} (name: ${p.name})` : p.filename)
61
- .join(', ');
62
- throw new InvalidArgumentError(`Patch "${identifier}" not found. Accepted identifiers: ordinal (e.g. 2), filename (e.g. 002-ui-foo.patch), or manifest name (e.g. ui-foo). Available: ${available}`, identifier);
60
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
63
61
  }
64
62
  const before = target.tier;
65
63
  const after = setting ? options.tier : undefined;