@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.
- package/CHANGELOG.md +1 -0
- package/README.md +41 -0
- package/dist/src/commands/export-all.js +9 -6
- package/dist/src/commands/export-flow.d.ts +9 -0
- package/dist/src/commands/export-flow.js +29 -1
- package/dist/src/commands/export-shared.d.ts +1 -1
- package/dist/src/commands/export-shared.js +12 -13
- package/dist/src/commands/export.js +39 -4
- package/dist/src/commands/lint.js +9 -0
- package/dist/src/commands/patch/rename.js +40 -9
- package/dist/src/commands/patch/reorder.js +17 -3
- package/dist/src/commands/re-export-files.js +16 -1
- package/dist/src/commands/re-export.js +21 -10
- package/dist/src/commands/verify.js +15 -1
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate-patch-policy.d.ts +7 -0
- package/dist/src/core/config-validate-patch-policy.js +176 -0
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/patch-export-coverage.d.ts +58 -0
- package/dist/src/core/patch-export-coverage.js +103 -0
- package/dist/src/core/patch-export-metadata.d.ts +36 -0
- package/dist/src/core/patch-export-metadata.js +69 -0
- package/dist/src/core/patch-export-update.d.ts +20 -0
- package/dist/src/core/patch-export-update.js +67 -0
- package/dist/src/core/patch-export.d.ts +13 -153
- package/dist/src/core/patch-export.js +23 -262
- package/dist/src/core/patch-manifest-validate.js +2 -2
- package/dist/src/core/patch-policy.d.ts +47 -0
- package/dist/src/core/patch-policy.js +350 -0
- package/dist/src/types/commands/options.d.ts +2 -0
- package/dist/src/types/commands/patches.d.ts +1 -1
- package/dist/src/types/config.d.ts +51 -0
- 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
|
-
.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
118
|
-
|
|
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 <${
|
|
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
|
|
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
|
-
.
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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');
|