@hominis/fireforge 0.18.2 → 0.18.3
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 +19 -12
- 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/remove.js +68 -0
- package/dist/src/commands/import.js +9 -1
- package/dist/src/commands/lint.js +56 -10
- package/dist/src/commands/status.js +27 -0
- package/dist/src/commands/test.js +20 -1
- package/dist/src/commands/token.js +1 -1
- package/dist/src/core/furnace-config.js +19 -0
- 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-lint.js +8 -0
- package/dist/src/core/register-shared-css.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,6 +143,9 @@ fireforge re-export --all --scan
|
|
|
143
143
|
# Preview what an export would do without writing
|
|
144
144
|
fireforge export browser/base/content/browser.js --dry-run
|
|
145
145
|
|
|
146
|
+
# Same preview surface for the aggregate path
|
|
147
|
+
fireforge export-all --name "all-changes" --category ui --dry-run
|
|
148
|
+
|
|
146
149
|
# Insert a new patch at a specific position
|
|
147
150
|
fireforge export browser/base/content/browser.js --order 3 --name "inserted" --category ui
|
|
148
151
|
fireforge export browser/base/content/browser.js --before 005-ui-sidebar.patch --name "prelim"
|
|
@@ -158,7 +161,7 @@ fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
|
|
|
158
161
|
fireforge re-export --all --scan --stamp
|
|
159
162
|
```
|
|
160
163
|
|
|
161
|
-
`export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default.
|
|
164
|
+
`export` refuses when the new patch's `filesAffected` would overlap with files already claimed by another non-superseded patch. Repartitioning ownership is a deliberate operation: the message points at `fireforge re-export --files <paths> <patch>` as the safe primitive. Pass `--allow-overlap` to acknowledge the conflict and proceed anyway — the resulting queue will fail `fireforge verify` immediately, so this is an intentional escape hatch, not a default. The flag covers cross-patch _modification_ overlap, where two patches both edit the same file. It does NOT bypass the new-file creation guard: two patches creating the same path on `/dev/null` cannot coexist in any apply order, so that case stays a hard refusal regardless of `--allow-overlap`.
|
|
162
165
|
|
|
163
166
|
`re-export --scan` also prompts before broadening a patch with more than a handful of newly discovered files or with files spanning multiple directories. The gate keeps the common refresh case frictionless (small, same-directory additions) while catching the failure mode where `--scan` silently pulls an adjacent feature into the wrong patch. Non-interactive mode requires `--yes` to acknowledge a broad expansion; dry-run previews never require confirmation.
|
|
164
167
|
|
|
@@ -267,16 +270,16 @@ fireforge status --json # machine-readable classified output
|
|
|
267
270
|
|
|
268
271
|
Then fix with the appropriate primitive:
|
|
269
272
|
|
|
270
|
-
| Problem | Fix
|
|
271
|
-
| ---------------------------------------------- |
|
|
272
|
-
| Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files`
|
|
273
|
-
| A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>`
|
|
274
|
-
| Wrong patch ordering | `fireforge patch reorder <patch> --to <N>`
|
|
275
|
-
| Ordinal gaps after deletes/splits | `fireforge patch compact`
|
|
276
|
-
| A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>`
|
|
277
|
-
| Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest`
|
|
278
|
-
| Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes
|
|
279
|
-
| Unmanaged changes you want to discard | `fireforge discard <file>` or `fireforge reset`
|
|
273
|
+
| Problem | Fix |
|
|
274
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
275
|
+
| Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
|
|
276
|
+
| A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
|
|
277
|
+
| Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
|
|
278
|
+
| Ordinal gaps after deletes/splits | `fireforge patch compact` |
|
|
279
|
+
| A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
|
|
280
|
+
| Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
|
|
281
|
+
| Dangling widget / locale registration in patch | Re-run `fireforge export` without `--exclude-furnace` to capture the source files, or revert furnace changes |
|
|
282
|
+
| Unmanaged changes you want to discard | `fireforge discard <file>` (also accepts a directory path to discard everything beneath it) or `fireforge reset` |
|
|
280
283
|
|
|
281
284
|
Every destructive command defaults to an interactive confirmation with a change summary. `--dry-run` previews without writing; `--yes` skips the prompt for CI; `--force-unsafe` bypasses structural refusals when you have context the linter cannot see. Do not hand-edit `patches.json` as the file is owned by FireForge — `doctor --repair-patches-manifest` reconstructs missing metadata, and `fireforge re-export <filename> --description "<text>"` overwrites recovered entries with operator-supplied metadata through the tool. `fireforge verify` cross-checks every registration hunk in each patch body against the files the queue and engine supply, so a patch that registers a widget / locale without carrying its source surfaces as a `dangling-registration` error rather than slipping through as "Verify clean"; `fireforge export-all --exclude-furnace` refuses up-front when it would produce that shape.
|
|
282
285
|
|
|
@@ -466,7 +469,11 @@ fireforge package
|
|
|
466
469
|
fireforge watch
|
|
467
470
|
|
|
468
471
|
# Add a CSS design token (requires `fireforge furnace init` first; see the Furnace/Tokens section below)
|
|
469
|
-
|
|
472
|
+
# The `--` separator is required because the token name itself starts with `--`,
|
|
473
|
+
# which Commander would otherwise read as an option flag. Bare names without `--`
|
|
474
|
+
# are accepted directly and get the configured `tokenPrefix` prepended.
|
|
475
|
+
fireforge token add --category 'Colors — General' --mode static -- --my-color 'light-dark(#fff, #000)'
|
|
476
|
+
fireforge token add --category 'Colors — General' --mode static my-color '#fff' # bare-name form
|
|
470
477
|
```
|
|
471
478
|
|
|
472
479
|
Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`, and derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
|
|
@@ -11,6 +11,82 @@ import { toError } from '../utils/errors.js';
|
|
|
11
11
|
import { pathExists } from '../utils/fs.js';
|
|
12
12
|
import { info, intro, isCancel, outro, spinner, warn } from '../utils/logger.js';
|
|
13
13
|
import { pickDefined } from '../utils/options.js';
|
|
14
|
+
/**
|
|
15
|
+
* Discards every status entry whose path lives under `dirPath`. Used by
|
|
16
|
+
* `discardCommand` as a directory-recursion fallback when the operator
|
|
17
|
+
* passed a directory path that contains modified or untracked entries
|
|
18
|
+
* but is not itself a status entry.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the single-file path's confirmation, dry-run, and Furnace-aware
|
|
21
|
+
* warning behaviour so the contract stays consistent. Each per-entry
|
|
22
|
+
* discard runs sequentially under its own try/catch so a failure on one
|
|
23
|
+
* file is reported but does not block the remaining files in the batch.
|
|
24
|
+
*/
|
|
25
|
+
async function discardDirectoryEntries(projectRoot, engineDir, dirPath, entries, options) {
|
|
26
|
+
if (!options.yes && !options.dryRun) {
|
|
27
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
28
|
+
if (!isInteractive) {
|
|
29
|
+
throw new InvalidArgumentError('Interactive confirmation not available. Use --yes flag to discard without confirmation.', 'Use: fireforge discard <directory> --yes');
|
|
30
|
+
}
|
|
31
|
+
const confirmed = await confirm({
|
|
32
|
+
message: `Discard changes to ${entries.length} file${entries.length === 1 ? '' : 's'} under ${dirPath}/?`,
|
|
33
|
+
initialValue: false,
|
|
34
|
+
});
|
|
35
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
36
|
+
outro('Discard cancelled');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (options.dryRun) {
|
|
41
|
+
info(`Would discard changes to ${entries.length} file(s) under ${dirPath}/:`);
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const target = entry.originalPath && entry.originalPath !== entry.file
|
|
44
|
+
? `${entry.originalPath} -> ${entry.file}`
|
|
45
|
+
: entry.file;
|
|
46
|
+
info(` ${target}`);
|
|
47
|
+
}
|
|
48
|
+
outro('Dry run complete — no changes made');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const s = spinner(`Discarding ${entries.length} file(s) under ${dirPath}/...`);
|
|
52
|
+
let succeeded = 0;
|
|
53
|
+
const failures = [];
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
try {
|
|
57
|
+
await discardStatusEntry(engineDir, entry);
|
|
58
|
+
succeeded += 1;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
failures.push(`${entry.file}: ${toError(error).message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
s.stop(`Discarded ${succeeded} of ${entries.length} file(s) under ${dirPath}/${failures.length > 0 ? ` (${failures.length} failed)` : ''}`);
|
|
65
|
+
for (const failure of failures) {
|
|
66
|
+
warn(` ${failure}`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const furnacePrefixes = await collectFurnaceManagedPrefixes(projectRoot);
|
|
70
|
+
const dirIsFurnace = [...furnacePrefixes].some((prefix) => `${dirPath}/`.startsWith(prefix) || prefix.startsWith(`${dirPath}/`));
|
|
71
|
+
if (dirIsFurnace) {
|
|
72
|
+
warn('These paths are managed by Furnace. Run "fireforge furnace apply" to redeploy components if needed.');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Furnace config may not exist — skip silently
|
|
77
|
+
}
|
|
78
|
+
if (failures.length > 0) {
|
|
79
|
+
throw new GeneralError(`Failed to discard ${failures.length} file(s) under ${dirPath}/. See warnings above.`);
|
|
80
|
+
}
|
|
81
|
+
outro(`${succeeded} file(s) restored to original state`);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (!(error instanceof GeneralError)) {
|
|
85
|
+
s.error('Discard failed');
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
14
90
|
/**
|
|
15
91
|
* Runs the discard command to revert changes to a specific file.
|
|
16
92
|
* @param projectRoot - Root directory of the project
|
|
@@ -31,7 +107,23 @@ export async function discardCommand(projectRoot, file, options = {}) {
|
|
|
31
107
|
// Check if the file has changes
|
|
32
108
|
const statusEntries = await expandUntrackedDirectoryEntries(paths.engine, await getWorkingTreeStatus(paths.engine));
|
|
33
109
|
const statusEntry = statusEntries.find((entry) => entry.file === file || entry.originalPath === file);
|
|
110
|
+
// Directory recursion fallback: when the explicit path does not match a
|
|
111
|
+
// single status entry but DOES correspond to one or more entries below
|
|
112
|
+
// it, treat the input as a directory and discard everything inside.
|
|
113
|
+
// 2026-04-25 eval Finding 20: `discard browser/components/storybook/
|
|
114
|
+
// stories/furnace --yes` failed with "no changes to discard" even
|
|
115
|
+
// though `status --unmanaged` listed 23 files under that directory —
|
|
116
|
+
// operators were forced to discard each file individually or fall
|
|
117
|
+
// back to non-FireForge cleanup commands. Match against the
|
|
118
|
+
// directory-with-trailing-slash form so a path like `foo/bar` doesn't
|
|
119
|
+
// accidentally match `foo/bar2/file`.
|
|
34
120
|
if (!statusEntry) {
|
|
121
|
+
const dirPrefix = file.endsWith('/') ? file : `${file}/`;
|
|
122
|
+
const dirEntries = statusEntries.filter((entry) => entry.file.startsWith(dirPrefix) || entry.originalPath?.startsWith(dirPrefix));
|
|
123
|
+
if (dirEntries.length > 0) {
|
|
124
|
+
await discardDirectoryEntries(projectRoot, paths.engine, file, dirEntries, options);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
35
127
|
throw new GeneralError(`File "${file}" has no changes to discard.`);
|
|
36
128
|
}
|
|
37
129
|
if (!options.yes && !options.dryRun) {
|
|
@@ -91,7 +183,7 @@ export async function discardCommand(projectRoot, file, options = {}) {
|
|
|
91
183
|
export function registerDiscard(program, { getProjectRoot, withErrorHandling }) {
|
|
92
184
|
program
|
|
93
185
|
.command('discard <file>')
|
|
94
|
-
.description('Discard changes to a specific file (deletes untracked files)')
|
|
186
|
+
.description('Discard changes to a specific file (deletes untracked files). Pass a directory path to discard every modified or untracked file beneath it; the operation walks the status output and reverts each match individually.')
|
|
95
187
|
.option('--dry-run', 'Show what would be discarded without doing it')
|
|
96
188
|
.option('-y, --yes', 'Skip confirmation prompt')
|
|
97
189
|
.action(withErrorHandling(async (file, options) => {
|
|
@@ -9,7 +9,7 @@ import { ExitCode } from '../errors/codes.js';
|
|
|
9
9
|
import { toError } from '../utils/errors.js';
|
|
10
10
|
import { pathExists } from '../utils/fs.js';
|
|
11
11
|
import { error, info, intro, outro, success, warn } from '../utils/logger.js';
|
|
12
|
-
import {
|
|
12
|
+
import { findExecutable } from '../utils/process.js';
|
|
13
13
|
import { FURNACE_DOCTOR_CHECKS } from './doctor-furnace.js';
|
|
14
14
|
import { inspectEngineWorkingTree } from './doctor-working-tree.js';
|
|
15
15
|
/**
|
|
@@ -255,9 +255,22 @@ const DOCTOR_CHECKS = [
|
|
|
255
255
|
// failure site.
|
|
256
256
|
name: 'Watchman available',
|
|
257
257
|
run: async () => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
// Resolve the absolute path so the OK row names what doctor actually
|
|
259
|
+
// found. The 2026-04-25 eval flagged a confusing case where the
|
|
260
|
+
// operator's interactive shell returned no result for `which
|
|
261
|
+
// watchman` but doctor still printed "OK" — the cause was a
|
|
262
|
+
// PATH-export discrepancy between the shell and the spawned
|
|
263
|
+
// subprocess, and surfacing the resolved path makes the discrepancy
|
|
264
|
+
// visible without users having to re-run with a verbose flag.
|
|
265
|
+
const path = await findExecutable('watchman');
|
|
266
|
+
if (path) {
|
|
267
|
+
return {
|
|
268
|
+
name: 'Watchman available',
|
|
269
|
+
passed: true,
|
|
270
|
+
severity: 'ok',
|
|
271
|
+
message: `OK (${path})`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
261
274
|
return warning('Watchman available', 'watchman is not installed or not on PATH. "fireforge watch" requires it.', 'Install watchman (brew install watchman / dnf install watchman / https://facebook.github.io/watchman/), then re-run doctor.');
|
|
262
275
|
},
|
|
263
276
|
},
|
|
@@ -69,6 +69,25 @@ async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
|
69
69
|
}
|
|
70
70
|
return { hadQueue: true, restored: toClean.length, preserved: preserved.length };
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Prints a one-line nudge pointing at `fireforge import` when the project
|
|
74
|
+
* carries a non-empty patch queue but the just-downloaded engine has not
|
|
75
|
+
* yet had any patches applied. The post-download spinner closes with
|
|
76
|
+
* "Patch-touched files already match baseline" because a fresh tree IS at
|
|
77
|
+
* baseline, but the 2026-04-25 eval saw operators read that as "patches
|
|
78
|
+
* are restored" and skip the import step. The note is suppressed when
|
|
79
|
+
* patches/ is missing or the manifest is empty so unconfigured projects
|
|
80
|
+
* stay quiet.
|
|
81
|
+
*/
|
|
82
|
+
async function noteUnappliedPatches(patchesDir) {
|
|
83
|
+
if (!(await pathExists(patchesDir)))
|
|
84
|
+
return;
|
|
85
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
86
|
+
if (!manifest || manifest.patches.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
const n = manifest.patches.length;
|
|
89
|
+
info(`Note: ${n} patch${n === 1 ? '' : 'es'} in patches/ have not been applied to this fresh engine. Run "fireforge import" to apply them.`);
|
|
90
|
+
}
|
|
72
91
|
/**
|
|
73
92
|
* Stops `restoreSpinner` with a message that reflects what actually
|
|
74
93
|
* happened. Three branches: empty queue → explicit no-op; queue present but
|
|
@@ -151,6 +170,7 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
151
170
|
downloadedVersion: version,
|
|
152
171
|
baseCommit,
|
|
153
172
|
});
|
|
173
|
+
await noteUnappliedPatches(paths.patches);
|
|
154
174
|
outro(`Firefox ${version} is ready! (resumed from partial init)`);
|
|
155
175
|
return;
|
|
156
176
|
}
|
|
@@ -299,6 +319,7 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
299
319
|
downloadedVersion: version,
|
|
300
320
|
baseCommit,
|
|
301
321
|
});
|
|
322
|
+
await noteUnappliedPatches(paths.patches);
|
|
302
323
|
outro(`Firefox ${version} is ready!`);
|
|
303
324
|
}
|
|
304
325
|
/** Registers the download command on the CLI program. */
|
|
@@ -15,6 +15,7 @@ import { ensureDir, pathExists } from '../utils/fs.js';
|
|
|
15
15
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
16
16
|
import { pickDefined } from '../utils/options.js';
|
|
17
17
|
import { PATCH_CATEGORIES } from '../utils/validation.js';
|
|
18
|
+
import { renderDryRunPreview } from './export-flow.js';
|
|
18
19
|
import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
19
20
|
async function checkBrandingManagedFiles(paths, config) {
|
|
20
21
|
const changedFiles = await getWorkingTreeStatus(paths.engine);
|
|
@@ -158,7 +159,8 @@ async function checkDuplicateNewFileCreations(paths, diff) {
|
|
|
158
159
|
.join('\n');
|
|
159
160
|
throw new GeneralError('Export-all refuses to capture new-file creations that are already claimed by existing patches.\n\n' +
|
|
160
161
|
`Conflicting creations:\n${conflictList}\n\n` +
|
|
161
|
-
'Only one patch may create a given path
|
|
162
|
+
'Only one patch may create a given path — two creation hunks on /dev/null cannot coexist in any apply order, so this case is structurally unrecoverable rather than verify-failing. The --allow-overlap escape hatch covers cross-patch MODIFICATION overlap (which yields a queue that fails verify but still applies); it deliberately does NOT cover this case. ' +
|
|
163
|
+
'Run "fireforge export <path> [...]" with an explicit file list that omits the already-claimed path(s), or resolve the conflict via "fireforge patch delete" / "fireforge re-export --files" before retrying export-all.');
|
|
162
164
|
}
|
|
163
165
|
/**
|
|
164
166
|
* Runs the export-all command to export all changes as a patch.
|
|
@@ -166,7 +168,8 @@ async function checkDuplicateNewFileCreations(paths, diff) {
|
|
|
166
168
|
* @param options - Export options
|
|
167
169
|
*/
|
|
168
170
|
export async function exportAllCommand(projectRoot, options = {}) {
|
|
169
|
-
|
|
171
|
+
const isDryRun = options.dryRun === true;
|
|
172
|
+
intro(isDryRun ? 'FireForge Export All (dry run)' : 'FireForge Export All');
|
|
170
173
|
const paths = getProjectPaths(projectRoot);
|
|
171
174
|
// Check if engine exists
|
|
172
175
|
if (!(await pathExists(paths.engine))) {
|
|
@@ -236,13 +239,38 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
236
239
|
if (!metadata)
|
|
237
240
|
return;
|
|
238
241
|
const { patchName, selectedCategory, description } = metadata;
|
|
239
|
-
// Ensure patches directory exists
|
|
240
|
-
|
|
241
|
-
|
|
242
|
+
// Ensure patches directory exists. Skip during a dry-run so the command
|
|
243
|
+
// is purely read-only — `--dry-run` callers should be safe to invoke
|
|
244
|
+
// against a project that has never exported a patch without leaving the
|
|
245
|
+
// empty `patches/` directory behind.
|
|
246
|
+
if (!isDryRun) {
|
|
247
|
+
await ensureDir(paths.patches);
|
|
248
|
+
}
|
|
249
|
+
const s = spinner(isDryRun ? 'Planning export-all...' : 'Exporting all changes...');
|
|
242
250
|
try {
|
|
243
251
|
// Extract affected files from diff
|
|
244
252
|
const filesAffected = extractAffectedFiles(diff);
|
|
245
253
|
await runPatchLint(paths.engine, filesAffected, diff, config, options.skipLint);
|
|
254
|
+
// Dry-run: enumerate filename, metadata, and supersede coverage without
|
|
255
|
+
// writing. Mirrors `fireforge export --dry-run` so the same preview
|
|
256
|
+
// surface is available for both targeted and aggregate exports. Runs
|
|
257
|
+
// AFTER lint so the operator sees the same lint output they would on
|
|
258
|
+
// a real run; runs BEFORE the supersede confirmation prompt because
|
|
259
|
+
// confirming a dry-run is meaningless.
|
|
260
|
+
if (isDryRun) {
|
|
261
|
+
s.stop('Plan ready');
|
|
262
|
+
await renderDryRunPreview({
|
|
263
|
+
patchesDir: paths.patches,
|
|
264
|
+
category: selectedCategory,
|
|
265
|
+
name: patchName,
|
|
266
|
+
description,
|
|
267
|
+
filesAffected,
|
|
268
|
+
sourceEsrVersion: config.firefox.version,
|
|
269
|
+
explicitSupersede: options.supersede === true,
|
|
270
|
+
});
|
|
271
|
+
outro('Dry run complete — no changes made');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
246
274
|
// Check how many existing patches would be superseded
|
|
247
275
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
248
276
|
if (!shouldProceed)
|
|
@@ -299,7 +327,8 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
|
|
|
299
327
|
.option('--supersede', 'Allow superseding multiple existing patches')
|
|
300
328
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
301
329
|
.option('--exclude-furnace', 'Export the non-Furnace subset of the aggregate diff instead of refusing when Furnace-managed files are modified. Furnace-managed files are still deployed by "fireforge furnace apply"; this flag only changes whether export-all aborts or filters in their presence.')
|
|
302
|
-
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify)')
|
|
330
|
+
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify). Does not bypass the new-file creation guard — two patches creating the same path is structurally unrecoverable, so that case still refuses regardless of this flag.')
|
|
331
|
+
.option('--dry-run', 'Print the export-all plan (filename, metadata, files affected, supersede preview) without writing anything to patches/. Lint still runs so the operator sees the same lint output a real run would produce.')
|
|
303
332
|
.action(withErrorHandling(async (options) => {
|
|
304
333
|
const { category, ...rest } = options;
|
|
305
334
|
await exportAllCommand(getProjectRoot(), {
|
|
@@ -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
|
|
@@ -357,7 +395,15 @@ async function lintPerPatch(projectRoot, paths) {
|
|
|
357
395
|
outro('Lint failed');
|
|
358
396
|
throw new GeneralError(`Patch lint found ${errors.length} error(s) across ${linted} patch(es). Fix these before exporting.`);
|
|
359
397
|
}
|
|
360
|
-
|
|
398
|
+
if (warnings.length > 0) {
|
|
399
|
+
outro('Lint passed with warnings');
|
|
400
|
+
}
|
|
401
|
+
else if (notices.length > 0) {
|
|
402
|
+
outro('Lint passed with notices');
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
outro('Lint passed');
|
|
406
|
+
}
|
|
361
407
|
}
|
|
362
408
|
/** Registers the lint command on the CLI program. */
|
|
363
409
|
export function registerLint(program, { getProjectRoot, withErrorHandling }) {
|
|
@@ -260,6 +260,14 @@ async function assertEngineHasBaselineCommit(engineDir, options) {
|
|
|
260
260
|
warn(guidance);
|
|
261
261
|
outro('Engine baseline missing — re-run download --force');
|
|
262
262
|
}
|
|
263
|
+
if (options.json) {
|
|
264
|
+
// Mirror `--json`'s contract: errors must be machine-parseable too.
|
|
265
|
+
// Without this branch the human guidance above is suppressed but the
|
|
266
|
+
// throw still falls through to the styled error renderer in
|
|
267
|
+
// withErrorHandling, leaving JSON consumers with non-JSON output on
|
|
268
|
+
// exactly the failure mode they care about catching.
|
|
269
|
+
process.stdout.write(JSON.stringify({ error: guidance, code: 'engine-baseline-missing' }) + '\n');
|
|
270
|
+
}
|
|
263
271
|
throw new GeneralError(guidance);
|
|
264
272
|
}
|
|
265
273
|
}
|
|
@@ -278,6 +286,19 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
278
286
|
}
|
|
279
287
|
const paths = getProjectPaths(projectRoot);
|
|
280
288
|
const config = await loadConfig(projectRoot);
|
|
289
|
+
// `--json` mode contracts to machine-parseable output on every code path,
|
|
290
|
+
// including failure modes. Before this guard, errors raised below
|
|
291
|
+
// ("Firefox source not found", "engine is not a git repository") flowed
|
|
292
|
+
// through the normal styled error renderer in `withErrorHandling`, so
|
|
293
|
+
// scripts piping `status --json | jq` broke precisely when the engine was
|
|
294
|
+
// missing. Surface a structured `{ "error": ..., "code": ... }` payload
|
|
295
|
+
// and exit non-zero via GeneralError so the exit code still reflects the
|
|
296
|
+
// failure but stdout remains valid JSON. The same guard runs for
|
|
297
|
+
// ownership mode below because that path also throws on missing engine.
|
|
298
|
+
const emitJsonError = (code, message) => {
|
|
299
|
+
process.stdout.write(JSON.stringify({ error: message, code }) + '\n');
|
|
300
|
+
throw new GeneralError(message);
|
|
301
|
+
};
|
|
281
302
|
// Ownership mode is a flat file→patch table; sources are the manifest's
|
|
282
303
|
// filesAffected, any worktree drift, and the cross-patch
|
|
283
304
|
// duplicate-new-file-creation map produced by walking each patch
|
|
@@ -326,10 +347,16 @@ export async function statusCommand(projectRoot, options = {}) {
|
|
|
326
347
|
}
|
|
327
348
|
// Check if engine exists
|
|
328
349
|
if (!(await pathExists(paths.engine))) {
|
|
350
|
+
if (options.json) {
|
|
351
|
+
emitJsonError('engine-missing', 'Firefox source not found. Run "fireforge download" first.');
|
|
352
|
+
}
|
|
329
353
|
throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
|
|
330
354
|
}
|
|
331
355
|
// Check if it's a git repository
|
|
332
356
|
if (!(await isGitRepository(paths.engine))) {
|
|
357
|
+
if (options.json) {
|
|
358
|
+
emitJsonError('engine-not-git', 'Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
359
|
+
}
|
|
333
360
|
throw new GeneralError('Engine directory is not a git repository. Run "fireforge download" to initialize.');
|
|
334
361
|
}
|
|
335
362
|
await assertEngineHasBaselineCommit(paths.engine, options);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
|
-
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
|
|
5
|
+
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, testWithOutput, } from '../core/mach.js';
|
|
6
6
|
import { assertMarionettePortAvailable } from '../core/marionette-port.js';
|
|
7
7
|
import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
|
|
8
8
|
import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
|
|
@@ -193,6 +193,25 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
193
193
|
// probe have access to `binaryName` (the port probe uses it to
|
|
194
194
|
// recognise a fork-branded browser holding the Marionette port).
|
|
195
195
|
const projectConfig = await loadConfig(projectRoot);
|
|
196
|
+
// `hasBuildArtifacts` only confirms `obj-*/dist/` exists; a partial
|
|
197
|
+
// build (linker failed, packaging step interrupted, etc.) can satisfy
|
|
198
|
+
// that check without ever writing the launchable binary the marionette
|
|
199
|
+
// preflight needs to spawn. `fireforge run` already uses
|
|
200
|
+
// `hasRunnableBundle` to fail fast with a precise message; mirror that
|
|
201
|
+
// here so `test --doctor` against an incomplete build surfaces the
|
|
202
|
+
// missing-bundle path instead of a cryptic `Browser process exited
|
|
203
|
+
// during spawn (exit code 1, signal none). stderr tail: (empty)`.
|
|
204
|
+
if (buildCheck.objDir) {
|
|
205
|
+
const bundleCheck = await hasRunnableBundle(paths.engine, projectConfig.binaryName, buildCheck.objDir);
|
|
206
|
+
if (!bundleCheck.runnable) {
|
|
207
|
+
const expectedSuffix = bundleCheck.expectedPath
|
|
208
|
+
? ` (expected at engine/${bundleCheck.expectedPath})`
|
|
209
|
+
: '';
|
|
210
|
+
throw new GeneralError(`Tests require a complete launchable build${expectedSuffix}. ` +
|
|
211
|
+
'The obj-*/dist/ tree exists but the launchable binary is missing — typically the result of an interrupted or partially failed `fireforge build`.\n\n' +
|
|
212
|
+
'Run "fireforge build" again and let it finish before retrying "fireforge test".');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
196
215
|
// Run incremental build if requested
|
|
197
216
|
if (options.build) {
|
|
198
217
|
await prepareBuildEnvironment(projectRoot, paths, projectConfig);
|
|
@@ -120,7 +120,7 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
|
|
|
120
120
|
});
|
|
121
121
|
token
|
|
122
122
|
.command('add <token-name> <value>')
|
|
123
|
-
.description('Add a design token to CSS and documentation')
|
|
123
|
+
.description('Add a design token to CSS and documentation. The token name is a positional argument, but most tokens start with `--` (CSS custom property syntax), which Commander reads as an option flag. Use the standard `--` separator to mark the end of options before the token name, e.g. `fireforge token add --mode static --category Colors -- --my-token "#fff"`. Bare names without `--` are accepted directly and prefixed using the configured Furnace `tokenPrefix`.')
|
|
124
124
|
.requiredOption('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing")')
|
|
125
125
|
.addOption(
|
|
126
126
|
// Use Commander's .choices() so invalid --mode values are rejected with
|
|
@@ -541,6 +541,20 @@ export async function updateFurnaceState(root, updates) {
|
|
|
541
541
|
await writeJson(paths.furnaceState, validateFurnaceState(nextState));
|
|
542
542
|
});
|
|
543
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* Engine-relative path of the directory `furnace preview` writes its
|
|
546
|
+
* generated Storybook story files into. Treated as Furnace-managed so
|
|
547
|
+
* `status` does not flag them as unmanaged and `lint` does not fail on
|
|
548
|
+
* their (intentionally bare) license headers.
|
|
549
|
+
*
|
|
550
|
+
* 2026-04-25 eval Finding 19: a successful `furnace preview` run synced
|
|
551
|
+
* 23 stories under this prefix; afterwards `status` showed all 23 as
|
|
552
|
+
* untracked unmanaged changes and aggregate `lint` failed with 23
|
|
553
|
+
* `missing-license-header` errors. The files are tool output — operators
|
|
554
|
+
* are not expected to commit or hand-edit them — so the right shape is
|
|
555
|
+
* to bucket them with the rest of Furnace's managed material.
|
|
556
|
+
*/
|
|
557
|
+
const FURNACE_STORYBOOK_STORIES_PREFIX = 'browser/components/storybook/stories/furnace/';
|
|
544
558
|
/**
|
|
545
559
|
* Collects engine-relative path prefixes that are managed by the Furnace
|
|
546
560
|
* component system (overrides, custom components, and their Fluent l10n
|
|
@@ -571,6 +585,11 @@ export async function collectFurnaceManagedPrefixes(root) {
|
|
|
571
585
|
prefixes.add(ftlDir.endsWith('/') ? ftlDir : ftlDir + '/');
|
|
572
586
|
}
|
|
573
587
|
}
|
|
588
|
+
// Always include the preview-generated stories prefix when furnace is
|
|
589
|
+
// initialised. The directory may not exist yet (no preview ever ran),
|
|
590
|
+
// but classifying it as furnace-managed is safe even when empty —
|
|
591
|
+
// status simply has nothing to bucket.
|
|
592
|
+
prefixes.add(FURNACE_STORYBOOK_STORIES_PREFIX);
|
|
574
593
|
return prefixes;
|
|
575
594
|
}
|
|
576
595
|
//# sourceMappingURL=furnace-config.js.map
|
|
@@ -21,6 +21,14 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
|
|
|
21
21
|
* Returns true if `content` starts with any known license header for the
|
|
22
22
|
* given comment style.
|
|
23
23
|
*
|
|
24
|
+
* For `js` files, MPL-2.0 is also accepted in the upstream Mozilla block-
|
|
25
|
+
* comment form (`/* ... *\/`) used by the Firefox source tree, not just the
|
|
26
|
+
* `// ` line-comment form `getLicenseHeader` emits. Without that, a new JS
|
|
27
|
+
* file copied from upstream Firefox (or written to match the surrounding
|
|
28
|
+
* code's convention) hit `missing-license-header` even with a verbatim
|
|
29
|
+
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
30
|
+
* false positive.
|
|
31
|
+
*
|
|
24
32
|
* @param content - File content to check
|
|
25
33
|
* @param style - Comment syntax of the file
|
|
26
34
|
*/
|
|
@@ -57,12 +57,26 @@ export function getLicenseHeader(license, style) {
|
|
|
57
57
|
* Returns true if `content` starts with any known license header for the
|
|
58
58
|
* given comment style.
|
|
59
59
|
*
|
|
60
|
+
* For `js` files, MPL-2.0 is also accepted in the upstream Mozilla block-
|
|
61
|
+
* comment form (`/* ... *\/`) used by the Firefox source tree, not just the
|
|
62
|
+
* `// ` line-comment form `getLicenseHeader` emits. Without that, a new JS
|
|
63
|
+
* file copied from upstream Firefox (or written to match the surrounding
|
|
64
|
+
* code's convention) hit `missing-license-header` even with a verbatim
|
|
65
|
+
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
66
|
+
* false positive.
|
|
67
|
+
*
|
|
60
68
|
* @param content - File content to check
|
|
61
69
|
* @param style - Comment syntax of the file
|
|
62
70
|
*/
|
|
63
71
|
export function hasAnyLicenseHeader(content, style) {
|
|
64
72
|
const licenses = Object.keys(HEADER_LINES);
|
|
65
|
-
|
|
73
|
+
if (licenses.some((license) => content.startsWith(getLicenseHeader(license, style)))) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (style === 'js' && content.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
66
80
|
}
|
|
67
81
|
/**
|
|
68
82
|
* Returns true if `content` starts with any known license header in any
|
|
@@ -65,7 +65,15 @@ async function isSharedCSSRegistered(engineDir, fileName) {
|
|
|
65
65
|
}
|
|
66
66
|
const name = basename(fileName, '.css');
|
|
67
67
|
const content = await readText(manifestPath);
|
|
68
|
-
|
|
68
|
+
// `register` writes the canonical `skin/classic/browser/<name>.css` form;
|
|
69
|
+
// `furnace chrome-doc create` writes a `content/browser/<name>.css` entry
|
|
70
|
+
// because the CSS is loaded by a chrome document via a `chrome://browser/
|
|
71
|
+
// content/<name>.css` URI. Match either prefix so paths registered by the
|
|
72
|
+
// chrome-doc scaffolder are not flagged as "potentially unregistered" by
|
|
73
|
+
// `status` and so a re-run of `register` against the same file recognises
|
|
74
|
+
// the existing entry instead of proposing a duplicate.
|
|
75
|
+
return (content.includes(`skin/classic/browser/${name}.css`) ||
|
|
76
|
+
content.includes(`content/browser/${name}.css`));
|
|
69
77
|
}
|
|
70
78
|
async function isBrowserContentRegistered(engineDir, fileName) {
|
|
71
79
|
const manifestPath = join(engineDir, 'browser/base/jar.mn');
|
|
@@ -359,6 +359,14 @@ export async function lintNewFileHeaders(repoDir, newFiles, config) {
|
|
|
359
359
|
continue;
|
|
360
360
|
if (file.startsWith('browser/branding/') && hasAnyLicenseHeader(content, style))
|
|
361
361
|
continue;
|
|
362
|
+
// Accept the MPL-2.0 block-comment form (`/* ... */` with leading `*`)
|
|
363
|
+
// for any JS file — that is the canonical upstream Firefox header
|
|
364
|
+
// shape, and `hasAnyLicenseHeader` above also recognises it. The
|
|
365
|
+
// explicit guard mirrors the branding carve-out so operators using the
|
|
366
|
+
// standard Mozilla header on a JS file (e.g. one copied from upstream
|
|
367
|
+
// browser/base/content) do not need `--skip-lint` to land it.
|
|
368
|
+
if (license === 'MPL-2.0' && hasAnyLicenseHeader(content, style))
|
|
369
|
+
continue;
|
|
362
370
|
issues.push({
|
|
363
371
|
file,
|
|
364
372
|
check: 'missing-license-header',
|
|
@@ -98,8 +98,14 @@ export async function registerSharedCSS(engineDir, fileName, after, dryRun = fal
|
|
|
98
98
|
const name = basename(fileName, '.css');
|
|
99
99
|
const entry = ` skin/classic/browser/${name}.css (../shared/${name}.css)`.replace(/\\/g, '/');
|
|
100
100
|
const content = await readText(manifestPath);
|
|
101
|
-
// Idempotency check
|
|
102
|
-
|
|
101
|
+
// Idempotency check. `furnace chrome-doc create` writes its CSS as a
|
|
102
|
+
// `content/browser/<name>.css` entry rather than the canonical
|
|
103
|
+
// `skin/classic/browser/<name>.css` form `register` produces; recognise
|
|
104
|
+
// both shapes so a follow-up `register` invocation against an
|
|
105
|
+
// already-chrome-doc-registered file reports `skipped` instead of
|
|
106
|
+
// appending a duplicate `skin/classic/browser/...` row.
|
|
107
|
+
if (content.includes(`skin/classic/browser/${name}.css`) ||
|
|
108
|
+
content.includes(`content/browser/${name}.css`)) {
|
|
103
109
|
return { manifest, entry, skipped: true };
|
|
104
110
|
}
|
|
105
111
|
const { value } = withParserFallback(() => registerSharedCSSTokenized(content, name, entry, after), () => legacyRegisterSharedCSS(content, name, entry, after), manifest);
|