@hominis/fireforge 0.21.0 → 0.21.2

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 (44) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +41 -0
  3. package/dist/src/commands/config.js +5 -0
  4. package/dist/src/commands/export-all.js +10 -6
  5. package/dist/src/commands/export-flow.d.ts +10 -0
  6. package/dist/src/commands/export-flow.js +50 -2
  7. package/dist/src/commands/export-shared.d.ts +1 -1
  8. package/dist/src/commands/export-shared.js +12 -13
  9. package/dist/src/commands/export.js +40 -4
  10. package/dist/src/commands/furnace/create-templates.js +10 -3
  11. package/dist/src/commands/furnace/create.js +1 -0
  12. package/dist/src/commands/furnace/deploy.js +1 -1
  13. package/dist/src/commands/furnace/validation-output.d.ts +2 -2
  14. package/dist/src/commands/furnace/validation-output.js +20 -4
  15. package/dist/src/commands/lint.js +9 -0
  16. package/dist/src/commands/patch/rename.js +40 -9
  17. package/dist/src/commands/patch/reorder.js +17 -3
  18. package/dist/src/commands/re-export-files.js +16 -1
  19. package/dist/src/commands/re-export.js +21 -10
  20. package/dist/src/commands/verify.js +15 -1
  21. package/dist/src/core/config-paths.d.ts +2 -2
  22. package/dist/src/core/config-paths.js +2 -0
  23. package/dist/src/core/config-validate-patch-policy.d.ts +7 -0
  24. package/dist/src/core/config-validate-patch-policy.js +176 -0
  25. package/dist/src/core/config-validate.js +6 -0
  26. package/dist/src/core/furnace-config-order.d.ts +7 -0
  27. package/dist/src/core/furnace-config-order.js +86 -0
  28. package/dist/src/core/furnace-config.js +13 -1
  29. package/dist/src/core/furnace-validate.js +3 -0
  30. package/dist/src/core/patch-export-coverage.d.ts +58 -0
  31. package/dist/src/core/patch-export-coverage.js +103 -0
  32. package/dist/src/core/patch-export-metadata.d.ts +36 -0
  33. package/dist/src/core/patch-export-metadata.js +69 -0
  34. package/dist/src/core/patch-export-update.d.ts +20 -0
  35. package/dist/src/core/patch-export-update.js +67 -0
  36. package/dist/src/core/patch-export.d.ts +13 -153
  37. package/dist/src/core/patch-export.js +23 -262
  38. package/dist/src/core/patch-manifest-validate.js +2 -2
  39. package/dist/src/core/patch-policy.d.ts +47 -0
  40. package/dist/src/core/patch-policy.js +350 -0
  41. package/dist/src/types/commands/options.d.ts +2 -0
  42. package/dist/src/types/commands/patches.d.ts +1 -1
  43. package/dist/src/types/config.d.ts +51 -0
  44. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,9 +6,11 @@
6
6
 
7
7
  - **Chrome-doc previews and cleanup.** `furnace chrome-doc create` now supports `--dry-run`, validating the same target files and jar registrations without writing. New `furnace chrome-doc remove <name>` removes scaffolded chrome-doc files, jar entries, and optional xpcshell packaging-test directories, with `--dry-run` and `--yes` support.
8
8
  - **Versioned `status --json` schema.** The JSON output is now an object with `schemaVersion`, `summary`, and `files` instead of a bare array. Error paths also emit versioned JSON objects with `code` and `error`.
9
+ - **Configurable patch queue policy.** Projects can now opt into `fireforge.json#patchPolicy` to define category-owned numeric ranges, reserved exception ranges, filename capture patterns, description requirements, gap policy, and mutation enforcement mode. FireForge enforces the policy during export/re-export/reorder/rename projections and reports the same findings from `verify` and `lint --per-patch`.
9
10
 
10
11
  ### Hardening
11
12
 
13
+ - **Eval 0.21.0 release-gate fixes.** `export --dry-run` now performs the same supersede and cross-patch ownership checks as real export before calling a plan safe; `furnace deploy --dry-run` validates successful custom-component plans against projected jar.mn registrations; generated Furnace components and browser-chrome test scaffolds are strict-checkJs and lazy-custom-element ready; chrome-doc packaging xpcshell tests no longer trip component-orphan validation; supported optional config keys such as `firefox.sha256` print `(not set)` when absent; and Furnace manifest writes preserve existing top-level/component ordering while appending new entries predictably.
12
14
  - **Override removal demotes back to stock.** Removing a Furnace override restores engine files, deletes the override workspace, clears override checksums, and re-adds the component to `stock` tracking instead of dropping it from `furnace.json`. Optional Furnace config fields, including `platformPrefixes`, are preserved across the write.
13
15
  - **Rename updates browser-chrome test bodies.** `furnace rename` now rewrites generated browser-chrome mochitest contents as well as filenames and `browser.toml`, preventing stale `waitForElement("<old>")` references after a component rename.
14
16
  - **UI build preflight is stricter.** `fireforge build --ui` now refuses before `mach build faster` when the current objdir lacks a completed launchable bundle, guiding fresh imports and partial builds through a full `fireforge build` first.
package/README.md CHANGED
@@ -107,6 +107,47 @@ patches/
107
107
 
108
108
  The category system is intentionally broad. The numeric ordering provides sequencing.
109
109
 
110
+ Projects that need stricter queue semantics can add an optional `patchPolicy` block to
111
+ `fireforge.json`. When present, FireForge checks the policy before mutating the patch queue and
112
+ reports the same policy findings from `fireforge verify` and `fireforge lint --per-patch`.
113
+
114
+ ```json
115
+ {
116
+ "patchPolicy": {
117
+ "filenamePattern": "^(?<order>\\d{3})-(?<category>branding|infra|ui)-(?<slug>[a-z0-9-]+)\\.patch$",
118
+ "requireDescription": true,
119
+ "allowGaps": true,
120
+ "mutationMode": "error",
121
+ "ranges": [
122
+ { "from": 1, "to": 99, "category": "branding" },
123
+ { "from": 100, "to": 199, "category": "infra" },
124
+ { "from": 200, "to": 299, "category": "ui" }
125
+ ],
126
+ "reservedRanges": [
127
+ {
128
+ "from": 900,
129
+ "to": 999,
130
+ "allowed": [
131
+ {
132
+ "filename": "900-infra-bootstrap-workaround.patch",
133
+ "files": ["tools/profiler/rust-api/build.rs"],
134
+ "adr": "docs/architecture/adr/0001-bootstrap-workaround.md"
135
+ }
136
+ ]
137
+ }
138
+ ]
139
+ }
140
+ }
141
+ ```
142
+
143
+ Policy ranges are category-owned: a `ui` patch in the example must use `200-299`, while
144
+ `900-999` is reserved for exact allowlisted exceptions. Reserved exceptions must include either
145
+ `adr` or `documentation`; when `files` is present, the patch may not touch paths outside that
146
+ allowlist. `filenamePattern` must expose named captures `order`, `category`, and `slug`.
147
+ `mutationMode` controls mutating commands: `"error"` refuses, `"warn"` prints warnings and
148
+ continues, and `"force"` refuses unless the command supports and receives `--force-unsafe`.
149
+ Without `patchPolicy`, existing repositories keep the broad category and numeric ordering behavior.
150
+
110
151
  ### Importing patches
111
152
 
112
153
  ```bash
@@ -99,6 +99,11 @@ export async function configCommand(projectRoot, key, value, options = {}) {
99
99
  const rawConfig = await loadRawConfigDocument(projectRoot);
100
100
  const currentValue = getNestedValue(rawConfig, key);
101
101
  if (currentValue === undefined) {
102
+ if (SUPPORTED_CONFIG_PATHS.includes(key)) {
103
+ info(`${key} = ${formatValue(currentValue)}`);
104
+ outro('');
105
+ return;
106
+ }
102
107
  throw new InvalidArgumentError(`Unknown config key: ${key}`);
103
108
  }
104
109
  else {
@@ -1,5 +1,3 @@
1
- // SPDX-License-Identifier: EUPL-1.2
2
- import { Option } from 'commander';
3
1
  import { isBrandingManagedPath } from '../core/branding.js';
4
2
  import { getProjectPaths, loadConfig } from '../core/config.js';
5
3
  import { collectFurnaceManagedPrefixes, furnaceConfigExists, loadFurnaceConfig, } from '../core/furnace-config.js';
@@ -14,7 +12,6 @@ import { GeneralError } from '../errors/base.js';
14
12
  import { ensureDir, pathExists } from '../utils/fs.js';
15
13
  import { info, intro, outro, spinner } from '../utils/logger.js';
16
14
  import { pickDefined } from '../utils/options.js';
17
- import { PATCH_CATEGORIES } from '../utils/validation.js';
18
15
  import { renderDryRunPreview } from './export-flow.js';
19
16
  import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
20
17
  async function checkBrandingManagedFiles(paths, config) {
@@ -235,7 +232,7 @@ export async function exportAllCommand(projectRoot, options = {}) {
235
232
  if (headersAdded) {
236
233
  diff = await getAllDiff(paths.engine);
237
234
  }
238
- const metadata = await promptExportPatchMetadata(options, isInteractive, 'export-all');
235
+ const metadata = await promptExportPatchMetadata(options, isInteractive, 'export-all', config);
239
236
  if (!metadata)
240
237
  return;
241
238
  const { patchName, selectedCategory, description } = metadata;
@@ -267,6 +264,9 @@ export async function exportAllCommand(projectRoot, options = {}) {
267
264
  filesAffected,
268
265
  sourceEsrVersion: config.firefox.version,
269
266
  explicitSupersede: options.supersede === true,
267
+ allowOverlap: options.allowOverlap === true,
268
+ config,
269
+ forceUnsafe: options.forceUnsafe === true,
270
270
  });
271
271
  outro('Dry run complete — no changes made');
272
272
  return;
@@ -300,6 +300,9 @@ export async function exportAllCommand(projectRoot, options = {}) {
300
300
  diff,
301
301
  filesAffected,
302
302
  sourceEsrVersion: config.firefox.version,
303
+ config,
304
+ policyCommand: 'export-all',
305
+ forceUnsafe: options.forceUnsafe === true,
303
306
  });
304
307
  for (const oldPatch of superseded) {
305
308
  info(`Superseded: ${oldPatch.filename}`);
@@ -322,18 +325,19 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
322
325
  .command('export-all')
323
326
  .description('Export all changes as a patch')
324
327
  .option('--name <name>', 'Name for the patch')
325
- .addOption(new Option('-c, --category <category>', 'Patch category').choices([...PATCH_CATEGORIES]))
328
+ .option('-c, --category <category>', 'Patch category')
326
329
  .option('-d, --description <desc>', 'Description of the patch')
327
330
  .option('--supersede', 'Allow superseding multiple existing patches')
328
331
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
329
332
  .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.')
330
333
  .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap with non-superseded patches (the resulting queue fails verify). Does not bypass the new-file creation guard — two patches creating the same path is structurally unrecoverable, so that case still refuses regardless of this flag.')
331
334
  .option('--dry-run', 'Print the export-all plan (filename, metadata, files affected, supersede preview) without writing anything to patches/. Lint still runs so the operator sees the same lint output a real run would produce.')
335
+ .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
332
336
  .action(withErrorHandling(async (options) => {
333
337
  const { category, ...rest } = options;
334
338
  await exportAllCommand(getProjectRoot(), {
335
339
  ...pickDefined(rest),
336
- ...(category !== undefined ? { category: category } : {}),
340
+ ...(category !== undefined ? { category } : {}),
337
341
  });
338
342
  }));
339
343
  }
@@ -9,6 +9,7 @@
9
9
  import { type ConflictReport } from '../core/destructive.js';
10
10
  import { type PatchRenameEntry } from '../core/patch-manifest.js';
11
11
  import type { ExportOptions, PatchCategory, PatchMetadata } from '../types/commands/index.js';
12
+ import type { FireForgeConfig } from '../types/config.js';
12
13
  /**
13
14
  * Shape for the rename map computed when a placement flag forces existing
14
15
  * patches to move out of the new slot. Keys are current filenames.
@@ -53,6 +54,10 @@ export interface CommitPlacementExportInput {
53
54
  metadata: PatchMetadata;
54
55
  expectedPlan: PlacementPlan;
55
56
  unsafeOverride?: boolean;
57
+ /** Project config, used only when opt-in patchPolicy is present. */
58
+ config?: FireForgeConfig;
59
+ /** Whether --force-unsafe was supplied by the mutating command. */
60
+ forceUnsafe?: boolean;
56
61
  /**
57
62
  * Optional post-commit hook that runs inside the patch directory lock,
58
63
  * after the mutation has succeeded but before the lock is released.
@@ -82,10 +87,15 @@ export interface DryRunPreviewInput {
82
87
  filesAffected: string[];
83
88
  sourceEsrVersion: string;
84
89
  explicitSupersede: boolean;
90
+ allowOverlap: boolean;
85
91
  /** Optional `PatchMetadata.tier` opt-in carried from the CLI. */
86
92
  tier?: 'branding';
87
93
  /** Optional `PatchMetadata.lintIgnore` carried from the CLI. */
88
94
  lintIgnore?: string[];
95
+ /** Project config, used only when opt-in patchPolicy is present. */
96
+ config?: FireForgeConfig;
97
+ /** Whether --force-unsafe was supplied by the mutating command. */
98
+ forceUnsafe?: boolean;
89
99
  }
90
100
  /**
91
101
  * Renders the plain (non-placement) dry-run preview: calls planExport,
@@ -12,11 +12,13 @@ import { findAllPatchesForFilesWithDetails, planExport, sanitizeName, } from '..
12
12
  import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
13
13
  import { withPatchDirectoryLock } from '../core/patch-lock.js';
14
14
  import { addPatchToManifest, loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, } from '../core/patch-manifest.js';
15
+ import { applyRenameMapToManifest, buildProjectedManifest, enforcePatchPolicy, } from '../core/patch-policy.js';
15
16
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
16
- import { InvalidArgumentError } from '../errors/base.js';
17
+ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
18
  import { toError } from '../utils/errors.js';
18
19
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
19
20
  import { info, warn } from '../utils/logger.js';
21
+ import { findPartialOwnershipOverlap } from './export-shared.js';
20
22
  function buildFilenameForPlacement(category, name, order, width) {
21
23
  const padded = String(order).padStart(Math.max(3, width), '0');
22
24
  return `${padded}-${category}-${sanitizeName(name)}.patch`;
@@ -199,13 +201,31 @@ export async function commitPlacementExport(input) {
199
201
  if (conflicts && input.unsafeOverride !== true) {
200
202
  throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
201
203
  }
204
+ const originalManifest = await loadPatchesManifest(input.patchesDir);
205
+ if (input.config !== undefined) {
206
+ const renamed = originalManifest !== null
207
+ ? applyRenameMapToManifest(originalManifest, currentPlan.renameMap)
208
+ : buildProjectedManifest(null, []);
209
+ enforcePatchPolicy({
210
+ config: input.config,
211
+ manifest: buildProjectedManifest(renamed, [
212
+ ...renamed.patches,
213
+ {
214
+ ...input.metadata,
215
+ filename: currentPlan.newFilename,
216
+ order: currentPlan.insertionOrder,
217
+ },
218
+ ]),
219
+ command: 'export',
220
+ forceUnsafe: input.forceUnsafe === true,
221
+ });
222
+ }
202
223
  // Snapshot pre-mutation state so we can best-effort restore the queue
203
224
  // if any of the three steps below fail mid-flight. Mirrors the
204
225
  // rollback shape in commitExportedPatch (src/core/patch-export.ts), but
205
226
  // inlined because the two rollbacks operate on different state shapes
206
227
  // (rename map vs. supersede set) and sharing a helper would be forced.
207
228
  const patchPath = join(input.patchesDir, currentPlan.newFilename);
208
- const originalManifest = await loadPatchesManifest(input.patchesDir);
209
229
  const originalNewPatchContent = (await pathExists(patchPath))
210
230
  ? await readText(patchPath)
211
231
  : null;
@@ -296,6 +316,11 @@ export async function commitPlacementExport(input) {
296
316
  */
297
317
  export async function renderDryRunPreview(input) {
298
318
  const supersedeDetails = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected);
319
+ const supersedingFilenames = new Set(supersedeDetails.map((detail) => detail.patch.filename));
320
+ const manifest = await loadPatchesManifest(input.patchesDir);
321
+ const overlap = manifest !== null
322
+ ? findPartialOwnershipOverlap(manifest, input.filesAffected, supersedingFilenames)
323
+ : new Map();
299
324
  const plan = await planExport({
300
325
  patchesDir: input.patchesDir,
301
326
  category: input.category,
@@ -305,7 +330,16 @@ export async function renderDryRunPreview(input) {
305
330
  sourceEsrVersion: input.sourceEsrVersion,
306
331
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
307
332
  ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
333
+ ...(input.config !== undefined ? { config: input.config } : {}),
308
334
  });
335
+ if (input.config !== undefined) {
336
+ enforcePatchPolicy({
337
+ config: input.config,
338
+ manifest: plan.manifestAfter,
339
+ command: 'export',
340
+ forceUnsafe: input.forceUnsafe === true,
341
+ });
342
+ }
309
343
  info(`\n[dry-run] Would write: patches/${plan.patchFilename}`);
310
344
  info(` category: ${plan.metadata.category}`);
311
345
  info(` order: ${plan.metadata.order}`);
@@ -329,5 +363,19 @@ export async function renderDryRunPreview(input) {
329
363
  else {
330
364
  info('\n[dry-run] No patches would be superseded.');
331
365
  }
366
+ if (overlap.size > 0) {
367
+ const entries = [...overlap.entries()].sort(([a], [b]) => a.localeCompare(b));
368
+ warn(`\n[dry-run] Would create cross-patch ownership overlap on ${String(entries.length)} file${entries.length === 1 ? '' : 's'}:`);
369
+ for (const [file, owners] of entries) {
370
+ warn(` - ${file} already claimed by: ${owners.join(', ')}`);
371
+ }
372
+ warn('The real export would leave the queue verify-failing. Repartition ownership with `fireforge re-export --files <paths> <existing-patch>` before exporting, or pass --allow-overlap to acknowledge the conflict.');
373
+ if (!input.allowOverlap) {
374
+ throw new GeneralError('Dry-run detected cross-patch ownership overlap. Pass --allow-overlap to preview the acknowledged conflict, or repartition ownership via `fireforge re-export --files`.');
375
+ }
376
+ }
377
+ else {
378
+ info('[dry-run] No cross-patch ownership overlap detected.');
379
+ }
332
380
  }
333
381
  //# sourceMappingURL=export-flow.js.map
@@ -31,7 +31,7 @@ export declare function runPatchLint(engineDir: string, filesAffected: string[],
31
31
  * @param isInteractive - Whether interactive prompts are allowed
32
32
  * @param commandName - Command name for error/help text
33
33
  */
34
- export declare function promptExportPatchMetadata(options: ExportOptions, isInteractive: boolean, commandName: 'export' | 'export-all'): Promise<{
34
+ export declare function promptExportPatchMetadata(options: ExportOptions, isInteractive: boolean, commandName: 'export' | 'export-all', config?: FireForgeConfig): Promise<{
35
35
  patchName: string;
36
36
  selectedCategory: PatchCategory;
37
37
  description: string;
@@ -5,10 +5,11 @@ import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-header
5
5
  import { findAllPatchesForFiles } from '../core/patch-export.js';
6
6
  import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
7
7
  import { loadPatchesManifest } from '../core/patch-manifest.js';
8
+ import { getPatchPolicyCategories, isCategoryAllowedByConfig } from '../core/patch-policy.js';
8
9
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
9
10
  import { pathExists, readText } from '../utils/fs.js';
10
11
  import { cancel, info, isCancel, warn } from '../utils/logger.js';
11
- import { isValidPatchCategory, PATCH_CATEGORIES, validatePatchName } from '../utils/validation.js';
12
+ import { PATCH_CATEGORIES, validatePatchName } from '../utils/validation.js';
12
13
  /**
13
14
  * Runs the full patch lint pipeline and reports results.
14
15
  * Warnings are always displayed. Errors block the export unless skipLint is true.
@@ -89,7 +90,8 @@ export async function runPatchLint(engineDir, filesAffected, diffContent, config
89
90
  * @param isInteractive - Whether interactive prompts are allowed
90
91
  * @param commandName - Command name for error/help text
91
92
  */
92
- export async function promptExportPatchMetadata(options, isInteractive, commandName) {
93
+ export async function promptExportPatchMetadata(options, isInteractive, commandName, config) {
94
+ const categories = config !== undefined ? getPatchPolicyCategories(config) : [...PATCH_CATEGORIES];
93
95
  let patchName = options.name;
94
96
  if (patchName) {
95
97
  const validationError = validatePatchName(patchName);
@@ -98,7 +100,7 @@ export async function promptExportPatchMetadata(options, isInteractive, commandN
98
100
  }
99
101
  }
100
102
  if (!patchName && !isInteractive) {
101
- throw new InvalidArgumentError('The --name flag is required in non-interactive mode', `Use: fireforge ${commandName} ${commandName === 'export' ? '<paths...> ' : ''}--name "my-patch-name" --category ui`);
103
+ throw new InvalidArgumentError('The --name flag is required in non-interactive mode', `Use: fireforge ${commandName} ${commandName === 'export' ? '<paths...> ' : ''}--name "my-patch-name" --category ${categories[0] ?? 'ui'}`);
102
104
  }
103
105
  if (!patchName) {
104
106
  const nameResult = await text({
@@ -114,23 +116,20 @@ export async function promptExportPatchMetadata(options, isInteractive, commandN
114
116
  }
115
117
  let category = options.category;
116
118
  if (category) {
117
- if (!isValidPatchCategory(category)) {
118
- throw new InvalidArgumentError(`Invalid category. Must be one of: ${PATCH_CATEGORIES.join(', ')}`, '--category');
119
+ const isAllowed = config !== undefined
120
+ ? isCategoryAllowedByConfig(config, category)
121
+ : PATCH_CATEGORIES.includes(category);
122
+ if (!isAllowed) {
123
+ throw new InvalidArgumentError(`Invalid category. Must be one of: ${categories.join(', ')}`, '--category');
119
124
  }
120
125
  }
121
126
  else if (!isInteractive) {
122
- throw new InvalidArgumentError('The --category flag is required in non-interactive mode', `Use: fireforge ${commandName} ${commandName === 'export' ? '<paths...> ' : ''}--name "name" --category <${PATCH_CATEGORIES.join('|')}>`);
127
+ throw new InvalidArgumentError('The --category flag is required in non-interactive mode', `Use: fireforge ${commandName} ${commandName === 'export' ? '<paths...> ' : ''}--name "name" --category <${categories.join('|')}>`);
123
128
  }
124
129
  else {
125
130
  const categoryResult = await select({
126
131
  message: 'Select a category for this patch:',
127
- options: [
128
- { value: 'branding', label: 'branding - Logo, icons, names, about pages' },
129
- { value: 'ui', label: 'ui - User interface changes' },
130
- { value: 'privacy', label: 'privacy - Telemetry, tracking, data collection' },
131
- { value: 'security', label: 'security - Security hardening, policies' },
132
- { value: 'infra', label: 'infra - Build system, tooling, CI, configuration' },
133
- ],
132
+ options: categories.map((value) => ({ value, label: value })),
134
133
  });
135
134
  if (isCancel(categoryResult)) {
136
135
  cancel('Export cancelled');
@@ -11,13 +11,15 @@ 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
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';
14
16
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
15
17
  import { toError } from '../utils/errors.js';
16
18
  import { ensureDir, pathExists } from '../utils/fs.js';
17
19
  import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
18
20
  import { pickDefined } from '../utils/options.js';
19
21
  import { stripEnginePrefix } from '../utils/paths.js';
20
- import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
22
+ import { parsePositiveIntegerFlag } from '../utils/validation.js';
21
23
  import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
22
24
  import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
23
25
  async function collectExportFiles(paths, files) {
@@ -164,7 +166,7 @@ export async function exportCommand(projectRoot, files, options) {
164
166
  if (headersAdded) {
165
167
  diff = await generatePatchDiff(paths.engine, allFiles);
166
168
  }
167
- const metadata = await promptExportPatchMetadata(options, isInteractive, 'export');
169
+ const metadata = await promptExportPatchMetadata(options, isInteractive, 'export', config);
168
170
  if (!metadata)
169
171
  return;
170
172
  const { patchName, selectedCategory, description } = metadata;
@@ -190,6 +192,32 @@ export async function exportCommand(projectRoot, files, options) {
190
192
  }
191
193
  placementPlan = await resolvePlacementPlan(paths.patches, options, selectedCategory, patchName);
192
194
  const conflicts = await projectPlacementForLint(paths.patches, placementPlan, diff);
195
+ const currentManifest = await loadPatchesManifest(paths.patches);
196
+ const renamed = currentManifest !== null
197
+ ? applyRenameMapToManifest(currentManifest, placementPlan.renameMap)
198
+ : buildProjectedManifest(null, []);
199
+ enforcePatchPolicy({
200
+ config,
201
+ manifest: buildProjectedManifest(renamed, [
202
+ ...renamed.patches,
203
+ {
204
+ filename: placementPlan.newFilename,
205
+ order: placementPlan.insertionOrder,
206
+ category: selectedCategory,
207
+ name: patchName,
208
+ description,
209
+ createdAt: new Date().toISOString(),
210
+ sourceEsrVersion: config.firefox.version,
211
+ filesAffected,
212
+ ...(options.tier !== undefined ? { tier: options.tier } : {}),
213
+ ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
214
+ ? { lintIgnore: options.lintIgnore }
215
+ : {}),
216
+ },
217
+ ]),
218
+ command: 'export',
219
+ forceUnsafe: options.forceUnsafe === true,
220
+ });
193
221
  const summary = placementSummary(placementPlan);
194
222
  const renameCount = placementPlan.renameMap.size;
195
223
  // Route through confirmDestructive when the operation is destructive
@@ -233,10 +261,13 @@ export async function exportCommand(projectRoot, files, options) {
233
261
  filesAffected,
234
262
  sourceEsrVersion: config.firefox.version,
235
263
  explicitSupersede: options.supersede === true,
264
+ allowOverlap: options.allowOverlap === true,
236
265
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
237
266
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
238
267
  ? { lintIgnore: options.lintIgnore }
239
268
  : {}),
269
+ config,
270
+ forceUnsafe: options.forceUnsafe === true,
240
271
  });
241
272
  outro('Dry run complete — no changes made');
242
273
  return;
@@ -269,6 +300,8 @@ export async function exportCommand(projectRoot, files, options) {
269
300
  metadata: placementMetadata,
270
301
  expectedPlan: placementPlan,
271
302
  unsafeOverride: options.forceUnsafe === true,
303
+ config,
304
+ forceUnsafe: options.forceUnsafe === true,
272
305
  // History append runs inside the same lock as the mutation so
273
306
  // concurrent placement exports cannot interleave their records
274
307
  // and a crash between mutation and record cannot orphan the
@@ -334,6 +367,9 @@ export async function exportCommand(projectRoot, files, options) {
334
367
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
335
368
  ? { lintIgnore: options.lintIgnore }
336
369
  : {}),
370
+ config,
371
+ policyCommand: 'export',
372
+ forceUnsafe: options.forceUnsafe === true,
337
373
  });
338
374
  for (const oldPatch of superseded) {
339
375
  info(`Superseded: ${oldPatch.filename}`);
@@ -356,7 +392,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
356
392
  .command('export <paths...>')
357
393
  .description('Export new changes as a patch (use re-export to update existing patches)')
358
394
  .option('-n, --name <name>', 'Name for the patch')
359
- .addOption(new Option('-c, --category <category>', 'Patch category').choices([...PATCH_CATEGORIES]))
395
+ .option('-c, --category <category>', 'Patch category')
360
396
  .option('-d, --description <desc>', 'Description of the patch')
361
397
  .option('--supersede', 'Allow superseding multiple existing patches')
362
398
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
@@ -374,7 +410,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
374
410
  const { category, tier, lintIgnore, ...rest } = options;
375
411
  await exportCommand(getProjectRoot(), paths, {
376
412
  ...pickDefined(rest),
377
- ...(category !== undefined ? { category: category } : {}),
413
+ ...(category !== undefined ? { category } : {}),
378
414
  ...(tier !== undefined ? { tier: tier } : {}),
379
415
  ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
380
416
  });
@@ -39,12 +39,18 @@ window.MozXULElement?.insertFTLIfNeeded("${ftlPath}");
39
39
  ? `
40
40
  connectedCallback() {
41
41
  super.connectedCallback();
42
- this.ownerDocument.l10n?.connectRoot(this.shadowRoot);
42
+ const { shadowRoot } = this;
43
+ if (shadowRoot) {
44
+ this.ownerDocument.l10n?.connectRoot(shadowRoot);
45
+ }
43
46
  }
44
47
 
45
48
  disconnectedCallback() {
46
49
  super.disconnectedCallback();
47
- this.ownerDocument.l10n?.disconnectRoot(this.shadowRoot);
50
+ const { shadowRoot } = this;
51
+ if (shadowRoot) {
52
+ this.ownerDocument.l10n?.disconnectRoot(shadowRoot);
53
+ }
48
54
  }
49
55
  `
50
56
  : '';
@@ -59,6 +65,7 @@ ${ftlModulePreamble}
59
65
  * @tagname ${name}
60
66
  */
61
67
  class ${className} extends MozLitElement {
68
+ /** @type {Record<string, unknown>} */
62
69
  static properties = {};
63
70
 
64
71
  constructor() {
@@ -72,7 +79,7 @@ ${lifecycleHooks}
72
79
  \`;
73
80
  }
74
81
  }
75
- customElements.define("${name}", ${className});
82
+ customElements.define("${name}", /** @type {CustomElementConstructor} */ (${className}));
76
83
  `;
77
84
  }
78
85
  /** Generates the .css file content for a custom component. */
@@ -111,6 +111,7 @@ support-files = ["head.js"]
111
111
  * @returns {Promise<CustomElementConstructor>}
112
112
  */
113
113
  async function waitForElement(tag) {
114
+ document.createElement(tag);
114
115
  return customElements.whenDefined(tag);
115
116
  }
116
117
  `;
@@ -364,7 +364,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
364
364
  }
365
365
  const validateSpinner = spinner(isDryRun ? 'Validating (read-only)...' : 'Validating...');
366
366
  const failedComponents = getFailedComponentNames(result);
367
- const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot);
367
+ const validation = await runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, result.actions);
368
368
  if (validation.done)
369
369
  return;
370
370
  const { totalErrors, totalWarnings, componentCount, skippedValidationCount } = validation;
@@ -1,5 +1,5 @@
1
1
  import type { getFurnacePaths } from '../../core/furnace-config.js';
2
- import type { FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
2
+ import type { DryRunAction, FurnaceConfig, ValidationIssue } from '../../types/furnace.js';
3
3
  import { type SpinnerHandle } from '../../utils/logger.js';
4
4
  /**
5
5
  * Displays validation issues and returns aggregated error and warning counts.
@@ -27,4 +27,4 @@ export type ValidationResult = {
27
27
  * @param projectRoot - Root directory of the project
28
28
  * @returns Validation counts, or `done: true` if the caller should early-return
29
29
  */
30
- export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string): Promise<ValidationResult>;
30
+ export declare function runDeployValidation(validateSpinner: SpinnerHandle, name: string | undefined, config: FurnaceConfig, furnacePaths: ReturnType<typeof getFurnacePaths>, failedComponents: Set<string>, isDryRun: boolean, projectRoot: string, dryRunActions?: DryRunAction[]): Promise<ValidationResult>;
@@ -24,6 +24,18 @@ export function displayValidationIssues(issues) {
24
24
  }
25
25
  return [errors, warnings];
26
26
  }
27
+ function filterProjectedDryRunIssues(issues, actions) {
28
+ if (!actions || actions.length === 0)
29
+ return issues;
30
+ const plannedJarRegistrations = new Set(actions.filter((action) => action.action === 'register-jar').map((action) => action.component));
31
+ return issues.filter((issue) => {
32
+ if (plannedJarRegistrations.has(issue.component) &&
33
+ (issue.check === 'missing-jar-mn-mjs' || issue.check === 'missing-jar-mn-css')) {
34
+ return false;
35
+ }
36
+ return true;
37
+ });
38
+ }
27
39
  function resolveNamedValidationTarget(name, config, furnacePaths) {
28
40
  if (name in config.overrides) {
29
41
  return {
@@ -53,7 +65,7 @@ function resolveNamedValidationTarget(name, config, furnacePaths) {
53
65
  * @param projectRoot - Root directory of the project
54
66
  * @returns Validation counts, or `done: true` if the caller should early-return
55
67
  */
56
- export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot) {
68
+ export async function runDeployValidation(validateSpinner, name, config, furnacePaths, failedComponents, isDryRun, projectRoot, dryRunActions) {
57
69
  let totalErrors = 0;
58
70
  let totalWarnings = 0;
59
71
  let componentCount = 0;
@@ -75,7 +87,8 @@ export async function runDeployValidation(validateSpinner, name, config, furnace
75
87
  validateSpinner.stop('Validation failed');
76
88
  throw new FurnaceError(`Component directory not found for "${name}".`, name);
77
89
  }
78
- const issues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
90
+ const rawIssues = await validateComponent(target.componentDir, name, target.type, config, projectRoot);
91
+ const issues = isDryRun ? filterProjectedDryRunIssues(rawIssues, dryRunActions) : rawIssues;
79
92
  componentCount = 1;
80
93
  validateSpinner.stop('Validation complete');
81
94
  if (issues.length === 0) {
@@ -96,11 +109,14 @@ export async function runDeployValidation(validateSpinner, name, config, furnace
96
109
  continue;
97
110
  }
98
111
  componentCount++;
99
- if (issues.length === 0) {
112
+ const projectedIssues = isDryRun
113
+ ? filterProjectedDryRunIssues(issues, dryRunActions)
114
+ : issues;
115
+ if (projectedIssues.length === 0) {
100
116
  success(`${componentName} — all checks passed`);
101
117
  }
102
118
  else {
103
- const [errors, warnings] = displayValidationIssues(issues);
119
+ const [errors, warnings] = displayValidationIssues(projectedIssues);
104
120
  totalErrors += errors;
105
121
  totalWarnings += warnings;
106
122
  }
@@ -11,6 +11,7 @@ import { extractAffectedFiles } from '../core/patch-apply.js';
11
11
  import { buildPatchQueueContext, lintExportedPatch, lintPatchQueue, resolvePatchSizeTier, } from '../core/patch-lint.js';
12
12
  import { collectDiffFilePaths, tagLintIssues } from '../core/patch-lint-diff-tag.js';
13
13
  import { loadPatchesManifest } from '../core/patch-manifest.js';
14
+ import { evaluatePatchPolicy } from '../core/patch-policy.js';
14
15
  import { GeneralError } from '../errors/base.js';
15
16
  import { pathExists } from '../utils/fs.js';
16
17
  import { info, intro, outro, success, warn } from '../utils/logger.js';
@@ -407,6 +408,14 @@ async function lintPerPatch(projectRoot, paths) {
407
408
  const config = await loadConfig(projectRoot);
408
409
  const ctx = await buildPatchQueueContext(paths.patches);
409
410
  const issues = [];
411
+ for (const issue of evaluatePatchPolicy(config, manifest)) {
412
+ issues.push({
413
+ file: issue.filename,
414
+ check: `patch-policy/${issue.code}`,
415
+ message: issue.message,
416
+ severity: issue.severity,
417
+ });
418
+ }
410
419
  let linted = 0;
411
420
  let skipped = 0;
412
421
  for (const patch of manifest.patches) {