@hominis/fireforge 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +41 -0
  3. package/dist/src/commands/export-all.js +9 -6
  4. package/dist/src/commands/export-flow.d.ts +9 -0
  5. package/dist/src/commands/export-flow.js +29 -1
  6. package/dist/src/commands/export-shared.d.ts +1 -1
  7. package/dist/src/commands/export-shared.js +12 -13
  8. package/dist/src/commands/export.js +39 -4
  9. package/dist/src/commands/lint.js +9 -0
  10. package/dist/src/commands/patch/rename.js +40 -9
  11. package/dist/src/commands/patch/reorder.js +17 -3
  12. package/dist/src/commands/re-export-files.js +16 -1
  13. package/dist/src/commands/re-export.js +21 -10
  14. package/dist/src/commands/verify.js +15 -1
  15. package/dist/src/core/config-paths.d.ts +2 -2
  16. package/dist/src/core/config-paths.js +2 -0
  17. package/dist/src/core/config-validate-patch-policy.d.ts +7 -0
  18. package/dist/src/core/config-validate-patch-policy.js +176 -0
  19. package/dist/src/core/config-validate.js +6 -0
  20. package/dist/src/core/patch-export-coverage.d.ts +58 -0
  21. package/dist/src/core/patch-export-coverage.js +103 -0
  22. package/dist/src/core/patch-export-metadata.d.ts +36 -0
  23. package/dist/src/core/patch-export-metadata.js +69 -0
  24. package/dist/src/core/patch-export-update.d.ts +20 -0
  25. package/dist/src/core/patch-export-update.js +67 -0
  26. package/dist/src/core/patch-export.d.ts +13 -153
  27. package/dist/src/core/patch-export.js +23 -262
  28. package/dist/src/core/patch-manifest-validate.js +2 -2
  29. package/dist/src/core/patch-policy.d.ts +47 -0
  30. package/dist/src/core/patch-policy.js +350 -0
  31. package/dist/src/types/commands/options.d.ts +2 -0
  32. package/dist/src/types/commands/patches.d.ts +1 -1
  33. package/dist/src/types/config.d.ts +51 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,7 @@
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
 
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
@@ -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;
@@ -268,6 +265,8 @@ export async function exportAllCommand(projectRoot, options = {}) {
268
265
  sourceEsrVersion: config.firefox.version,
269
266
  explicitSupersede: options.supersede === true,
270
267
  allowOverlap: options.allowOverlap === true,
268
+ config,
269
+ forceUnsafe: options.forceUnsafe === true,
271
270
  });
272
271
  outro('Dry run complete — no changes made');
273
272
  return;
@@ -301,6 +300,9 @@ export async function exportAllCommand(projectRoot, options = {}) {
301
300
  diff,
302
301
  filesAffected,
303
302
  sourceEsrVersion: config.firefox.version,
303
+ config,
304
+ policyCommand: 'export-all',
305
+ forceUnsafe: options.forceUnsafe === true,
304
306
  });
305
307
  for (const oldPatch of superseded) {
306
308
  info(`Superseded: ${oldPatch.filename}`);
@@ -323,18 +325,19 @@ export function registerExportAll(program, { getProjectRoot, withErrorHandling }
323
325
  .command('export-all')
324
326
  .description('Export all changes as a patch')
325
327
  .option('--name <name>', 'Name for the patch')
326
- .addOption(new Option('-c, --category <category>', 'Patch category').choices([...PATCH_CATEGORIES]))
328
+ .option('-c, --category <category>', 'Patch category')
327
329
  .option('-d, --description <desc>', 'Description of the patch')
328
330
  .option('--supersede', 'Allow superseding multiple existing patches')
329
331
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
330
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.')
331
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.')
332
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')
333
336
  .action(withErrorHandling(async (options) => {
334
337
  const { category, ...rest } = options;
335
338
  await exportAllCommand(getProjectRoot(), {
336
339
  ...pickDefined(rest),
337
- ...(category !== undefined ? { category: category } : {}),
340
+ ...(category !== undefined ? { category } : {}),
338
341
  });
339
342
  }));
340
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.
@@ -87,6 +92,10 @@ export interface DryRunPreviewInput {
87
92
  tier?: 'branding';
88
93
  /** Optional `PatchMetadata.lintIgnore` carried from the CLI. */
89
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;
90
99
  }
91
100
  /**
92
101
  * Renders the plain (non-placement) dry-run preview: calls planExport,
@@ -12,6 +12,7 @@ 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
17
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
17
18
  import { toError } from '../utils/errors.js';
@@ -200,13 +201,31 @@ export async function commitPlacementExport(input) {
200
201
  if (conflicts && input.unsafeOverride !== true) {
201
202
  throw new InvalidArgumentError(`Refusing to run export: ${conflicts.reason}. Pass --force-unsafe to override.`, '--force-unsafe');
202
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
+ }
203
223
  // Snapshot pre-mutation state so we can best-effort restore the queue
204
224
  // if any of the three steps below fail mid-flight. Mirrors the
205
225
  // rollback shape in commitExportedPatch (src/core/patch-export.ts), but
206
226
  // inlined because the two rollbacks operate on different state shapes
207
227
  // (rename map vs. supersede set) and sharing a helper would be forced.
208
228
  const patchPath = join(input.patchesDir, currentPlan.newFilename);
209
- const originalManifest = await loadPatchesManifest(input.patchesDir);
210
229
  const originalNewPatchContent = (await pathExists(patchPath))
211
230
  ? await readText(patchPath)
212
231
  : null;
@@ -311,7 +330,16 @@ export async function renderDryRunPreview(input) {
311
330
  sourceEsrVersion: input.sourceEsrVersion,
312
331
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
313
332
  ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
333
+ ...(input.config !== undefined ? { config: input.config } : {}),
314
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
+ }
315
343
  info(`\n[dry-run] Would write: patches/${plan.patchFilename}`);
316
344
  info(` category: ${plan.metadata.category}`);
317
345
  info(` order: ${plan.metadata.order}`);
@@ -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
@@ -238,6 +266,8 @@ export async function exportCommand(projectRoot, files, options) {
238
266
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
239
267
  ? { lintIgnore: options.lintIgnore }
240
268
  : {}),
269
+ config,
270
+ forceUnsafe: options.forceUnsafe === true,
241
271
  });
242
272
  outro('Dry run complete — no changes made');
243
273
  return;
@@ -270,6 +300,8 @@ export async function exportCommand(projectRoot, files, options) {
270
300
  metadata: placementMetadata,
271
301
  expectedPlan: placementPlan,
272
302
  unsafeOverride: options.forceUnsafe === true,
303
+ config,
304
+ forceUnsafe: options.forceUnsafe === true,
273
305
  // History append runs inside the same lock as the mutation so
274
306
  // concurrent placement exports cannot interleave their records
275
307
  // and a crash between mutation and record cannot orphan the
@@ -335,6 +367,9 @@ export async function exportCommand(projectRoot, files, options) {
335
367
  ...(options.lintIgnore !== undefined && options.lintIgnore.length > 0
336
368
  ? { lintIgnore: options.lintIgnore }
337
369
  : {}),
370
+ config,
371
+ policyCommand: 'export',
372
+ forceUnsafe: options.forceUnsafe === true,
338
373
  });
339
374
  for (const oldPatch of superseded) {
340
375
  info(`Superseded: ${oldPatch.filename}`);
@@ -357,7 +392,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
357
392
  .command('export <paths...>')
358
393
  .description('Export new changes as a patch (use re-export to update existing patches)')
359
394
  .option('-n, --name <name>', 'Name for the patch')
360
- .addOption(new Option('-c, --category <category>', 'Patch category').choices([...PATCH_CATEGORIES]))
395
+ .option('-c, --category <category>', 'Patch category')
361
396
  .option('-d, --description <desc>', 'Description of the patch')
362
397
  .option('--supersede', 'Allow superseding multiple existing patches')
363
398
  .option('--skip-lint', 'Skip patch lint checks (downgrade errors to warnings)')
@@ -375,7 +410,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
375
410
  const { category, tier, lintIgnore, ...rest } = options;
376
411
  await exportCommand(getProjectRoot(), paths, {
377
412
  ...pickDefined(rest),
378
- ...(category !== undefined ? { category: category } : {}),
413
+ ...(category !== undefined ? { category } : {}),
379
414
  ...(tier !== undefined ? { tier: tier } : {}),
380
415
  ...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
381
416
  });
@@ -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) {
@@ -18,12 +18,13 @@
18
18
  */
19
19
  import { rename as fsRename } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
- import { getProjectPaths } from '../../core/config.js';
21
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
22
22
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
23
23
  import { sanitizeName } from '../../core/patch-export.js';
24
24
  import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
25
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
26
26
  import { loadPatchesManifest, resolvePatchIdentifier, savePatchesManifest, } from '../../core/patch-manifest.js';
27
+ import { buildProjectedManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
27
28
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
28
29
  import { toError } from '../../utils/errors.js';
29
30
  import { pathExists } from '../../utils/fs.js';
@@ -66,23 +67,40 @@ async function commitRenameUnderLock(input) {
66
67
  if (!before) {
67
68
  throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename.`);
68
69
  }
70
+ let oldPath;
71
+ let newPath;
69
72
  if (filenameChanging) {
70
73
  const collisionInLock = fresh.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
71
74
  if (collisionInLock) {
72
75
  throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch claimed that filename concurrently.`, 'patch rename');
73
76
  }
74
- const oldPath = join(patchesDir, target.filename);
75
- const newPath = join(patchesDir, newFilename);
77
+ oldPath = join(patchesDir, target.filename);
78
+ newPath = join(patchesDir, newFilename);
76
79
  if (await pathExists(newPath)) {
77
80
  throw new InvalidArgumentError(`Cannot rename: ${newFilename} already exists on disk. Resolve manually before retrying.`, 'patch rename');
78
81
  }
79
- await fsRename(oldPath, newPath);
80
82
  fresh.patches[idx] = {
81
83
  ...before,
82
84
  filename: newFilename,
83
85
  name: newName,
84
86
  ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
85
87
  };
88
+ }
89
+ else {
90
+ fresh.patches[idx] = {
91
+ ...before,
92
+ ...(nameChanging ? { name: newName } : {}),
93
+ ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
94
+ };
95
+ }
96
+ enforcePatchPolicy({
97
+ config: input.config,
98
+ manifest: buildProjectedManifest(fresh, fresh.patches),
99
+ command: 'patch rename',
100
+ forceUnsafe: input.forceUnsafe === true,
101
+ });
102
+ if (filenameChanging && oldPath !== undefined && newPath !== undefined) {
103
+ await fsRename(oldPath, newPath);
86
104
  try {
87
105
  await savePatchesManifest(patchesDir, fresh);
88
106
  }
@@ -97,11 +115,6 @@ async function commitRenameUnderLock(input) {
97
115
  }
98
116
  }
99
117
  else {
100
- fresh.patches[idx] = {
101
- ...before,
102
- ...(nameChanging ? { name: newName } : {}),
103
- ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
104
- };
105
118
  await savePatchesManifest(patchesDir, fresh);
106
119
  }
107
120
  try {
@@ -115,6 +128,7 @@ async function commitRenameUnderLock(input) {
115
128
  ...(descriptionChanging ? { oldDescription: target.description, newDescription } : {}),
116
129
  },
117
130
  ...(input.yes === true ? { yes: true } : {}),
131
+ ...(input.forceUnsafe === true ? { unsafeOverride: true } : {}),
118
132
  result: 'ok',
119
133
  });
120
134
  }
@@ -138,6 +152,7 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
138
152
  throw new InvalidArgumentError('Specify --to <new-name>. The new name is sanitised into the filename slug the same way `export --name` is.', 'patch rename');
139
153
  }
140
154
  const paths = getProjectPaths(projectRoot);
155
+ const config = await loadConfig(projectRoot);
141
156
  if (!(await pathExists(paths.patches))) {
142
157
  throw new GeneralError('Patches directory not found.');
143
158
  }
@@ -187,6 +202,19 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
187
202
  if (descriptionChanging) {
188
203
  summary.push(`description: "${target.description || '(none)'}" → "${options.description ?? '(none)'}"`);
189
204
  }
205
+ enforcePatchPolicy({
206
+ config,
207
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === target.filename
208
+ ? {
209
+ ...entry,
210
+ filename: newFilename,
211
+ name: nameChanging ? (options.to ?? entry.name) : entry.name,
212
+ ...(descriptionChanging ? { description: options.description ?? '' } : {}),
213
+ }
214
+ : entry)),
215
+ command: 'patch rename',
216
+ forceUnsafe: options.forceUnsafe === true,
217
+ });
190
218
  const decision = await confirmDestructive({
191
219
  operation: 'patch-rename',
192
220
  title: `Rename ${target.filename}`,
@@ -213,6 +241,8 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
213
241
  nameChanging,
214
242
  descriptionChanging,
215
243
  ...(options.yes === true ? { yes: true } : {}),
244
+ ...(options.forceUnsafe === true ? { forceUnsafe: true } : {}),
245
+ config,
216
246
  });
217
247
  if (filenameChanging) {
218
248
  info(`${target.filename} → ${newFilename}`);
@@ -237,6 +267,7 @@ export function registerPatchRename(parent, context) {
237
267
  .option('--description <text>', 'Replacement description (omit to leave description unchanged)')
238
268
  .option('--dry-run', 'Show what would change without writing')
239
269
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
270
+ .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
240
271
  .action(withErrorHandling(async (name, options) => {
241
272
  await patchRenameCommand(getProjectRoot(), name, pickDefined(options));
242
273
  }));
@@ -9,12 +9,13 @@
9
9
  * before any bytes move.
10
10
  */
11
11
  import { Option } from 'commander';
12
- import { getProjectPaths } from '../../core/config.js';
12
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
13
13
  import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
14
14
  import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
15
15
  import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
16
16
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
17
17
  import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
18
+ import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
18
19
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
19
20
  import { toError } from '../../utils/errors.js';
20
21
  import { pathExists } from '../../utils/fs.js';
@@ -184,7 +185,7 @@ function resolveDestination(target, manifestPatches, options) {
184
185
  }
185
186
  return { destinationOrder: anchor.order + 1, anchorFilename: anchor.filename };
186
187
  }
187
- async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, buildHistoryEntry) {
188
+ async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, config, buildHistoryEntry) {
188
189
  await withPatchDirectoryLock(patchesDir, async () => {
189
190
  const currentManifest = await loadPatchesManifest(patchesDir);
190
191
  if (!currentManifest || currentManifest.patches.length === 0) {
@@ -216,6 +217,12 @@ async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename,
216
217
  if (!renameMapsEqual(renameMap, currentRenameMap)) {
217
218
  throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder to recompute the rename plan.');
218
219
  }
220
+ enforcePatchPolicy({
221
+ config,
222
+ manifest: applyRenameMapToManifest(currentManifest, currentRenameMap),
223
+ command: 'patch reorder',
224
+ forceUnsafe: options.forceUnsafe === true,
225
+ });
219
226
  const currentProjected = projectReorder(await buildPatchQueueContext(patchesDir), currentRenameMap);
220
227
  const currentConflicts = lintPatchQueue(currentProjected).filter((i) => i.severity === 'error');
221
228
  if (currentConflicts.length > 0 && options.forceUnsafe !== true) {
@@ -262,6 +269,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
262
269
  throw new InvalidArgumentError('--to, --before, and --after are mutually exclusive.', 'patch reorder');
263
270
  }
264
271
  const paths = getProjectPaths(projectRoot);
272
+ const config = await loadConfig(projectRoot);
265
273
  if (!(await pathExists(paths.patches))) {
266
274
  throw new GeneralError('Patches directory not found.');
267
275
  }
@@ -286,6 +294,12 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
286
294
  const projected = projectReorder(baseCtx, renameMap);
287
295
  const projectedIssues = lintPatchQueue(projected);
288
296
  const errorIssues = projectedIssues.filter((i) => i.severity === 'error');
297
+ enforcePatchPolicy({
298
+ config,
299
+ manifest: applyRenameMapToManifest(manifest, renameMap),
300
+ command: 'patch reorder',
301
+ forceUnsafe: options.forceUnsafe === true,
302
+ });
289
303
  const conflicts = errorIssues.length > 0
290
304
  ? {
291
305
  reason: `reorder would introduce ${errorIssues.length} cross-patch lint error(s)`,
@@ -344,7 +358,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
344
358
  result: 'ok',
345
359
  };
346
360
  };
347
- await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, buildHistoryEntry);
361
+ await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, config, buildHistoryEntry);
348
362
  info(`Reordered ${renameMap.size} patch(es).`);
349
363
  outro('Reorder complete');
350
364
  }
@@ -6,6 +6,8 @@ import { computeProjectedLintRegressions } from '../core/lint-projection.js';
6
6
  import { extractAffectedFiles } from '../core/patch-apply.js';
7
7
  import { updatePatchAndMetadata } from '../core/patch-export.js';
8
8
  import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
9
+ import { loadPatchesManifest } from '../core/patch-manifest.js';
10
+ import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
9
11
  import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
10
12
  import { InvalidArgumentError } from '../errors/base.js';
11
13
  import { pathExists } from '../utils/fs.js';
@@ -163,6 +165,16 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
163
165
  const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
164
166
  await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
165
167
  const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
168
+ const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
169
+ const manifest = await loadPatchesManifest(paths.patches);
170
+ if (manifest) {
171
+ enforcePatchPolicy({
172
+ config,
173
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === target.filename ? { ...entry, ...filesUpdates } : entry)),
174
+ command: 're-export --files',
175
+ forceUnsafe: options.forceUnsafe === true,
176
+ });
177
+ }
166
178
  // Shrinks are destructive (previously-owned files become unmanaged).
167
179
  // Additive-only changes still deserve a prompt because --files asserts
168
180
  // an authoritative file set.
@@ -205,7 +217,6 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
205
217
  // directory lock as the mutation (via the onCommitted hook) so two
206
218
  // concurrent re-exports cannot interleave records and a crash between
207
219
  // mutation and append cannot orphan the audit trail.
208
- const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
209
220
  await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, filesUpdates, async () => {
210
221
  await appendHistory(paths.patches, {
211
222
  operation: 're-export-files',
@@ -219,6 +230,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
219
230
  ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
220
231
  result: 'ok',
221
232
  });
233
+ }, {
234
+ config,
235
+ command: 're-export --files',
236
+ forceUnsafe: options.forceUnsafe === true,
222
237
  });
223
238
  success(`Re-exported ${target.filename}`);
224
239
  outro('Re-export complete');