@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.
- package/README.md +29 -16
- package/dist/src/commands/build.js +27 -12
- package/dist/src/commands/config.js +56 -3
- package/dist/src/commands/discard.js +93 -1
- package/dist/src/commands/doctor.js +17 -4
- package/dist/src/commands/download.js +21 -0
- package/dist/src/commands/export-all.js +35 -6
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +59 -8
- package/dist/src/commands/furnace/chrome-doc-templates.js +95 -12
- package/dist/src/commands/furnace/chrome-doc.js +24 -2
- package/dist/src/commands/furnace/deploy.js +10 -1
- package/dist/src/commands/furnace/init.js +28 -2
- package/dist/src/commands/furnace/remove.js +68 -0
- package/dist/src/commands/import.js +9 -1
- package/dist/src/commands/lint.js +78 -13
- package/dist/src/commands/patch/delete.js +2 -4
- package/dist/src/commands/patch/lint-ignore.js +2 -4
- package/dist/src/commands/patch/reorder.js +2 -4
- package/dist/src/commands/patch/tier.js +2 -4
- package/dist/src/commands/status.js +39 -1
- package/dist/src/commands/test.js +20 -1
- package/dist/src/commands/token.js +1 -1
- package/dist/src/core/furnace-apply.js +11 -3
- package/dist/src/core/furnace-config.js +19 -0
- package/dist/src/core/furnace-marker.d.ts +16 -0
- package/dist/src/core/furnace-marker.js +23 -0
- package/dist/src/core/git.js +66 -10
- package/dist/src/core/license-headers.d.ts +8 -0
- package/dist/src/core/license-headers.js +15 -1
- package/dist/src/core/manifest-rules.js +9 -1
- package/dist/src/core/patch-identifier-suggest.d.ts +25 -0
- package/dist/src/core/patch-identifier-suggest.js +108 -0
- package/dist/src/core/patch-lint.js +8 -0
- package/dist/src/core/register-shared-css.d.ts +28 -0
- package/dist/src/core/register-shared-css.js +67 -3
- 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
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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"${
|
|
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.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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 (
|
|
123
|
-
info('
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|