@hominis/fireforge 0.16.3 → 0.16.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/CHANGELOG.md +20 -1
- package/README.md +6 -0
- package/dist/src/commands/download.js +44 -13
- package/dist/src/commands/export-all.js +19 -2
- package/dist/src/commands/export-shared.d.ts +36 -0
- package/dist/src/commands/export-shared.js +76 -0
- package/dist/src/commands/export.js +23 -2
- package/dist/src/commands/furnace/create-readback.d.ts +23 -0
- package/dist/src/commands/furnace/create-readback.js +34 -0
- package/dist/src/commands/furnace/create.js +2 -0
- package/dist/src/commands/furnace/preview.d.ts +12 -0
- package/dist/src/commands/furnace/preview.js +34 -2
- package/dist/src/commands/furnace/status.js +1 -1
- package/dist/src/commands/patch/index.js +10 -1
- package/dist/src/commands/re-export.js +79 -6
- package/dist/src/commands/resolve.js +15 -1
- package/dist/src/commands/run.js +27 -5
- package/dist/src/commands/test.js +8 -1
- package/dist/src/commands/token-coverage.js +55 -1
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/wire.js +22 -2
- package/dist/src/core/mach-error-hints.js +16 -0
- package/dist/src/core/mach.js +15 -6
- package/dist/src/core/wire-destroy.js +18 -5
- package/dist/src/core/wire-init.js +20 -5
- package/dist/src/core/wire-utils.d.ts +15 -0
- package/dist/src/core/wire-utils.js +17 -0
- package/dist/src/types/commands/options.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## 0.16.0
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### UX, correctness, and consistency
|
|
6
|
+
|
|
7
|
+
- **`fireforge patch` / `fireforge token` — exit 0 with help (parent-command contract).** `fireforge furnace` exited 0 and printed its status message when run with no subcommand; `fireforge patch` and `fireforge token` silently inherited commander's default help-then-exit-1 path. Scripts probing the CLI surface therefore saw an inconsistent exit contract for three parent commands that do the same job (group related subcommands). Both `patch` and `token` now install a default `.action()` that prints their own help via `outputHelp()` and returns successfully — exit 0, same output shape, no destructive or stateful side effect. A new drift test in `src/commands/__tests__/manifest.test.ts` asserts every group-style parent has a default action installed so a future parent cannot regress back to the exit-1 contract silently.
|
|
8
|
+
- **`fireforge furnace status` — tips now prefix with `fireforge`.** The trailing `info()` line at the end of `furnace status` said `run \`furnace status <name>\``/`furnace --help`, which only worked if the operator happened to have a separate `furnace` binary on PATH. Copy-pasting the suggestion out of FireForge's own output produced a shell error on every fresh project. The message now names the real invocation (`fireforge furnace status <name>`, `fireforge furnace --help`), so the suggested commands are directly runnable.
|
|
9
|
+
- **`fireforge download` — de-duplicated git-init progress in non-TTY logs.** The resume and init `onProgress` callbacks called both `spinner.message(msg)` and `step(msg)` in non-TTY mode, producing two copies of every git-init progress line in CI logs because `src/utils/logger.ts`'s non-TTY spinner fallback already calls `p.log.step(msg)` from `message()`. The explicit `step()` sibling call is removed on both paths, so each progress message appears exactly once regardless of TTY mode. The tests in `download.test.ts` / `download.integration.test.ts` now assert the spinner-handle contract directly instead of the removed `step()` signal.
|
|
10
|
+
- **`fireforge download` — honest closing message on an empty patch queue.** `download` always stopped the restore spinner with `Patch-touched files restored`, even when the project had never exported a patch — a misleading claim of work on a fresh workspace. `cleanPatchTouchedFiles` now returns a structured `{ hadQueue, restored, preserved }` result, and a new `closeRestoreSpinner` helper picks one of three stop messages: `No patches in queue — nothing to restore` (empty queue), `Patch-touched files already match baseline` (queue present, nothing dirty), or the original `Patch-touched files restored` (work actually happened).
|
|
11
|
+
- **`fireforge build` — gecko-profiler bindgen hint now surfaces on Darwin 25.** The `_CharT` / `basic_string___self_view` hint had been registered in `mach-error-hints.ts` since 0.16.0 but never fired against the eval's Darwin 25 build log. Root cause: `build()` and `buildUI()` in `src/core/mach.ts` fed only `result.stderr` to `surfaceMachErrorHints`, but mach's timestamp-prefixing wrapper streamed the `rustc error[E0425]` lines through stdout. The hint surfacer now scans `${result.stderr}\n${result.stdout}` so the existing `_CharT` pattern matches regardless of which stream mach chose. A new regression test in `mach.test.ts` pins the stdout-only failure mode so a future refactor cannot accidentally narrow the capture again.
|
|
12
|
+
- **`fireforge build` — new hint clarifying the post-failure `Configure complete!` epilogue.** When `mach build` fails, mach's own shutdown pipeline runs a `Config object not found by mach. / Configure complete! / Be sure to run |mach build|...` block on the way out. That block is plain upstream mach output, printed after the non-zero exit code has already been established, but it looks deceptively like a success banner. A new `MACH_ERROR_HINTS` entry matches the exact `Config object not found by mach.\s*Configure complete!` signature and surfaces `Ignore the trailing "Config object not found by mach. / Configure complete!" block — that is mach's post-failure configure summary printed after the build already failed, not a sign the build succeeded.` The pattern is narrow enough that a real post-`mach configure` success (which legitimately prints "Configure complete!" alone) does not trigger it.
|
|
13
|
+
- **`fireforge wire --dry-run` — init/destroy validation now matches the real run.** Pre-0.16 the `validateWireName(expression, …)` check only ran inside `addInitToBrowserInit` / `addDestroyToBrowserInit` (the real-execution path), so `fireforge wire eval-startup --init 'void 0' --dry-run` succeeded and rendered a plausible preview — then the same arguments without `--dry-run` failed with `Invalid init expression "void 0": must contain only letters, digits, hyphens, underscores, dots, and $ signs`. Validation is now hoisted into `wireCommand` before the dry-run/real branch, so both paths enforce the identical regex. The library-level call inside addInit/addDestroy remains as defence-in-depth for programmatic callers that bypass the CLI entry point.
|
|
14
|
+
- **`fireforge wire` — coerces bare property chains into function calls.** The init/destroy validator accepted both `Foo.bar` and `Foo.bar()` shapes, but the emitted code template interpolated the expression verbatim — so `fireforge wire eval-startup --init EvalStartup.init` wrote `EvalStartup.init;` (a plain property reference) into `browser-init.js`, silently producing a lifecycle hookup that never invoked the hook. A new `coerceToCall(expression)` helper in `src/core/wire-utils.ts` appends `()` when the expression lacks trailing parens, idempotent when they are already present. Both the AST (`addInitAST`/`addDestroyAST`) and legacy fallback (`legacyAddInit`/`legacyAddDestroy`) code paths route the expression through the coercer so they agree on the emitted block shape. The idempotency regex in `addInitToBrowserInit`/`addDestroyToBrowserInit` also matches against the coerced form, so re-running `wire` with the bare form is correctly a no-op against a file that already contains the coerced call. The dry-run preview (`printWireDryRun`) applies the same coercion so the preview and the real run match.
|
|
15
|
+
- **`fireforge furnace create` — defensive read-back after writing `furnace.json`.** The eval observed a run where `furnace create eval-card --with-tests --localized --allow-prefix-mismatch` reported success and wrote component files under `components/custom/eval-card/`, but `furnace.json` ended up with `"custom": {}` and every subsequent command (`status`, `apply`, `rename`, `remove`) on `eval-card` failed with "not found in furnace.json". Local reproduction with the same source and same arguments writes the entry correctly and the unit-test coverage is green, so the bug could not be pinpointed from source alone. The defensive fix is a read-back verification in `performCreateMutations`: after `writeFurnaceConfig(…, config)` we call `loadFurnaceConfig(projectRoot)` and assert `componentName in persisted.custom`. If the entry is missing, the command throws a `FurnaceError` pointing to the prefix rule and `--allow-prefix-mismatch`, the rollback journal restores the pre-command state, and the operator sees the failure instead of a phantom success. The mock in `furnace-create.test.ts` was updated to reflect `writeFurnaceConfig` into subsequent `loadFurnaceConfig` reads so the unit-test path exercises the same round-trip.
|
|
16
|
+
- **`fireforge token coverage` — scans deployed Furnace custom-component CSS.** Coverage discovery was git-status-based: it read `getStatusWithCodes` and filtered to `.css` files. A `moz-eval-card.css` deployed into `engine/toolkit/content/widgets/moz-eval-card/` by `furnace deploy` never appeared in the scan if it was already tracked in the engine's own git repository (i.e. not dirty in status). The command now loads `furnace.json` (when present) and walks every entry in `config.custom`, probing `${targetPath}/${componentName}.css` under the engine directory. Files that exist on disk are merged with the git-status set and de-duplicated; the tokens CSS itself is still excluded. Projects without `furnace.json` keep the old git-status-only path unchanged. A new test case in `token-coverage.test.ts` pins the augmented discovery against the eval's exact scenario.
|
|
17
|
+
- **`fireforge furnace preview` — backend-artifact failure gets its own diagnosis.** When `mach storybook` failed deep inside `config.status` / `chrome-map.json` because the Firefox build backend was incomplete, the pre-0.16 heuristic's second clause matched the literal string `backend` — which does not appear in the error output — so the generic message sent the operator back to `fireforge furnace preview --install` (the wrong recovery path) after the install had already succeeded. `buildStorybookFailureMessage` is now exported and covered by a dedicated test file (`preview-failure-message.test.ts`). It first checks a narrow set of backend-artifact patterns (`chrome-map.json`, `config.status`, `obj-*/dist/bin/.lldbinit`) against a file-not-found signal; on match it produces a distinct message telling the operator to rerun `fireforge build` and wait for it to finish. The generic "missing Storybook dependencies" and fallthrough branches are preserved for the cases they actually describe.
|
|
18
|
+
- **`fireforge export` / `fireforge export-all` — new `--allow-overlap` gate against silent cross-patch ownership.** Pre-0.16 `export` only caught FULL-coverage supersedes via `findAllPatchesForFiles`. A second export targeting a shared file like `browser/themes/shared/jar.inc.mn` happily created a queue where two patches both listed the same file in `filesAffected`, and `fireforge verify` then immediately failed with "cross-patch filesAffected conflicts". A new `findPartialOwnershipOverlap` helper in `export-shared.ts` walks the manifest and maps each overlapping file to its claiming patches, excluding any patches that the caller already intends to fully supersede. The new `guardOwnershipOverlap` routes the result through a non-interactive refusal or an interactive prompt: without `--allow-overlap`, the command refuses in non-interactive mode and asks for acknowledgement in interactive mode. The refusal message points at `fireforge re-export --files <paths> <patch>` as the right primitive for repartitioning ownership, which replaces the (much more dangerous) manual `patches.json` editing the eval's operator resorted to. Both `export` and `export-all` now expose a matching `--allow-overlap` flag.
|
|
19
|
+
- **`fireforge re-export --scan` — broad expansions require explicit acknowledgement.** Pre-0.16 `--scan` blindly merged every modified or untracked file in a patch's parent directories into its `filesAffected`. The eval scenario: two small patches in adjacent-but-unrelated features shared a directory tree, and a single `re-export --all --scan` silently absorbed the entire neighbour feature (xhtml + xpcshell tests + theme CSS) into the wrong patch. The 0.16.0 gate: when `--scan` would add more than three files, or files spanning more than one directory, the command calls `confirmBroadScanAdditions` — dry-run proceeds silently (previewing is the whole point), `--yes` proceeds silently (the explicit opt-in), interactive mode prompts for confirmation, and non-interactive mode without `--yes` refuses with the expansion summary. Small same-directory additions (the common refresh case) stay frictionless.
|
|
20
|
+
- **`fireforge test --doctor` — closes the intro frame with an explicit outro.** Running `test --doctor` on its own showed `● Running marionette preflight...` and then exited silently with code 0 in the eval's non-TTY capture — the `Marionette preflight: PASS (…)` line that `reportMarionettePreflight` emitted via `info()` failed to render inside the unclosed clack intro frame. The doctor-only success branch now calls `outro(\`Marionette preflight: PASS (${durationMs}ms)\`)`before returning, which closes the tree and gives scripts a deterministic "done" marker to parse. The failing branch already throws through`GeneralError`, which is routed via the standard error pipeline and does not need the same treatment.
|
|
21
|
+
- **`fireforge run --smoke-exit` — allowlist summary shows both error-class and total counts.** The previous summary only reported `Allowlisted hits: N`, where `N` was incremented only when a line matched both `SMOKE_ERROR_PATTERNS` AND the caller-supplied allowlist. An operator whose `--console-allow RSLoader:` pattern visibly matched `console.warn: RSLoader: …` lines still saw `Allowlisted hits: 0`, because `console.warn:` is not a smoke-error class — the allowlist was never consulted for those lines. The summary now distinguishes two counters: `Allowlisted error hits (suppressed): N` (the exit-contract number: errors the allowlist kept out of the findings list) and `Allowlisted lines total: M` (the mental-model number: every console line that matched the allowlist, regardless of error class). The exit contract itself is unchanged — unallowed errors still fail the smoke window.
|
|
22
|
+
- **`fireforge resolve` — `filesAffected` is always recomputed from the new diff body.** Pre-0.16 the resolve command updated `patches.json.filesAffected` only when `activeFiles.length < existingFiles.length` (files deleted from disk). If the user's manual fix eliminated every hunk for a specific file while the file itself still existed on disk, the metadata kept claiming it — and the next `fireforge import` immediately failed the patch-manifest consistency check with "patches.json declares [...] but the patch file targets [...]". The command now threads the generated `diffContent` through `extractAffectedFiles` (the same helper `export` and the consistency checker use) and unconditionally passes the result as `filesAffected`. Resolve and consistency-check therefore always agree on the set of targeted files. A new round-trip test in `resolve.test.ts` pins the scenario where the diff shrinks but every file still exists on disk.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
6
25
|
|
|
7
26
|
- **Release workflow — shell injection via `${{ inputs.version }}` interpolation.** `.github/workflows/release.yml` previously interpolated `${{ inputs.version }}` directly into `npm version "${{ inputs.version }}" --no-git-tag-version`, so anyone with `actions:write` could trigger `workflow_dispatch` with a crafted version string (e.g. `1.0.0"; <command> #`) and execute arbitrary shell inside the job. The release job carries `contents: write`, `id-token: write`, and the `npm` trusted-publishing environment, so that shell would have had an open door to the publish credentials. The fix routes `${{ inputs.version }}` through an `env: INPUT_VERSION:` block on the `Bump version` step and references `"$INPUT_VERSION"` inside the `run:` script, so GitHub Actions substitutes the value into the process environment rather than into the shell source. The `Tag and push` step gets the same treatment for `${{ steps.version.outputs.version }}` — defense-in-depth, since `npm version`'s semver check already filters that interpolation, but the pattern is consistent and cheap.
|
|
8
27
|
- **`fireforge config` — prototype pollution via sentinel key segments.** `mutateConfig` in `src/core/config-mutate.ts` walks a dot-separated key through `getOrCreateChildRecord(parent, segment)` with no filter on `__proto__`, `constructor`, or `prototype`. `fireforge config __proto__.polluted 1 --force` therefore reached `parent["__proto__"]` and wrote a plain property onto `Object.prototype`, polluting every object in the Node process for the rest of the run. `--force` was the motivating pathway (the strict path guard rejects unknown top-level keys for non-sentinels), but the raw sink was also publicly re-exported from `src/core/config.ts`, widening the blast radius to any future caller. The fix rejects sentinel segments up-front in `mutateConfig` with a `ConfigError` before any clone or mutation — a single guard at the entry point covers both the descent loop and the final leaf assignment, and surfaces to the CLI as a normal "invalid key" failure rather than a crash. `readJson`'s existing reviver already strips the sentinels from loads, so input configs can't arrive pre-polluted.
|
package/README.md
CHANGED
|
@@ -154,6 +154,10 @@ fireforge re-export --files browser/base/content/browser.js 002-ui-toolbar
|
|
|
154
154
|
fireforge re-export --all --scan --stamp
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
`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.
|
|
158
|
+
|
|
159
|
+
`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.
|
|
160
|
+
|
|
157
161
|
### Rebasing on top of a new Firefox version
|
|
158
162
|
|
|
159
163
|
1. Update `firefox.version` in `fireforge.json`
|
|
@@ -592,6 +596,8 @@ Exit codes are wired distinct from `BUILD_ERROR`:
|
|
|
592
596
|
|
|
593
597
|
POSIX only — process-group semantics do not map cleanly onto Windows. A smoke window shorter than 30 s warns up-front because cold-start time alone can consume that budget on a debug build; `--capture-console <file>` mirrors the captured stream so post-exit inspection has the raw log without re-running.
|
|
594
598
|
|
|
599
|
+
The summary block reports two allowlist counters so operators can tell whether a pattern actually matched anything: `Allowlisted error hits (suppressed)` is the exit-contract number (errors that would have failed the window but were dropped by the allowlist), and `Allowlisted lines total` is the mental-model number (every console line that matched the allowlist, regardless of whether it was an error-class line). A non-zero `total` with a zero `suppressed` count means the allowlist patterns matched benign info/warn lines that never counted toward the exit contract to begin with.
|
|
600
|
+
|
|
595
601
|
### Furnace `--shared-ftl` for feature-scoped Fluent bundles
|
|
596
602
|
|
|
597
603
|
A feature with multiple components (e.g. an eight-component dock) typically wants one shared `.ftl` per feature rather than eight per-component stubs. `furnace create <tag> --localized --shared-ftl <chrome-uri>` participates in an existing feature-scoped bundle:
|
|
@@ -10,7 +10,7 @@ import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
|
10
10
|
import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
12
12
|
import { checkDiskSpace, ensureDir, pathExists, removeDir } from '../utils/fs.js';
|
|
13
|
-
import { info, intro, outro, spinner,
|
|
13
|
+
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
14
14
|
import { pickDefined } from '../utils/options.js';
|
|
15
15
|
/**
|
|
16
16
|
* Collects the set of patch-touched files from the manifest.
|
|
@@ -39,11 +39,13 @@ async function getPatchTouchedFiles(patchesDir) {
|
|
|
39
39
|
*/
|
|
40
40
|
async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
41
41
|
const patchFiles = await getPatchTouchedFiles(patchesDir);
|
|
42
|
-
if (patchFiles.size === 0)
|
|
43
|
-
return;
|
|
42
|
+
if (patchFiles.size === 0) {
|
|
43
|
+
return { hadQueue: false, restored: 0, preserved: 0 };
|
|
44
|
+
}
|
|
44
45
|
const dirtyFiles = await getDirtyFiles(engineDir, [...patchFiles]);
|
|
45
|
-
if (dirtyFiles.length === 0)
|
|
46
|
-
return;
|
|
46
|
+
if (dirtyFiles.length === 0) {
|
|
47
|
+
return { hadQueue: true, restored: 0, preserved: 0 };
|
|
48
|
+
}
|
|
47
49
|
const toClean = preExistingDirty
|
|
48
50
|
? dirtyFiles.filter((f) => !preExistingDirty.has(f))
|
|
49
51
|
: dirtyFiles;
|
|
@@ -65,6 +67,29 @@ async function cleanPatchTouchedFiles(engineDir, patchesDir, preExistingDirty) {
|
|
|
65
67
|
warn(` ${file}`);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
return { hadQueue: true, restored: toClean.length, preserved: preserved.length };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Stops `restoreSpinner` with a message that reflects what actually
|
|
74
|
+
* happened. Three branches: empty queue → explicit no-op; queue present but
|
|
75
|
+
* nothing dirty → "already clean"; queue with dirty files → the usual
|
|
76
|
+
* "Patch-touched files restored" success line.
|
|
77
|
+
*
|
|
78
|
+
* Before 0.16.0 the spinner always closed with "Patch-touched files
|
|
79
|
+
* restored", so a fresh project with zero patches saw a claim of restore
|
|
80
|
+
* work that had not happened — misleading and easy to mistake for a
|
|
81
|
+
* silent retry.
|
|
82
|
+
*/
|
|
83
|
+
function closeRestoreSpinner(restoreSpinner, result) {
|
|
84
|
+
if (!result.hadQueue) {
|
|
85
|
+
restoreSpinner.stop('No patches in queue — nothing to restore');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (result.restored === 0 && result.preserved === 0) {
|
|
89
|
+
restoreSpinner.stop('Patch-touched files already match baseline');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
restoreSpinner.stop('Patch-touched files restored');
|
|
68
93
|
}
|
|
69
94
|
/**
|
|
70
95
|
* Runs the download command.
|
|
@@ -100,11 +125,16 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
100
125
|
const resumeSpinner = spinner('Resuming git repository initialization...');
|
|
101
126
|
try {
|
|
102
127
|
await resumeRepository(paths.engine, {
|
|
128
|
+
// The non-TTY spinner fallback in `src/utils/logger.ts`
|
|
129
|
+
// already calls `p.log.step(msg)` from `message()`, so
|
|
130
|
+
// forwarding the progress message is the single authority
|
|
131
|
+
// in both TTY and non-TTY modes. Before 0.16.0 this
|
|
132
|
+
// callback also invoked `step(message)` explicitly when
|
|
133
|
+
// stdio was not a TTY, which printed the same step line
|
|
134
|
+
// twice in CI logs (once from the fallback, once from
|
|
135
|
+
// the explicit call).
|
|
103
136
|
onProgress: (message) => {
|
|
104
137
|
resumeSpinner.message(message);
|
|
105
|
-
if (!(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
106
|
-
step(message);
|
|
107
|
-
}
|
|
108
138
|
},
|
|
109
139
|
});
|
|
110
140
|
const baseCommit = await getHead(paths.engine);
|
|
@@ -223,11 +253,12 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
223
253
|
let baseCommit;
|
|
224
254
|
try {
|
|
225
255
|
await initRepository(paths.engine, 'firefox', {
|
|
256
|
+
// Same one-authority rule as the resume path above: the non-TTY
|
|
257
|
+
// spinner fallback already emits `step(msg)` internally, so
|
|
258
|
+
// calling `step()` in addition to `.message()` duplicated every
|
|
259
|
+
// git-init progress line in CI logs.
|
|
226
260
|
onProgress: (message) => {
|
|
227
261
|
gitSpinner.message(message);
|
|
228
|
-
if (!(process.stdout.isTTY && process.stderr.isTTY)) {
|
|
229
|
-
step(message);
|
|
230
|
-
}
|
|
231
262
|
},
|
|
232
263
|
});
|
|
233
264
|
baseCommit = await getHead(paths.engine);
|
|
@@ -257,8 +288,8 @@ export async function downloadCommand(projectRoot, options) {
|
|
|
257
288
|
// reporting success against a dirty engine.
|
|
258
289
|
const restoreSpinner = spinner('Restoring patch-touched files to baseline...');
|
|
259
290
|
try {
|
|
260
|
-
await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
261
|
-
restoreSpinner
|
|
291
|
+
const restoreResult = await cleanPatchTouchedFiles(paths.engine, paths.patches);
|
|
292
|
+
closeRestoreSpinner(restoreSpinner, restoreResult);
|
|
262
293
|
}
|
|
263
294
|
catch (error) {
|
|
264
295
|
restoreSpinner.error('Failed to restore patch-touched files');
|
|
@@ -7,14 +7,14 @@ import { hasChanges, isGitRepository } from '../core/git.js';
|
|
|
7
7
|
import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
|
|
8
8
|
import { getWorkingTreeStatus } from '../core/git-status.js';
|
|
9
9
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
10
|
-
import { commitExportedPatch } from '../core/patch-export.js';
|
|
10
|
+
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
11
11
|
import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
|
|
12
12
|
import { GeneralError } from '../errors/base.js';
|
|
13
13
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
14
14
|
import { info, intro, outro, spinner } from '../utils/logger.js';
|
|
15
15
|
import { pickDefined } from '../utils/options.js';
|
|
16
16
|
import { PATCH_CATEGORIES } from '../utils/validation.js';
|
|
17
|
-
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
17
|
+
import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
18
18
|
async function checkBrandingManagedFiles(paths, config) {
|
|
19
19
|
const changedFiles = await getWorkingTreeStatus(paths.engine);
|
|
20
20
|
const brandingManagedFiles = changedFiles
|
|
@@ -177,6 +177,22 @@ export async function exportAllCommand(projectRoot, options = {}) {
|
|
|
177
177
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
178
178
|
if (!shouldProceed)
|
|
179
179
|
return;
|
|
180
|
+
// Overlap gate — see the matching comment in `export.ts`. The same
|
|
181
|
+
// cross-patch ownership problem applies to `export-all` because a
|
|
182
|
+
// mixed aggregate diff often touches shared files like manifest
|
|
183
|
+
// fragments that other patches already claim.
|
|
184
|
+
const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
|
|
185
|
+
const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
|
|
186
|
+
const shouldProceedPastOverlap = await guardOwnershipOverlap({
|
|
187
|
+
patchesDir: paths.patches,
|
|
188
|
+
filesAffected,
|
|
189
|
+
supersedingFilenames,
|
|
190
|
+
allowOverlap: options.allowOverlap === true,
|
|
191
|
+
isInteractive,
|
|
192
|
+
s,
|
|
193
|
+
});
|
|
194
|
+
if (!shouldProceedPastOverlap)
|
|
195
|
+
return;
|
|
180
196
|
// Get Firefox version for metadata
|
|
181
197
|
const { patchFilename, superseded } = await commitExportedPatch({
|
|
182
198
|
patchesDir: paths.patches,
|
|
@@ -213,6 +229,7 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
|
|
|
213
229
|
.option('--supersede', 'Allow superseding multiple existing patches')
|
|
214
230
|
.option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
|
|
215
231
|
.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.')
|
|
232
|
+
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify)')
|
|
216
233
|
.action(withErrorHandling(async (options) => {
|
|
217
234
|
const { category, ...rest } = options;
|
|
218
235
|
await exportAllCommand(getProjectRoot(), {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PatchesManifest } from '../types/commands/index.js';
|
|
1
2
|
import type { ExportOptions, PatchCategory } from '../types/commands/index.js';
|
|
2
3
|
import type { FireForgeConfig } from '../types/config.js';
|
|
3
4
|
import type { SpinnerHandle } from '../utils/logger.js';
|
|
@@ -52,3 +53,38 @@ export declare function confirmSupersedePatches(patchesDir: string, filesAffecte
|
|
|
52
53
|
* @returns true if files were modified on disk (caller must regenerate diff)
|
|
53
54
|
*/
|
|
54
55
|
export declare function autoFixLicenseHeaders(engineDir: string, diffContent: string, config: FireForgeConfig, isInteractive: boolean): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Maps every file in `filesAffected` to the existing patches that already
|
|
58
|
+
* claim ownership of it, excluding the caller's own patch (when `newFilename`
|
|
59
|
+
* is provided) and any patches that the caller intends to fully supersede.
|
|
60
|
+
*
|
|
61
|
+
* Returns an empty map when no overlap exists. Used by the overlap gate in
|
|
62
|
+
* `export` and `export-all` to refuse a default-mode export that would
|
|
63
|
+
* silently create cross-patch ownership conflicts — the same class of
|
|
64
|
+
* conflict `verify` immediately fails with.
|
|
65
|
+
*/
|
|
66
|
+
export declare function findPartialOwnershipOverlap(manifest: PatchesManifest, filesAffected: string[], excludeFilenames: ReadonlySet<string>): Map<string, string[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Gate that refuses the default export path when the new patch would
|
|
69
|
+
* silently claim files that are already tracked by other non-superseded
|
|
70
|
+
* patches. `findAllPatchesForFiles` already catches the full-coverage
|
|
71
|
+
* supersede case — this helper fills the gap for partial overlap, which
|
|
72
|
+
* was the eval finding #12 scenario (two patches both claiming
|
|
73
|
+
* `browser/themes/shared/jar.inc.mn` after a second export with
|
|
74
|
+
* `--before`).
|
|
75
|
+
*
|
|
76
|
+
* Proceeds silently when there is no overlap, or when the caller passed
|
|
77
|
+
* `--allow-overlap`. In interactive mode the caller is prompted to
|
|
78
|
+
* acknowledge the overlap (the proper fix path is `re-export --files` to
|
|
79
|
+
* repartition ownership, so the prompt surfaces that pointer). In
|
|
80
|
+
* non-interactive mode the function throws — better to fail fast than
|
|
81
|
+
* let the queue fall out of sync with verify.
|
|
82
|
+
*/
|
|
83
|
+
export declare function guardOwnershipOverlap(args: {
|
|
84
|
+
patchesDir: string;
|
|
85
|
+
filesAffected: string[];
|
|
86
|
+
supersedingFilenames: ReadonlySet<string>;
|
|
87
|
+
allowOverlap: boolean;
|
|
88
|
+
isInteractive: boolean;
|
|
89
|
+
s: SpinnerHandle;
|
|
90
|
+
}): Promise<boolean>;
|
|
@@ -4,6 +4,7 @@ import { confirm, select, text } from '@clack/prompts';
|
|
|
4
4
|
import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-headers.js';
|
|
5
5
|
import { findAllPatchesForFiles } from '../core/patch-export.js';
|
|
6
6
|
import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, } from '../core/patch-lint.js';
|
|
7
|
+
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
7
8
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
8
9
|
import { pathExists, readText } from '../utils/fs.js';
|
|
9
10
|
import { cancel, info, isCancel, warn } from '../utils/logger.js';
|
|
@@ -222,4 +223,79 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
|
|
|
222
223
|
}
|
|
223
224
|
return true;
|
|
224
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Maps every file in `filesAffected` to the existing patches that already
|
|
228
|
+
* claim ownership of it, excluding the caller's own patch (when `newFilename`
|
|
229
|
+
* is provided) and any patches that the caller intends to fully supersede.
|
|
230
|
+
*
|
|
231
|
+
* Returns an empty map when no overlap exists. Used by the overlap gate in
|
|
232
|
+
* `export` and `export-all` to refuse a default-mode export that would
|
|
233
|
+
* silently create cross-patch ownership conflicts — the same class of
|
|
234
|
+
* conflict `verify` immediately fails with.
|
|
235
|
+
*/
|
|
236
|
+
export function findPartialOwnershipOverlap(manifest, filesAffected, excludeFilenames) {
|
|
237
|
+
const overlap = new Map();
|
|
238
|
+
const targetSet = new Set(filesAffected);
|
|
239
|
+
for (const patch of manifest.patches) {
|
|
240
|
+
if (excludeFilenames.has(patch.filename))
|
|
241
|
+
continue;
|
|
242
|
+
for (const file of patch.filesAffected) {
|
|
243
|
+
if (!targetSet.has(file))
|
|
244
|
+
continue;
|
|
245
|
+
const owners = overlap.get(file) ?? [];
|
|
246
|
+
owners.push(patch.filename);
|
|
247
|
+
overlap.set(file, owners);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return overlap;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Gate that refuses the default export path when the new patch would
|
|
254
|
+
* silently claim files that are already tracked by other non-superseded
|
|
255
|
+
* patches. `findAllPatchesForFiles` already catches the full-coverage
|
|
256
|
+
* supersede case — this helper fills the gap for partial overlap, which
|
|
257
|
+
* was the eval finding #12 scenario (two patches both claiming
|
|
258
|
+
* `browser/themes/shared/jar.inc.mn` after a second export with
|
|
259
|
+
* `--before`).
|
|
260
|
+
*
|
|
261
|
+
* Proceeds silently when there is no overlap, or when the caller passed
|
|
262
|
+
* `--allow-overlap`. In interactive mode the caller is prompted to
|
|
263
|
+
* acknowledge the overlap (the proper fix path is `re-export --files` to
|
|
264
|
+
* repartition ownership, so the prompt surfaces that pointer). In
|
|
265
|
+
* non-interactive mode the function throws — better to fail fast than
|
|
266
|
+
* let the queue fall out of sync with verify.
|
|
267
|
+
*/
|
|
268
|
+
export async function guardOwnershipOverlap(args) {
|
|
269
|
+
const { patchesDir, filesAffected, supersedingFilenames, allowOverlap, isInteractive, s } = args;
|
|
270
|
+
if (allowOverlap)
|
|
271
|
+
return true;
|
|
272
|
+
const manifest = await loadPatchesManifest(patchesDir);
|
|
273
|
+
if (!manifest)
|
|
274
|
+
return true;
|
|
275
|
+
const overlap = findPartialOwnershipOverlap(manifest, filesAffected, supersedingFilenames);
|
|
276
|
+
if (overlap.size === 0)
|
|
277
|
+
return true;
|
|
278
|
+
s.stop();
|
|
279
|
+
const entries = [...overlap.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
280
|
+
warn(`This export would create cross-patch ownership overlap on ${String(entries.length)} file${entries.length === 1 ? '' : 's'}:`);
|
|
281
|
+
for (const [file, owners] of entries) {
|
|
282
|
+
warn(` - ${file} already claimed by: ${owners.join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
warn('The queue would fail `fireforge verify` immediately after this export. ' +
|
|
285
|
+
'To repartition ownership safely, run `fireforge re-export --files <paths> <existing-patch>` ' +
|
|
286
|
+
'on the overlapping patches first, then re-run the export.');
|
|
287
|
+
if (!isInteractive) {
|
|
288
|
+
throw new GeneralError('Refusing to export a queue with cross-patch ownership overlap in non-interactive mode. ' +
|
|
289
|
+
'Pass --allow-overlap to acknowledge the conflict, or repartition ownership via `fireforge re-export --files`.');
|
|
290
|
+
}
|
|
291
|
+
const confirmed = await confirm({
|
|
292
|
+
message: 'Proceed with overlapping ownership? This will leave the queue in a verify-failing state.',
|
|
293
|
+
initialValue: false,
|
|
294
|
+
});
|
|
295
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
296
|
+
cancel('Export cancelled');
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
225
301
|
//# sourceMappingURL=export-shared.js.map
|
|
@@ -10,7 +10,7 @@ import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff
|
|
|
10
10
|
import { isBinaryFile } from '../core/git-file-ops.js';
|
|
11
11
|
import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
|
|
12
12
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
13
|
-
import { commitExportedPatch } from '../core/patch-export.js';
|
|
13
|
+
import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
|
|
14
14
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
15
15
|
import { toError } from '../utils/errors.js';
|
|
16
16
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
@@ -19,7 +19,7 @@ import { pickDefined } from '../utils/options.js';
|
|
|
19
19
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
20
20
|
import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
|
|
21
21
|
import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
|
|
22
|
-
import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
22
|
+
import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
|
|
23
23
|
async function collectExportFiles(paths, files) {
|
|
24
24
|
const collectedFiles = new Set();
|
|
25
25
|
let fileStatuses;
|
|
@@ -286,6 +286,26 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
286
286
|
const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
|
|
287
287
|
if (!shouldProceed)
|
|
288
288
|
return;
|
|
289
|
+
// Overlap gate: pre-0.16.0 `export` only caught FULL-coverage
|
|
290
|
+
// supersedes, so a second export targeting a shared file like
|
|
291
|
+
// `browser/themes/shared/jar.inc.mn` happily created a queue where
|
|
292
|
+
// two patches both listed the same file in `filesAffected`. `verify`
|
|
293
|
+
// then failed immediately on "cross-patch filesAffected conflicts".
|
|
294
|
+
// `confirmSupersedePatches` might already have confirmed full
|
|
295
|
+
// supersedes above; pass their filenames through so we do not flag
|
|
296
|
+
// a file claimed by a patch that is about to be removed.
|
|
297
|
+
const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
|
|
298
|
+
const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
|
|
299
|
+
const shouldProceedPastOverlap = await guardOwnershipOverlap({
|
|
300
|
+
patchesDir: paths.patches,
|
|
301
|
+
filesAffected,
|
|
302
|
+
supersedingFilenames,
|
|
303
|
+
allowOverlap: options.allowOverlap === true,
|
|
304
|
+
isInteractive,
|
|
305
|
+
s,
|
|
306
|
+
});
|
|
307
|
+
if (!shouldProceedPastOverlap)
|
|
308
|
+
return;
|
|
289
309
|
const { patchFilename, superseded } = await commitExportedPatch({
|
|
290
310
|
patchesDir: paths.patches,
|
|
291
311
|
category: selectedCategory,
|
|
@@ -327,6 +347,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
327
347
|
.option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
|
|
328
348
|
.option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
|
|
329
349
|
.option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
|
|
350
|
+
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
|
|
330
351
|
.action(withErrorHandling(async (paths, options) => {
|
|
331
352
|
const { category, ...rest } = options;
|
|
332
353
|
await exportCommand(getProjectRoot(), paths, {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive read-back helper for `furnace create`. Extracted from
|
|
3
|
+
* `create.ts` so the authoring command stays under the per-file LOC
|
|
4
|
+
* budget.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Asserts that the just-written furnace.json contains the expected
|
|
8
|
+
* custom component entry. The eval run's finding #9 observed a
|
|
9
|
+
* scenario where `furnace create --allow-prefix-mismatch` reported
|
|
10
|
+
* success and wrote the component files, but the subsequent
|
|
11
|
+
* `furnace status` found `custom: {}` in furnace.json — an invariant
|
|
12
|
+
* violation with no clear smoking gun in the code path. Local repros
|
|
13
|
+
* do not trigger it, so the defensive readback is the safest recovery
|
|
14
|
+
* contract we can offer: if the new entry is not visible on the next
|
|
15
|
+
* load, throw a `FurnaceError` so the rollback journal restores the
|
|
16
|
+
* pre-command state and the operator sees the failure instead of a
|
|
17
|
+
* phantom success.
|
|
18
|
+
*
|
|
19
|
+
* @param projectRoot - Root of the FireForge project
|
|
20
|
+
* @param componentName - Custom-element tag name that must be present
|
|
21
|
+
* in `config.custom` after the write. Throws when absent.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertCustomEntryPersisted(projectRoot: string, componentName: string): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Defensive read-back helper for `furnace create`. Extracted from
|
|
4
|
+
* `create.ts` so the authoring command stays under the per-file LOC
|
|
5
|
+
* budget.
|
|
6
|
+
*/
|
|
7
|
+
import { loadFurnaceConfig } from '../../core/furnace-config.js';
|
|
8
|
+
import { FurnaceError } from '../../errors/furnace.js';
|
|
9
|
+
/**
|
|
10
|
+
* Asserts that the just-written furnace.json contains the expected
|
|
11
|
+
* custom component entry. The eval run's finding #9 observed a
|
|
12
|
+
* scenario where `furnace create --allow-prefix-mismatch` reported
|
|
13
|
+
* success and wrote the component files, but the subsequent
|
|
14
|
+
* `furnace status` found `custom: {}` in furnace.json — an invariant
|
|
15
|
+
* violation with no clear smoking gun in the code path. Local repros
|
|
16
|
+
* do not trigger it, so the defensive readback is the safest recovery
|
|
17
|
+
* contract we can offer: if the new entry is not visible on the next
|
|
18
|
+
* load, throw a `FurnaceError` so the rollback journal restores the
|
|
19
|
+
* pre-command state and the operator sees the failure instead of a
|
|
20
|
+
* phantom success.
|
|
21
|
+
*
|
|
22
|
+
* @param projectRoot - Root of the FireForge project
|
|
23
|
+
* @param componentName - Custom-element tag name that must be present
|
|
24
|
+
* in `config.custom` after the write. Throws when absent.
|
|
25
|
+
*/
|
|
26
|
+
export async function assertCustomEntryPersisted(projectRoot, componentName) {
|
|
27
|
+
const persisted = await loadFurnaceConfig(projectRoot);
|
|
28
|
+
if (!(componentName in persisted.custom)) {
|
|
29
|
+
throw new FurnaceError(`Wrote furnace.json but "${componentName}" is missing from config.custom on read-back. ` +
|
|
30
|
+
'This should not happen — please report the issue. As a workaround, ' +
|
|
31
|
+
're-run the command, or add the entry to furnace.json by hand.', componentName);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=create-readback.js.map
|
|
@@ -19,6 +19,7 @@ import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils
|
|
|
19
19
|
import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
|
|
20
20
|
import { resolveCreateFeatures } from './create-features.js';
|
|
21
21
|
import { scaffoldMochikitTestFiles } from './create-mochikit.js';
|
|
22
|
+
import { assertCustomEntryPersisted } from './create-readback.js';
|
|
22
23
|
import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
|
|
23
24
|
import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
|
|
24
25
|
async function loadAuthoringFurnaceConfig(projectRoot) {
|
|
@@ -266,6 +267,7 @@ async function performCreateMutations(args) {
|
|
|
266
267
|
args.config.custom[args.componentName] = customEntry;
|
|
267
268
|
await snapshotFile(journal, args.furnacePaths.furnaceConfig);
|
|
268
269
|
await writeFurnaceConfig(args.projectRoot, args.config);
|
|
270
|
+
await assertCustomEntryPersisted(args.projectRoot, args.componentName);
|
|
269
271
|
if (args.testStyle === 'browser-chrome') {
|
|
270
272
|
const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
|
|
271
273
|
testFiles.push(...scafFiles);
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { FurnacePreviewOptions } from '../../types/commands/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a targeted Storybook failure message from captured mach output.
|
|
4
|
+
*
|
|
5
|
+
* Exported for the test suite: the heuristic has three branches (backend
|
|
6
|
+
* artifact missing, Storybook dep missing, generic) and regression
|
|
7
|
+
* testing each is easier when the classifier is addressable directly.
|
|
8
|
+
*
|
|
9
|
+
* @param output - Combined stdout and stderr from the Storybook command
|
|
10
|
+
* @param installRequested - Whether the caller requested a dependency reinstall first
|
|
11
|
+
* @returns User-facing guidance for the specific failure mode
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildStorybookFailureMessage(output: string, installRequested: boolean): string;
|
|
2
14
|
/**
|
|
3
15
|
* Runs the furnace preview command to start Storybook for component preview.
|
|
4
16
|
* @param projectRoot - Root directory of the project
|
|
@@ -72,17 +72,49 @@ function reportPreviewStagingFailures(stageResult) {
|
|
|
72
72
|
const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
|
|
73
73
|
throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to stage for preview`);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Filenames emitted by the Firefox build backend (not by Storybook's npm
|
|
77
|
+
* package set) — their absence means `mach build` has not produced its
|
|
78
|
+
* post-configure artifacts, which is a different failure mode from a
|
|
79
|
+
* missing Storybook workspace dependency tree. The eval log for finding
|
|
80
|
+
* #11 reported `FileNotFoundError: [...] chrome-map.json` *after* a
|
|
81
|
+
* successful Storybook `npm install`, and the pre-0.16 heuristic
|
|
82
|
+
* misdiagnosed it as a dep failure and sent the operator back to
|
|
83
|
+
* `--install`. Pattern list is narrow on purpose so we only surface the
|
|
84
|
+
* backend-rebuild hint when we are confident.
|
|
85
|
+
*/
|
|
86
|
+
const BACKEND_ARTIFACT_PATTERNS = [
|
|
87
|
+
/chrome-map\.json/i,
|
|
88
|
+
/config\.status/i,
|
|
89
|
+
/obj-[^\s/]+\/dist\/bin\/\.lldbinit/i,
|
|
90
|
+
];
|
|
75
91
|
/**
|
|
76
92
|
* Builds a targeted Storybook failure message from captured mach output.
|
|
93
|
+
*
|
|
94
|
+
* Exported for the test suite: the heuristic has three branches (backend
|
|
95
|
+
* artifact missing, Storybook dep missing, generic) and regression
|
|
96
|
+
* testing each is easier when the classifier is addressable directly.
|
|
97
|
+
*
|
|
77
98
|
* @param output - Combined stdout and stderr from the Storybook command
|
|
78
99
|
* @param installRequested - Whether the caller requested a dependency reinstall first
|
|
79
100
|
* @returns User-facing guidance for the specific failure mode
|
|
80
101
|
*/
|
|
81
|
-
function buildStorybookFailureMessage(output, installRequested) {
|
|
102
|
+
export function buildStorybookFailureMessage(output, installRequested) {
|
|
82
103
|
const installHint = installRequested
|
|
83
104
|
? 'Try running "python3 ./mach storybook upgrade" manually in the engine directory.'
|
|
84
105
|
: 'Run "fireforge furnace preview --install" to bootstrap Storybook dependencies, or run "python3 ./mach storybook upgrade" manually in engine/.';
|
|
85
|
-
|
|
106
|
+
const hasFileNotFoundSignal = /(ENOENT|No such file or directory|FileNotFoundError)/i.test(output);
|
|
107
|
+
// Check backend-artifact signal first — a missing chrome-map.json looks
|
|
108
|
+
// like any other "No such file" error to a naïve regex, but the fix is
|
|
109
|
+
// to rerun `fireforge build`, not to reinstall Storybook dependencies.
|
|
110
|
+
if (hasFileNotFoundSignal && BACKEND_ARTIFACT_PATTERNS.some((p) => p.test(output))) {
|
|
111
|
+
return ('Storybook failed because the Firefox build backend artifacts are missing or stale ' +
|
|
112
|
+
'(chrome-map.json / config.status / obj-*/dist/bin/.lldbinit). ' +
|
|
113
|
+
'This is a Firefox-build completeness issue, not a Storybook dependency issue.\n\n' +
|
|
114
|
+
'Rerun "fireforge build" and let it finish, then retry "fireforge furnace preview". ' +
|
|
115
|
+
'A full rebuild regenerates the backend artifacts Storybook reads.');
|
|
116
|
+
}
|
|
117
|
+
if (hasFileNotFoundSignal && /storybook|backend/i.test(output)) {
|
|
86
118
|
return ('Storybook failed because the Firefox checkout appears to be missing Storybook workspace files or backend dependencies.\n\n' +
|
|
87
119
|
installHint);
|
|
88
120
|
}
|
|
@@ -196,7 +196,7 @@ export async function furnaceStatusCommand(projectRoot, name) {
|
|
|
196
196
|
warn('Engine drift detected since last apply (reset/download/manual edit). Run `fireforge furnace apply` to re-deploy.');
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
-
info('Tip: run `furnace status <name>` for detailed component info, or `furnace --help` for all subcommands.');
|
|
199
|
+
info('Tip: run `fireforge furnace status <name>` for detailed component info, or `fireforge furnace --help` for all subcommands.');
|
|
200
200
|
outro('Status complete');
|
|
201
201
|
}
|
|
202
202
|
//# sourceMappingURL=status.js.map
|
|
@@ -20,7 +20,16 @@ export { patchReorderCommand } from './reorder.js';
|
|
|
20
20
|
export function registerPatch(program, context) {
|
|
21
21
|
const patch = program
|
|
22
22
|
.command('patch')
|
|
23
|
-
.description('Manage individual patches in the queue (compact, delete, reorder)')
|
|
23
|
+
.description('Manage individual patches in the queue (compact, delete, reorder)')
|
|
24
|
+
// Match `fireforge furnace`'s no-args contract: print the group's help and
|
|
25
|
+
// exit 0. Without this default action, commander routes `fireforge patch`
|
|
26
|
+
// (no subcommand) through its own help-then-exit-1 path, so scripts that
|
|
27
|
+
// probe the CLI surface see a misleading non-zero exit for a purely
|
|
28
|
+
// informational invocation. The action prints the exact same help commander
|
|
29
|
+
// would otherwise print, but returns successfully.
|
|
30
|
+
.action(() => {
|
|
31
|
+
patch.outputHelp();
|
|
32
|
+
});
|
|
24
33
|
registerPatchCompact(patch, context);
|
|
25
34
|
registerPatchDelete(patch, context);
|
|
26
35
|
registerPatchReorder(patch, context);
|