@hominis/fireforge 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +22 -0
  3. package/dist/src/commands/export-all.js +5 -15
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +36 -0
  10. package/dist/src/commands/export.js +47 -112
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +1 -1
  20. package/dist/src/commands/lint-per-patch.js +110 -81
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +96 -84
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-scan.js +8 -1
  42. package/dist/src/commands/rebase/summary.d.ts +1 -5
  43. package/dist/src/commands/rebase/summary.js +1 -1
  44. package/dist/src/commands/status-output.js +77 -68
  45. package/dist/src/commands/test-diagnose.d.ts +23 -0
  46. package/dist/src/commands/test-diagnose.js +210 -0
  47. package/dist/src/commands/test-run.d.ts +58 -0
  48. package/dist/src/commands/test-run.js +88 -0
  49. package/dist/src/commands/test.js +169 -257
  50. package/dist/src/commands/token.js +15 -1
  51. package/dist/src/commands/wire.js +109 -78
  52. package/dist/src/core/build-audit.d.ts +1 -1
  53. package/dist/src/core/build-audit.js +2 -46
  54. package/dist/src/core/build-baseline-types.d.ts +38 -0
  55. package/dist/src/core/build-baseline-types.js +10 -0
  56. package/dist/src/core/build-baseline.d.ts +1 -31
  57. package/dist/src/core/build-prepare.d.ts +1 -1
  58. package/dist/src/core/build-prepare.js +2 -45
  59. package/dist/src/core/config-paths.d.ts +0 -8
  60. package/dist/src/core/config-paths.js +4 -4
  61. package/dist/src/core/config-state.d.ts +0 -6
  62. package/dist/src/core/config-state.js +1 -1
  63. package/dist/src/core/config-validate-patch-policy.js +12 -13
  64. package/dist/src/core/config-validate.js +48 -28
  65. package/dist/src/core/engine-changes.d.ts +24 -0
  66. package/dist/src/core/engine-changes.js +64 -0
  67. package/dist/src/core/firefox-cache.d.ts +0 -5
  68. package/dist/src/core/firefox-cache.js +1 -1
  69. package/dist/src/core/firefox-download.d.ts +0 -6
  70. package/dist/src/core/firefox-download.js +1 -1
  71. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  72. package/dist/src/core/furnace-apply-helpers.js +11 -20
  73. package/dist/src/core/furnace-apply.d.ts +1 -1
  74. package/dist/src/core/furnace-apply.js +1 -1
  75. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  76. package/dist/src/core/furnace-checksum-utils.js +15 -0
  77. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  78. package/dist/src/core/furnace-config-validate.js +133 -0
  79. package/dist/src/core/furnace-config.d.ts +4 -32
  80. package/dist/src/core/furnace-config.js +15 -111
  81. package/dist/src/core/furnace-constants.d.ts +0 -10
  82. package/dist/src/core/furnace-constants.js +2 -2
  83. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  84. package/dist/src/core/furnace-css-fragments.js +243 -0
  85. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  86. package/dist/src/core/furnace-jsconfig.js +171 -0
  87. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  88. package/dist/src/core/furnace-validate-helpers.js +40 -1
  89. package/dist/src/core/furnace-validate-registration.js +16 -1
  90. package/dist/src/core/furnace-validate.js +54 -2
  91. package/dist/src/core/git-file-ops.d.ts +0 -12
  92. package/dist/src/core/git-file-ops.js +2 -2
  93. package/dist/src/core/lint-cache.d.ts +0 -13
  94. package/dist/src/core/lint-cache.js +5 -5
  95. package/dist/src/core/mach.d.ts +5 -1
  96. package/dist/src/core/mach.js +6 -2
  97. package/dist/src/core/manifest-register.d.ts +5 -16
  98. package/dist/src/core/manifest-register.js +3 -1
  99. package/dist/src/core/patch-lint-checkjs.js +53 -7
  100. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  101. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  102. package/dist/src/core/patch-lint-observer.js +168 -0
  103. package/dist/src/core/patch-lint.js +132 -125
  104. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  105. package/dist/src/core/patch-manifest-io.js +44 -2
  106. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  107. package/dist/src/core/patch-manifest-validate.js +1 -1
  108. package/dist/src/core/patch-manifest.d.ts +1 -1
  109. package/dist/src/core/patch-manifest.js +1 -1
  110. package/dist/src/core/patch-policy.d.ts +0 -4
  111. package/dist/src/core/patch-policy.js +10 -4
  112. package/dist/src/core/register-browser-content.d.ts +1 -1
  113. package/dist/src/core/register-module.d.ts +1 -1
  114. package/dist/src/core/register-result.d.ts +21 -0
  115. package/dist/src/core/register-result.js +9 -0
  116. package/dist/src/core/register-shared-css.d.ts +1 -1
  117. package/dist/src/core/register-test-manifest.d.ts +1 -1
  118. package/dist/src/core/test-harness-crash.d.ts +61 -0
  119. package/dist/src/core/test-harness-crash.js +140 -0
  120. package/dist/src/core/test-stale-check.d.ts +1 -1
  121. package/dist/src/core/test-stale-check.js +2 -46
  122. package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
  123. package/dist/src/core/test-xpcshell-retry.js +4 -2
  124. package/dist/src/core/token-dark-mode.js +14 -26
  125. package/dist/src/core/token-manager.d.ts +4 -0
  126. package/dist/src/core/token-manager.js +70 -16
  127. package/dist/src/core/typecheck-shim.d.ts +0 -21
  128. package/dist/src/core/typecheck-shim.js +26 -4
  129. package/dist/src/core/wire-utils.js +37 -44
  130. package/dist/src/types/commands/index.d.ts +1 -1
  131. package/dist/src/types/commands/options.d.ts +105 -0
  132. package/dist/src/types/furnace.d.ts +12 -1
  133. package/dist/src/utils/elapsed.d.ts +0 -2
  134. package/dist/src/utils/elapsed.js +1 -1
  135. package/dist/src/utils/fs.d.ts +0 -5
  136. package/dist/src/utils/fs.js +1 -1
  137. package/dist/src/utils/regex.d.ts +0 -6
  138. package/dist/src/utils/regex.js +3 -3
  139. package/dist/src/utils/validation.d.ts +0 -8
  140. package/dist/src/utils/validation.js +2 -2
  141. package/package.json +6 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0
4
+
5
+ - Made `patch compact` range-aware: with `patchPolicy.ranges` configured, each category range compacts independently (anchored at its first occupied ordinal, treating reserved orders as non-gaps), so a mid-range gap under `allowGaps: false` can finally be closed without projecting patches across category boundaries. Reserved-range patches and out-of-range strays are left in place with a warning. Without ranges the historical whole-queue renumber from 1 is unchanged.
6
+ - Rewrote `stagedDependencies.forwardImports[].owner` references during every patch renumber (compact, reorder, placement export, rename) and in reorder's dry-run projection, so staged-dependency declarations survive renumbering instead of dangling and surfacing pre-existing forward imports as new errors. `patch delete` now warns when other patches still name the deleted patch as an owner.
7
+ - Added `fireforge patch split <source> --files <paths...> --name <name>` to move files out of a patch into a brand-new patch as one transaction: worktree-derived shrink of the source, new-patch creation with `--order`/`--before`/`--after` placement (default: after the source), and staged-dependency owner rewrites in dependent patches — validated against the final projection only, with dry-run support and reverse-order rollback. `patch move-files` now points at it for the move-to-new-patch case.
8
+ - Scoped plain `re-export --scan` to the patch's exact directory footprint: git pathspecs recurse, so a claimed file in a shallow directory used to sweep every unmanaged file in the subtree into the scan candidates of whichever patch was exported first. Deeper paths now require an explicit `--scan-file` / `--scan-files` assignment; the broad-scan confirmation guard is unchanged.
9
+ - Resolved imports of patch-owned modules to their real sources in the per-patch `checkJs` pass (unique-basename matching for `chrome://`/`resource://` specifiers, with a `.mjs` → `.sys.mjs` fallback), so JSDoc `value is …` type-guard predicates and `@template` generics survive module boundaries instead of degrading to `any`. Unknown or ambiguous specifiers keep the loose ambient-wildcard typing. Note: previously invisible cross-module type errors in patch-owned modules may now surface.
10
+ - Added `ChromeUtils.getClassName`, `ChromeUtils.defineLazyGetter`, and the `Localization` constructor to the shipped Firefox-globals typecheck shim, so per-patch lint accepts these stable chrome globals without local casts. Projects can still extend the ambient set via `patchLint.checkJsExtraShim` / `typecheck.extraShim`.
11
+ - Fixed the exported-method JSDoc `@param` extractor to scan balanced braces, so inline object types containing nested generics (e.g. `@param {{ id: string, args?: Record<string, string | number | boolean> }} message`) no longer fail `jsdoc-class-method-param-mismatch`; optional `[name]` and defaulted `[name=x]` forms now parse too.
12
+ - Rewrote the `observer-topic-naming` check to parse balanced multi-line call sites and inspect the actual topic argument (the second) instead of the first string literal on the line, and added a known-Firefox-topics allowlist (`idle-daily`, the `quit-application` family, lifecycle and `http-on-*` topics) so simulating upstream notifications in tests is never flagged. Constant-named topics remain exempt.
13
+ - Taught `fireforge test` to classify harness runs from their output instead of trusting exit codes or summary lines: recognized harness-crash shapes (macOS mozlog resource-monitor/psutil startup tracebacks, pre-test no-output hangs that still print `Passed: 0`, and post-green "Application shut down (without crashing) in the middle of a test!" re-entries after a focus stall) are retried with a bounded budget (`--harness-retries <n>`, default 2), and a zero-exit run with no `TEST-START` now fails instead of passing silently.
14
+ - Sharded multi-path `fireforge test` invocations into sequential single-file harness runs with per-shard retries, per-shard diagnosis, and an aggregate PASS/FAIL/CRASH summary, avoiding the cross-file profile/pref bleed that destabilized later files in combined runs. `--no-shard` restores the single combined invocation.
15
+ - Added `fireforge test --perf-samples <path>`, which publishes the resolved artifact path to the harness process as `<BINARYNAME>_PERF_SAMPLE_JSON` so downstream perf-budget checkers no longer maintain their own env contract.
16
+ - Fixed named `furnace deploy <component>` to run the same pipeline as deploy-all, so renaming or deleting a component file now prunes the stale engine copy, its `jar.mn` line, and (for a removed main module) the `customElements.js` registration, instead of leaving orphans a later re-export would capture. Unchanged components now skip instead of force-reapplying, and `furnace validate` flags engine-side orphans (`orphaned-engine-file`) left by pre-0.31.0 deploys.
17
+ - Added shared CSS fragments for Furnace widgets: a `/* @fireforge-include <fragment>.css */` directive in a widget stylesheet expands the named `components/shared/` fragment into the deployed copy (fenced, idempotent), keeping the workspace single-sourced across shadow-DOM-isolated widgets. Fragment edits surface as ordinary component drift and redeploy refreshes every consumer; `furnace validate` reports `missing-fragment` and `stale-fragment-expansion`.
18
+ - Added automatic jsconfig `paths` maintenance for multi-file components: with `furnace.json#typecheckJsconfig` set, deploy and sync keep `compilerOptions.paths` entries mapping each registered module's `chrome://global/content/elements/<file>.mjs` URL to its workspace source (no `baseUrl` needed), pruning entries for removed helpers and preserving all hand-written configuration; `furnace validate` reports drift as `jsconfig-paths-drift`.
19
+ - Fixed `token add` double-prefixing bare names that already start with the configured `tokenPrefix` text; such names are now treated as fully qualified with an informational note.
20
+ - Added `token add --create-category`, which declares the missing category banner inside the `:root` block and inserts the token in the same single write; the "Category not found" error now advertises the flag.
21
+ - Added two static release gates: `npm run deadcode:check` (knip, with a `knip.json` tuned to the project's entry points) fails on unused exports/files/dependencies, and `npm run cycles:check` (dpdm) fails on circular imports. Both run in `release:check` and the pre-push hook.
22
+ - Internalized 45 exports that had no consumers outside their defining module, and untangled the seven type-only import cycles (`BuildBaseline`, `RegisterResult`, `ResolvedTestStyle`, and `LintCommandOptions` moved to leaf/type modules) so the cycle gate starts from zero.
23
+ - Refactored the twelve functions with cyclomatic complexity above 30 (worst: `exportCommand` at 48) into focused helpers — behavior-preserving statement moves, verified by the existing suites — and now enforce `complexity: ["error", 30]` via ESLint. The long-standing `max-lines-per-function` suppression on `exportCommand` is gone with the split.
24
+ - Deduplicated four copied sequences: the patch-subcommand preamble (seven commands now share `requirePatchQueue`/`requirePatchTarget`), the changed-since-baseline collector (`build-audit`/`build-prepare`/`test-stale-check` share `collectChangedEnginePaths`), the export supersede+overlap gate (`export`/`export-all` share `runSupersedeAndOverlapGates`), and two same-file clones in `token-dark-mode.ts` and `config-validate-patch-policy.ts`.
25
+ - Restricted `process.exit()` to `bin/fireforge.ts` via lint (`no-restricted-properties`), turning the previously comment-enforced invariant into a build failure.
26
+ - Bumped the TypeScript `target`/`lib` from ES2022 to ES2023 (Node ≥ 22.22.1 fully implements it) and dropped the redundant `@typescript-eslint/eslint-plugin` / `@typescript-eslint/parser` devDependencies already provided by the `typescript-eslint` meta-package.
27
+
3
28
  ## 0.30.0
4
29
 
5
30
  - Added safe repo-local per-patch lint result caching for `lint --per-patch`, plus `--no-cache` and `lint cache clear` escape hatches while preserving release-gate severity accounting and queue-wide checks. Warm cache hits now skip per-patch diff generation as well as lint rule execution, guarded by patch, config, engine content, queue ownership, and engine HEAD inputs.
package/README.md CHANGED
@@ -82,6 +82,19 @@ assignment before refreshing the patch. For release whitespace checks, use
82
82
  `npm run whitespace:check`; it still checks source diffs while excluding generated
83
83
  `patches/*.patch` diff syntax.
84
84
 
85
+ Queue maintenance lives under `fireforge patch`: `patch compact` closes ordinal gaps
86
+ (range-aware when `patchPolicy.ranges` is configured), `patch reorder` moves a patch, and
87
+ `patch split <source> --files <paths...> --name <name>` carves files out of a patch into a
88
+ new one as a single transaction — including staged-dependency owner rewrites — with
89
+ `--dry-run` support.
90
+
91
+ `fireforge test` runs multiple test paths as sequential per-file shards by default
92
+ (`--no-shard` restores one combined invocation), retries recognized harness crashes up to
93
+ `--harness-retries <n>` times (default 2), and can publish a perf-sample artifact path to
94
+ the harness via `--perf-samples <path>` (exported as `<BINARYNAME>_PERF_SAMPLE_JSON`).
95
+ Design tokens are managed with `fireforge token add`; pass `--create-category` to declare a
96
+ new category banner and insert the token in one step.
97
+
85
98
  ## Rebasing Firefox Source
86
99
 
87
100
  When Mozilla publishes a new Firefox source release you need to update the configured version/product, download the new source code and reapply the patches:
@@ -109,6 +122,15 @@ npx fireforge furnace preview
109
122
 
110
123
  Use `fireforge furnace --help` for the full set of component commands.
111
124
 
125
+ Cross-widget CSS can be single-sourced as shared fragments: place the fragment in
126
+ `components/shared/` and reference it from a widget stylesheet with a
127
+ `/* @fireforge-include <fragment>.css */` comment — deploy expands it into the deployed
128
+ copy only, and editing the fragment surfaces as component drift until the next deploy.
129
+ For typed cross-module imports of multi-file components, set
130
+ `furnace.json#typecheckJsconfig` to a consumer-owned jsconfig and deploy will maintain
131
+ `compilerOptions.paths` entries mapping each deployed
132
+ `chrome://global/content/elements/<file>.mjs` URL to its workspace source.
133
+
112
134
  ## Roadmap
113
135
 
114
136
  - **Docker builds** Reproducible builds using Docker containers.
@@ -5,7 +5,7 @@ import { hasChanges, isGitRepository } from '../core/git.js';
5
5
  import { getAllDiff, getDiffForFilesAgainstHead } from '../core/git-diff.js';
6
6
  import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from '../core/git-status.js';
7
7
  import { extractAffectedFiles } from '../core/patch-apply.js';
8
- import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
8
+ import { commitExportedPatch } from '../core/patch-export.js';
9
9
  import { buildPatchQueueContext, collectNewFileCreatorsByPath, detectNewFilesInDiff, } from '../core/patch-lint.js';
10
10
  import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
11
11
  import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
@@ -14,7 +14,7 @@ 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 { renderDryRunPreview } from './export-flow.js';
17
- import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
17
+ import { autoFixLicenseHeaders, promptExportPatchMetadata, runPatchLint, runSupersedeAndOverlapGates, } from './export-shared.js';
18
18
  async function checkBrandingManagedFiles(paths, config) {
19
19
  const changedFiles = await getWorkingTreeStatus(paths.engine);
20
20
  const brandingManagedFiles = changedFiles
@@ -272,25 +272,15 @@ export async function exportAllCommand(projectRoot, options = {}) {
272
272
  outro('Dry run complete — no changes made');
273
273
  return;
274
274
  }
275
- // Check how many existing patches would be superseded
276
- const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
277
- if (!shouldProceed)
278
- return;
279
- // Overlap gate — see the matching comment in `export.ts`. The same
280
- // cross-patch ownership problem applies to `export-all` because a
281
- // mixed aggregate diff often touches shared files like manifest
282
- // fragments that other patches already claim.
283
- const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
284
- const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
285
- const shouldProceedPastOverlap = await guardOwnershipOverlap({
275
+ const shouldProceedPastGates = await runSupersedeAndOverlapGates({
286
276
  patchesDir: paths.patches,
287
277
  filesAffected,
288
- supersedingFilenames,
278
+ supersede: options.supersede,
289
279
  allowOverlap: options.allowOverlap === true,
290
280
  isInteractive,
291
281
  s,
292
282
  });
293
- if (!shouldProceedPastOverlap)
283
+ if (!shouldProceedPastGates)
294
284
  return;
295
285
  // Get Firefox version for metadata
296
286
  const { patchFilename, superseded } = await commitExportedPatch({
@@ -19,6 +19,12 @@ export interface PlacementPlan {
19
19
  newFilename: string;
20
20
  renameMap: Map<string, PatchRenameEntry>;
21
21
  }
22
+ /**
23
+ * Structural equality for placement plans — used by placement-mode export
24
+ * and `patch split` to verify the queue did not change between the
25
+ * confirmed preview and the under-lock commit.
26
+ */
27
+ export declare function placementPlansEqual(left: PlacementPlan, right: PlacementPlan): boolean;
22
28
  /**
23
29
  * Computes the shift map that moves existing patches out of the requested
24
30
  * slot to make room for a new patch at `requestedOrder`.
@@ -33,7 +33,12 @@ function prefixWidthForPatches(manifestPatches, requestedOrder) {
33
33
  function getSortedRenameEntries(renameMap) {
34
34
  return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
35
35
  }
36
- function placementPlansEqual(left, right) {
36
+ /**
37
+ * Structural equality for placement plans — used by placement-mode export
38
+ * and `patch split` to verify the queue did not change between the
39
+ * confirmed preview and the under-lock commit.
40
+ */
41
+ export function placementPlansEqual(left, right) {
37
42
  if (left.insertionOrder !== right.insertionOrder || left.newFilename !== right.newFilename) {
38
43
  return false;
39
44
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Placement-flag gating for `fireforge export`, split out of `export.ts`
3
+ * to keep the command body inside the per-function and per-file line
4
+ * budgets. `gatePlacementPlan` owns every policy/confirmation decision a
5
+ * placement export makes before the locked commit in `export.ts` runs.
6
+ */
7
+ import type { ExportOptions, PatchMetadata } from '../types/commands/index.js';
8
+ import type { FireForgeConfig } from '../types/config.js';
9
+ import type { SpinnerHandle } from '../utils/logger.js';
10
+ import { type PlacementPlan } from './export-flow.js';
11
+ /**
12
+ * Spreadable optional metadata (`tier`, `lintIgnore`) derived from the
13
+ * export flags. Every manifest-row construction site in this command
14
+ * shares this shape; with exactOptionalPropertyTypes the keys must be
15
+ * omitted entirely (not set to undefined) when the flags are absent.
16
+ */
17
+ export declare function patchMetadataExtras(options: ExportOptions): Partial<Pick<PatchMetadata, 'tier' | 'lintIgnore'>>;
18
+ /**
19
+ * Resolves and gates the placement plan when any placement flag
20
+ * (`--order`/`--before`/`--after`) was given: rejects the `--supersede`
21
+ * combination, enforces reserved ranges and patch policy against the
22
+ * projected manifest, and routes destructive renumbers (or dry-runs)
23
+ * through `confirmDestructive`. Returns the plan to commit, or `'stop'`
24
+ * when the command should end here (dry-run rendered or operator
25
+ * cancelled — the corresponding outro has already been printed).
26
+ */
27
+ export declare function gatePlacementPlan(args: {
28
+ patchesDir: string;
29
+ options: ExportOptions;
30
+ selectedCategory: string;
31
+ patchName: string;
32
+ description: string;
33
+ filesAffected: string[];
34
+ diff: string;
35
+ config: FireForgeConfig;
36
+ isDryRun: boolean;
37
+ s: SpinnerHandle;
38
+ }): Promise<PlacementPlan | 'stop'>;
@@ -0,0 +1,105 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Placement-flag gating for `fireforge export`, split out of `export.ts`
4
+ * to keep the command body inside the per-function and per-file line
5
+ * budgets. `gatePlacementPlan` owns every policy/confirmation decision a
6
+ * placement export makes before the locked commit in `export.ts` runs.
7
+ */
8
+ import { confirmDestructive } from '../core/destructive.js';
9
+ import { loadPatchesManifest } from '../core/patch-manifest.js';
10
+ import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
11
+ import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
12
+ import { InvalidArgumentError } from '../errors/base.js';
13
+ import { outro } from '../utils/logger.js';
14
+ import { placementSummary, projectPlacementForLint, resolvePlacementPlan, } from './export-flow.js';
15
+ import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
16
+ /**
17
+ * Spreadable optional metadata (`tier`, `lintIgnore`) derived from the
18
+ * export flags. Every manifest-row construction site in this command
19
+ * shares this shape; with exactOptionalPropertyTypes the keys must be
20
+ * omitted entirely (not set to undefined) when the flags are absent.
21
+ */
22
+ export function patchMetadataExtras(options) {
23
+ return {
24
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
25
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
26
+ ? { lintIgnore: options.lintIgnore }
27
+ : {}),
28
+ };
29
+ }
30
+ /**
31
+ * Resolves and gates the placement plan when any placement flag
32
+ * (`--order`/`--before`/`--after`) was given: rejects the `--supersede`
33
+ * combination, enforces reserved ranges and patch policy against the
34
+ * projected manifest, and routes destructive renumbers (or dry-runs)
35
+ * through `confirmDestructive`. Returns the plan to commit, or `'stop'`
36
+ * when the command should end here (dry-run rendered or operator
37
+ * cancelled — the corresponding outro has already been printed).
38
+ */
39
+ export async function gatePlacementPlan(args) {
40
+ const { patchesDir, options, selectedCategory, patchName, description, filesAffected, diff, config, isDryRun, s, } = args;
41
+ if (options.supersede) {
42
+ throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
43
+ }
44
+ const placementPlan = await resolvePlacementPlan(patchesDir, options, selectedCategory, patchName);
45
+ const currentManifest = await loadPatchesManifest(patchesDir);
46
+ if (currentManifest !== null) {
47
+ assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
48
+ }
49
+ const conflicts = await projectPlacementForLint(patchesDir, placementPlan, diff);
50
+ const renamed = currentManifest !== null
51
+ ? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
52
+ : buildProjectedManifest(null, []);
53
+ enforcePatchPolicy({
54
+ config,
55
+ manifest: buildProjectedManifest(renamed, [
56
+ ...renamed.patches,
57
+ {
58
+ filename: placementPlan.newFilename,
59
+ order: placementPlan.insertionOrder,
60
+ category: selectedCategory,
61
+ name: patchName,
62
+ description,
63
+ createdAt: new Date().toISOString(),
64
+ ...buildPatchSourceMetadata(config.firefox),
65
+ filesAffected,
66
+ ...patchMetadataExtras(options),
67
+ },
68
+ ]),
69
+ command: 'export',
70
+ forceUnsafe: options.forceUnsafe === true,
71
+ });
72
+ const summary = placementSummary(placementPlan);
73
+ const renameCount = placementPlan.renameMap.size;
74
+ // Route through confirmDestructive when the operation is destructive
75
+ // enough to warrant a prompt (more than one rename) OR when the user
76
+ // asked for a dry-run. The dry-run branch must always print the
77
+ // placement summary — previously, single-rename/no-rename dry-runs
78
+ // exited silently with no filename or projected layout.
79
+ if (renameCount > 1 || isDryRun) {
80
+ s.stop();
81
+ const decision = await confirmDestructive({
82
+ operation: 'export-order',
83
+ title: `Export with placement at order ${placementPlan.insertionOrder}`,
84
+ summary,
85
+ yes: options.yes === true,
86
+ dryRun: isDryRun,
87
+ unsafeOverride: options.forceUnsafe === true,
88
+ conflicts,
89
+ });
90
+ if (decision === 'dry-run') {
91
+ outro('Dry run complete — no changes made');
92
+ return 'stop';
93
+ }
94
+ if (decision === 'cancelled') {
95
+ outro('Export cancelled');
96
+ return 'stop';
97
+ }
98
+ }
99
+ else if (conflicts && options.forceUnsafe !== true) {
100
+ s.stop();
101
+ throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
102
+ }
103
+ return placementPlan;
104
+ }
105
+ //# sourceMappingURL=export-placement-gate.js.map
@@ -94,3 +94,31 @@ export declare function guardOwnershipOverlap(args: {
94
94
  isInteractive: boolean;
95
95
  s: SpinnerHandle;
96
96
  }): Promise<boolean>;
97
+ /**
98
+ * Runs the two pre-commit gates shared by `export` and `export-all` in
99
+ * order: the supersede confirmation, then the cross-patch ownership
100
+ * overlap guard. The overlap guard receives the filenames of the patches
101
+ * the export would fully supersede so it does not flag a file claimed by
102
+ * a patch that is about to be removed (pre-0.16.0 `export` only caught
103
+ * FULL-coverage supersedes, so a second export targeting a shared file
104
+ * like `browser/themes/shared/jar.inc.mn` created a queue where two
105
+ * patches both claimed the file and `verify` failed immediately).
106
+ *
107
+ * @param args - Gate inputs shared by both export commands
108
+ * @param args.patchesDir - Absolute path of the patches directory
109
+ * @param args.filesAffected - Engine-relative files the export claims
110
+ * @param args.supersede - The command's `--supersede` flag
111
+ * @param args.allowOverlap - The command's `--allow-overlap` flag
112
+ * @param args.isInteractive - Whether prompting the operator is possible
113
+ * @param args.s - Active spinner, stopped before any prompt
114
+ * @returns `true` when both gates passed; `false` when the operator
115
+ * declined (the caller returns without committing)
116
+ */
117
+ export declare function runSupersedeAndOverlapGates(args: {
118
+ patchesDir: string;
119
+ filesAffected: string[];
120
+ supersede: boolean | undefined;
121
+ allowOverlap: boolean;
122
+ isInteractive: boolean;
123
+ s: SpinnerHandle;
124
+ }): Promise<boolean>;
@@ -315,4 +315,40 @@ export async function guardOwnershipOverlap(args) {
315
315
  }
316
316
  return true;
317
317
  }
318
+ /**
319
+ * Runs the two pre-commit gates shared by `export` and `export-all` in
320
+ * order: the supersede confirmation, then the cross-patch ownership
321
+ * overlap guard. The overlap guard receives the filenames of the patches
322
+ * the export would fully supersede so it does not flag a file claimed by
323
+ * a patch that is about to be removed (pre-0.16.0 `export` only caught
324
+ * FULL-coverage supersedes, so a second export targeting a shared file
325
+ * like `browser/themes/shared/jar.inc.mn` created a queue where two
326
+ * patches both claimed the file and `verify` failed immediately).
327
+ *
328
+ * @param args - Gate inputs shared by both export commands
329
+ * @param args.patchesDir - Absolute path of the patches directory
330
+ * @param args.filesAffected - Engine-relative files the export claims
331
+ * @param args.supersede - The command's `--supersede` flag
332
+ * @param args.allowOverlap - The command's `--allow-overlap` flag
333
+ * @param args.isInteractive - Whether prompting the operator is possible
334
+ * @param args.s - Active spinner, stopped before any prompt
335
+ * @returns `true` when both gates passed; `false` when the operator
336
+ * declined (the caller returns without committing)
337
+ */
338
+ export async function runSupersedeAndOverlapGates(args) {
339
+ const { patchesDir, filesAffected, supersede, allowOverlap, isInteractive, s } = args;
340
+ const shouldProceed = await confirmSupersedePatches(patchesDir, filesAffected, supersede, isInteractive, s);
341
+ if (!shouldProceed)
342
+ return false;
343
+ const willSupersede = await findAllPatchesForFiles(patchesDir, filesAffected);
344
+ const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
345
+ return guardOwnershipOverlap({
346
+ patchesDir,
347
+ filesAffected,
348
+ supersedingFilenames,
349
+ allowOverlap,
350
+ isInteractive,
351
+ s,
352
+ });
353
+ }
318
354
  //# sourceMappingURL=export-shared.js.map
@@ -3,16 +3,14 @@ import { stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { Option } from 'commander';
5
5
  import { getProjectPaths, loadConfig } from '../core/config.js';
6
- import { appendHistory, confirmDestructive } from '../core/destructive.js';
6
+ import { appendHistory } from '../core/destructive.js';
7
7
  import { collectFurnaceManagedPrefixes } from '../core/furnace-config.js';
8
8
  import { getStatusWithCodes, isGitRepository } from '../core/git.js';
9
9
  import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff.js';
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, findAllPatchesForFiles } from '../core/patch-export.js';
14
- import { loadPatchesManifest } from '../core/patch-manifest.js';
15
- import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
13
+ import { commitExportedPatch } from '../core/patch-export.js';
16
14
  import { buildPatchSourceMetadata } from '../core/patch-source-metadata.js';
17
15
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
18
16
  import { toError } from '../utils/errors.js';
@@ -21,9 +19,9 @@ import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
21
19
  import { pickDefined } from '../utils/options.js';
22
20
  import { stripEnginePrefix } from '../utils/paths.js';
23
21
  import { parsePositiveIntegerFlag } from '../utils/validation.js';
24
- import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
25
- import { assertPlacementPreservesReservedRanges } from './export-placement-policy.js';
26
- import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
22
+ import { commitPlacementExport, renderDryRunPreview } from './export-flow.js';
23
+ import { gatePlacementPlan, patchMetadataExtras } from './export-placement-gate.js';
24
+ import { autoFixLicenseHeaders, promptExportPatchMetadata, runPatchLint, runSupersedeAndOverlapGates, } from './export-shared.js';
27
25
  async function collectExportFiles(paths, files) {
28
26
  const collectedFiles = new Set();
29
27
  let fileStatuses;
@@ -100,20 +98,14 @@ async function generatePatchDiff(engineDir, allFiles) {
100
98
  return diffs.join('\n');
101
99
  }
102
100
  /**
103
- * Runs the export command to export file changes as a patch.
104
- * Accepts one or more file/directory paths and bundles them into a single patch.
105
- * @param projectRoot - Root directory of the project
106
- * @param files - File or directory paths to export (relative to engine/)
107
- * @param options - Export options
101
+ * Validation + diff phase of `exportCommand`: checks flag combinations and
102
+ * the engine checkout, collects the export file set (honouring
103
+ * `--exclude-furnace`), generates the diff, auto-fixes license headers,
104
+ * and prompts for patch metadata. Returns `null` when the operator
105
+ * cancelled the metadata prompt (the command ends silently, matching the
106
+ * prompt's own cancel handling).
108
107
  */
109
- // The command body is intentionally linear: validation → diff → placement
110
- // gate → dry-run/placement/default write. Splitting it further would
111
- // spread the error-handling (spinner.error, try/catch) across multiple
112
- // helpers and hurt readability more than it would help.
113
- // eslint-disable-next-line max-lines-per-function
114
- export async function exportCommand(projectRoot, files, options) {
115
- const isDryRun = options.dryRun === true;
116
- intro(isDryRun ? 'FireForge Export (dry run)' : 'FireForge Export');
108
+ async function prepareExport(projectRoot, files, options) {
117
109
  // Placement flags are mutually exclusive with each other.
118
110
  const placementFlagCount = [
119
111
  options.order !== undefined,
@@ -170,7 +162,23 @@ export async function exportCommand(projectRoot, files, options) {
170
162
  }
171
163
  const metadata = await promptExportPatchMetadata(options, isInteractive, 'export', config);
172
164
  if (!metadata)
165
+ return null;
166
+ return { paths, placementFlagCount, diff, config, isInteractive, metadata };
167
+ }
168
+ /**
169
+ * Runs the export command to export file changes as a patch.
170
+ * Accepts one or more file/directory paths and bundles them into a single patch.
171
+ * @param projectRoot - Root directory of the project
172
+ * @param files - File or directory paths to export (relative to engine/)
173
+ * @param options - Export options
174
+ */
175
+ export async function exportCommand(projectRoot, files, options) {
176
+ const isDryRun = options.dryRun === true;
177
+ intro(isDryRun ? 'FireForge Export (dry run)' : 'FireForge Export');
178
+ const prepared = await prepareExport(projectRoot, files, options);
179
+ if (!prepared)
173
180
  return;
181
+ const { paths, placementFlagCount, diff, config, isInteractive, metadata } = prepared;
174
182
  const { patchName, selectedCategory, description } = metadata;
175
183
  const s = spinner(isDryRun ? 'Planning export...' : 'Exporting patch...');
176
184
  try {
@@ -189,71 +197,21 @@ export async function exportCommand(projectRoot, files, options) {
189
197
  // exclusive with supersede — the semantics overlap confusingly.
190
198
  let placementPlan = null;
191
199
  if (placementFlagCount > 0) {
192
- if (options.supersede) {
193
- throw new InvalidArgumentError('Placement flags (--order/--before/--after) cannot be combined with --supersede.', 'export placement');
194
- }
195
- placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
196
- const currentManifest = await loadPatchesManifest(paths.patches);
197
- if (currentManifest !== null) {
198
- assertPlacementPreservesReservedRanges(placementPlan, currentManifest.patches, config, selectedCategory);
199
- }
200
- const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
201
- const renamed = currentManifest !== null
202
- ? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
203
- : buildProjectedManifest(null, []);
204
- enforcePatchPolicy({
200
+ const gated = await gatePlacementPlan({
201
+ patchesDir: paths.patches,
202
+ options,
203
+ selectedCategory,
204
+ patchName,
205
+ description,
206
+ filesAffected,
207
+ diff,
205
208
  config,
206
- manifest: buildProjectedManifest(renamed, [
207
- ...renamed.patches,
208
- {
209
- filename: placementPlan.newFilename,
210
- order: placementPlan.insertionOrder,
211
- category: selectedCategory,
212
- name: patchName,
213
- description,
214
- createdAt: new Date().toISOString(),
215
- ...buildPatchSourceMetadata(config.firefox),
216
- filesAffected,
217
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
218
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
219
- ? { lintIgnore: options.lintIgnore }
220
- : {}),
221
- },
222
- ]),
223
- command: 'export',
224
- forceUnsafe: options.forceUnsafe === true,
209
+ isDryRun,
210
+ s,
225
211
  });
226
- const summary = placementSummary(placementPlan);
227
- const renameCount = placementPlan.renameMap.size;
228
- // Route through confirmDestructive when the operation is destructive
229
- // enough to warrant a prompt (more than one rename) OR when the user
230
- // asked for a dry-run. The dry-run branch must always print the
231
- // placement summary — previously, single-rename/no-rename dry-runs
232
- // exited silently with no filename or projected layout.
233
- if (renameCount > 1 || isDryRun) {
234
- s.stop();
235
- const decision = await confirmDestructive({
236
- operation: 'export-order',
237
- title: `Export with placement at order ${placementPlan.insertionOrder}`,
238
- summary,
239
- yes: options.yes === true,
240
- dryRun: isDryRun,
241
- unsafeOverride: options.forceUnsafe === true,
242
- conflicts,
243
- });
244
- if (decision === 'dry-run') {
245
- outro('Dry run complete — no changes made');
246
- return;
247
- }
248
- if (decision === 'cancelled') {
249
- outro('Export cancelled');
250
- return;
251
- }
252
- }
253
- else if (conflicts && options.forceUnsafe !== true) {
254
- s.stop();
255
- throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
256
- }
212
+ if (gated === 'stop')
213
+ return;
214
+ placementPlan = gated;
257
215
  }
258
216
  // Dry-run path: compute the plan and print it, never write.
259
217
  if (isDryRun && !placementPlan) {
@@ -267,10 +225,7 @@ export async function exportCommand(projectRoot, files, options) {
267
225
  ...buildPatchSourceMetadata(config.firefox),
268
226
  explicitSupersede: options.supersede === true,
269
227
  allowOverlap: options.allowOverlap === true,
270
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
271
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
272
- ? { lintIgnore: options.lintIgnore }
273
- : {}),
228
+ ...patchMetadataExtras(options),
274
229
  config,
275
230
  forceUnsafe: options.forceUnsafe === true,
276
231
  });
@@ -291,10 +246,7 @@ export async function exportCommand(projectRoot, files, options) {
291
246
  createdAt: new Date().toISOString(),
292
247
  ...buildPatchSourceMetadata(config.firefox),
293
248
  filesAffected,
294
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
295
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
296
- ? { lintIgnore: options.lintIgnore }
297
- : {}),
249
+ ...patchMetadataExtras(options),
298
250
  };
299
251
  const committedPlan = await commitPlacementExport({
300
252
  patchesDir: paths.patches,
@@ -336,29 +288,15 @@ export async function exportCommand(projectRoot, files, options) {
336
288
  return;
337
289
  }
338
290
  // Default (no dry-run, no placement) path: the pre-existing behavior.
339
- // Check how many existing patches would be superseded
340
- const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
341
- if (!shouldProceed)
342
- return;
343
- // Overlap gate: pre-0.16.0 `export` only caught FULL-coverage
344
- // supersedes, so a second export targeting a shared file like
345
- // `browser/themes/shared/jar.inc.mn` happily created a queue where
346
- // two patches both listed the same file in `filesAffected`. `verify`
347
- // then failed immediately on "cross-patch filesAffected conflicts".
348
- // `confirmSupersedePatches` might already have confirmed full
349
- // supersedes above; pass their filenames through so we do not flag
350
- // a file claimed by a patch that is about to be removed.
351
- const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
352
- const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
353
- const shouldProceedPastOverlap = await guardOwnershipOverlap({
291
+ const shouldProceedPastGates = await runSupersedeAndOverlapGates({
354
292
  patchesDir: paths.patches,
355
293
  filesAffected,
356
- supersedingFilenames,
294
+ supersede: options.supersede,
357
295
  allowOverlap: options.allowOverlap === true,
358
296
  isInteractive,
359
297
  s,
360
298
  });
361
- if (!shouldProceedPastOverlap)
299
+ if (!shouldProceedPastGates)
362
300
  return;
363
301
  const { patchFilename, superseded } = await commitExportedPatch({
364
302
  patchesDir: paths.patches,
@@ -368,10 +306,7 @@ export async function exportCommand(projectRoot, files, options) {
368
306
  diff,
369
307
  filesAffected,
370
308
  ...buildPatchSourceMetadata(config.firefox),
371
- ...(options.tier !== undefined ? { tier: options.tier } : {}),
372
- ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
373
- ? { lintIgnore: options.lintIgnore }
374
- : {}),
309
+ ...patchMetadataExtras(options),
375
310
  config,
376
311
  policyCommand: 'export',
377
312
  forceUnsafe: options.forceUnsafe === true,
@@ -7,19 +7,6 @@
7
7
  * upstream Firefox (browser.xhtml, privatebrowsing/aboutPrivateBrowsing.html,
8
8
  * etc.) minus the fork-specific wiring. A fork author fills in the body.
9
9
  */
10
- /**
11
- * Sentinel attribute emitted on every `furnace chrome-doc create`-scaffolded
12
- * root element. Platform modules (`DevToolsStartup`, `PageActions`,
13
- * `SessionStore`, `DownloadsButton`, …) that observe
14
- * `browser-delayed-startup-finished` and walk INTO the window assume the
15
- * `browser.xhtml` DOM and throw on anything else. A fork-authored patch
16
- * to such a module can use `hasAttribute(...)` against this sentinel as
17
- * a cheap, fork-neutral guard to skip the walk on a custom chrome doc.
18
- *
19
- * Exposed as a named constant so test code and external checks can
20
- * reference the exact attribute name without hardcoding the string.
21
- */
22
- export declare const FURNACE_CHROME_DOC_SENTINEL = "data-furnace-chrome-doc";
23
10
  /**
24
11
  * XHTML shell for a top-level chrome document.
25
12
  *
@@ -20,7 +20,7 @@
20
20
  * Exposed as a named constant so test code and external checks can
21
21
  * reference the exact attribute name without hardcoding the string.
22
22
  */
23
- export const FURNACE_CHROME_DOC_SENTINEL = 'data-furnace-chrome-doc';
23
+ const FURNACE_CHROME_DOC_SENTINEL = 'data-furnace-chrome-doc';
24
24
  /**
25
25
  * XHTML shell for a top-level chrome document.
26
26
  *