@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
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { join } from 'node:path';
3
+ import { toError } from '../utils/errors.js';
4
+ import { pathExists, readText, writeText } from '../utils/fs.js';
5
+ import { warn } from '../utils/logger.js';
6
+ import { withPatchDirectoryLock } from './patch-apply.js';
7
+ import { loadPatchesManifest, savePatchesManifest } from './patch-manifest.js';
8
+ import { buildProjectedManifest, enforcePatchPolicy } from './patch-policy.js';
9
+ /**
10
+ * Updates a patch file body and its manifest row under the same patch
11
+ * directory lock. Intended for commands like `re-export --files` where the
12
+ * file body and `filesAffected` metadata must move together.
13
+ */
14
+ export async function updatePatchAndMetadata(patchesDir, filename, newContent, updates, onCommitted, policyGate) {
15
+ await withPatchDirectoryLock(patchesDir, async () => {
16
+ const manifest = await loadPatchesManifest(patchesDir);
17
+ if (!manifest) {
18
+ throw new Error('Cannot update patch metadata: patches.json is missing.');
19
+ }
20
+ const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
21
+ if (patchIndex === -1) {
22
+ throw new Error(`Cannot update patch metadata: ${filename} not found in patches.json.`);
23
+ }
24
+ const patchPath = join(patchesDir, filename);
25
+ if (!(await pathExists(patchPath))) {
26
+ throw new Error(`Cannot update patch: patch file is missing on disk: ${filename}`);
27
+ }
28
+ const originalContent = await readText(patchPath);
29
+ const existingPatch = manifest.patches[patchIndex];
30
+ manifest.patches[patchIndex] = { ...existingPatch, ...updates };
31
+ if (policyGate !== undefined) {
32
+ enforcePatchPolicy({
33
+ config: policyGate.config,
34
+ manifest: buildProjectedManifest(manifest, manifest.patches),
35
+ command: policyGate.command,
36
+ forceUnsafe: policyGate.forceUnsafe === true,
37
+ });
38
+ }
39
+ let patchWritten = false;
40
+ try {
41
+ await writeText(patchPath, newContent);
42
+ patchWritten = true;
43
+ await savePatchesManifest(patchesDir, manifest);
44
+ }
45
+ catch (error) {
46
+ if (patchWritten) {
47
+ try {
48
+ await writeText(patchPath, originalContent);
49
+ }
50
+ catch (rollbackError) {
51
+ warn(`Rollback warning: could not restore ${filename} after metadata write failed: ${toError(rollbackError).message}`);
52
+ }
53
+ }
54
+ throw error;
55
+ }
56
+ if (onCommitted) {
57
+ try {
58
+ await onCommitted();
59
+ }
60
+ catch (hookError) {
61
+ warn(`History log append failed after updatePatchAndMetadata committed (${filename}): ` +
62
+ toError(hookError).message);
63
+ }
64
+ }
65
+ });
66
+ }
67
+ //# sourceMappingURL=patch-export-update.js.map
@@ -1,4 +1,9 @@
1
1
  import type { PatchCategory, PatchesManifest, PatchInfo, PatchMetadata } from '../types/commands/index.js';
2
+ import type { FireForgeConfig } from '../types/config.js';
3
+ import { type SupersedeCoverageDetail } from './patch-export-coverage.js';
4
+ export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, type PatchCoverage, type SupersedeCoverageDetail, } from './patch-export-coverage.js';
5
+ export { type ClearablePatchMetadataField, mutatePatchMetadata, type PatchMetadataMutation, type PatchMetadataMutationResult, updatePatchMetadata, } from './patch-export-metadata.js';
6
+ export { updatePatchAndMetadata } from './patch-export-update.js';
2
7
  /**
3
8
  * Gets the next patch number for a new patch.
4
9
  * @param patchesDir - Path to the patches directory
@@ -35,6 +40,12 @@ export interface CommitExportedPatchInput {
35
40
  tier?: 'branding';
36
41
  /** Optional `PatchMetadata.lintIgnore` (empty array treated as absent). */
37
42
  lintIgnore?: string[];
43
+ /** Project config, used only when opt-in patchPolicy is present. */
44
+ config?: FireForgeConfig;
45
+ /** Mutating command name for policy errors. */
46
+ policyCommand?: string;
47
+ /** Whether --force-unsafe was supplied by the mutating command. */
48
+ forceUnsafe?: boolean;
38
49
  }
39
50
  export interface CommitExportedPatchResult {
40
51
  patchFilename: string;
@@ -76,165 +87,12 @@ export declare function findExistingPatchForFile(patchesDir: string, filePath: s
76
87
  * @param newContent - New patch content
77
88
  */
78
89
  export declare function updatePatch(patchPath: string, newContent: string): Promise<void>;
79
- /**
80
- * Optional post-commit hook for {@link updatePatchAndMetadata}. Runs inside
81
- * the patch directory lock after the mutation has succeeded but before the
82
- * lock is released. Intended for history-log appends so the audit record
83
- * lands atomically with the mutation. Hook failures are warned but never
84
- * re-thrown: by the time the hook runs the mutation is already committed,
85
- * so there is nothing meaningful to roll back.
86
- */
87
- export type UpdatePatchCommittedHook = () => Promise<void>;
88
- /**
89
- * Updates a patch file body and its manifest row under the same patch
90
- * directory lock. Intended for commands like `re-export --files` where the
91
- * file body and `filesAffected` metadata must move together.
92
- *
93
- * If the manifest write fails after the patch body has been rewritten, the
94
- * original patch content is restored best-effort before the error is
95
- * re-thrown.
96
- *
97
- * @param patchesDir - Path to the patches directory
98
- * @param filename - Target patch filename
99
- * @param newContent - New patch body
100
- * @param updates - Metadata fields to merge into the existing row
101
- * @param onCommitted - Optional hook that runs inside the same lock after
102
- * the mutation succeeds. See {@link UpdatePatchCommittedHook}.
103
- */
104
- export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook): Promise<void>;
105
- /**
106
- * Optional `PatchMetadata` keys safe to clear via the helpers below.
107
- * Required keys (filename, order, etc.) are excluded by construction so
108
- * an over-eager `unsetFields: ['filename']` cannot delete a field the
109
- * manifest validator requires. Add new keys here only when they become
110
- * optional on the type.
111
- */
112
- export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
113
- /**
114
- * Updates metadata for a patch in the manifest.
115
- *
116
- * Required-field updates go through the `updates` partial. Clearing an
117
- * optional field (e.g. removing the `tier` override) goes through
118
- * `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
119
- * not let `Partial<PatchMetadata>` carry an explicit `undefined` value
120
- * for fields whose declared type does not include `undefined`. The
121
- * implementation deletes the listed keys from the merged record before
122
- * writing, so the on-disk JSON omits them and the validator's
123
- * "preserve only when present" contract is preserved.
124
- *
125
- * @param patchesDir - Path to the patches directory
126
- * @param filename - Patch filename
127
- * @param updates - Field values to set. Pass an empty object when only
128
- * clearing fields.
129
- * @param unsetFields - Optional fields to remove from the entry (so
130
- * serialization drops them).
131
- */
132
- export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>, unsetFields?: ReadonlyArray<ClearablePatchMetadataField>): Promise<void>;
133
- /**
134
- * Return shape from a {@link mutatePatchMetadata} mutator.
135
- */
136
- export interface PatchMetadataMutation {
137
- /** Field values to set on the entry. */
138
- set?: Partial<PatchMetadata>;
139
- /** Optional fields to remove from the entry entirely. */
140
- unset?: ReadonlyArray<ClearablePatchMetadataField>;
141
- }
142
- /**
143
- * Result of a successful {@link mutatePatchMetadata} call.
144
- */
145
- export interface PatchMetadataMutationResult {
146
- /** Pre-mutation snapshot of the patch's metadata. */
147
- before: PatchMetadata;
148
- /** Post-mutation state of the patch's metadata. */
149
- after: PatchMetadata;
150
- }
151
- /**
152
- * Reads a patch's metadata under the directory lock, applies a mutator
153
- * function to compute the update, and writes the result back — all
154
- * under a single lock so a concurrent writer cannot interleave a
155
- * read-modify-write cycle. Useful for operations that need to compute
156
- * the new value from the old (e.g. unioning a `lintIgnore` list,
157
- * removing a specific entry), which {@link updatePatchMetadata}'s flat
158
- * merge cannot express on its own.
159
- *
160
- * The mutator returns `{ set, unset }` so it can both write fields
161
- * and drop optional ones. `set` and `unset` are merged before write:
162
- * `set` runs first via spread, then `unset` deletes the listed keys.
163
- *
164
- * @returns The pre/post metadata pair when the patch is found and the
165
- * write succeeds; `null` when the manifest is missing or the named
166
- * patch is not in it. Callers should treat `null` as "no-op, nothing
167
- * to log".
168
- */
169
- export declare function mutatePatchMetadata(patchesDir: string, filename: string, mutator: (existing: PatchMetadata) => PatchMetadataMutation): Promise<PatchMetadataMutationResult | null>;
170
- /**
171
- * Finds patches that are completely superseded by newer patches.
172
- * A patch is superseded if all its affected files are covered by newer patches.
173
- * @param patchesDir - Path to the patches directory
174
- * @param newPatchFiles - Files affected by the new patch
175
- * @param excludeFilename - Filename to exclude from results (the new patch itself)
176
- * @returns Superseded patches
177
- */
178
- export declare function findSupersededPatches(patchesDir: string, newPatchFiles: string[], excludeFilename?: string): Promise<PatchInfo[]>;
179
90
  /**
180
91
  * Deletes a patch file and removes it from the manifest.
181
92
  * @param patchesDir - Path to the patches directory
182
93
  * @param filename - Patch filename to delete
183
94
  */
184
95
  export declare function deletePatch(patchesDir: string, filename: string): Promise<void>;
185
- /**
186
- * Report whether a patch is fully covered by a new export, and which of its
187
- * files caused the coverage.
188
- *
189
- * Widened from a bare boolean to `{covered, byFiles}` so that `export
190
- * --supersede --dry-run` can tell the operator which files in each existing
191
- * patch triggered its supersession — the opaque "this export would
192
- * supersede N patches" message was the primary reason `--supersede` was
193
- * unsafe before this change.
194
- */
195
- export interface PatchCoverage {
196
- covered: boolean;
197
- byFiles: string[];
198
- }
199
- /**
200
- * Checks whether a patch is fully covered by a new export.
201
- * A patch is fully covered when every file it affects is present in the new export.
202
- * @param patchFiles - Files affected by the existing patch
203
- * @param targetFiles - Files affected by the new export
204
- * @returns Coverage report with the triggering file list when `covered` is true
205
- */
206
- export declare function isPatchFullyCovered(patchFiles: string[], targetFiles: string[]): PatchCoverage;
207
- /**
208
- * Finds patches whose filesAffected entries are fully covered by the specified files.
209
- * Used for complete supersession when exporting full-file patches.
210
- * @param patchesDir - Path to the patches directory
211
- * @param targetFiles - Files affected by the new export
212
- * @param excludeFilename - Filename to exclude from results (the new patch itself)
213
- * @returns Patches that are fully covered by the new export
214
- */
215
- export declare function findAllPatchesForFiles(patchesDir: string, targetFiles: string[], excludeFilename?: string): Promise<PatchInfo[]>;
216
- /**
217
- * Describes which files in a covered patch triggered its supersession.
218
- * Returned from {@link planExport} so dry-run previews can render a
219
- * complete "moved / removed" picture rather than a bare patch count.
220
- */
221
- export interface SupersedeCoverageDetail {
222
- /** Existing patch filename. */
223
- filename: string;
224
- /** Files the existing patch claimed that the new export also claims. */
225
- coveredByFiles: string[];
226
- }
227
- /**
228
- * Resolves coverage details for every existing patch that the new export
229
- * would fully cover. Mirrors {@link findAllPatchesForFiles} but returns the
230
- * widened {@link PatchCoverage.byFiles} list per match so callers can render
231
- * a per-patch breakdown.
232
- */
233
- export declare function findAllPatchesForFilesWithDetails(patchesDir: string, targetFiles: string[], excludeFilename?: string): Promise<{
234
- patch: PatchInfo;
235
- coverage: PatchCoverage;
236
- metadata: PatchMetadata;
237
- }[]>;
238
96
  /**
239
97
  * Fully computed plan for a pending export. Returned from
240
98
  * {@link planExport} so that `--dry-run` previews can render the full
@@ -281,6 +139,8 @@ export interface PlanExportInput {
281
139
  * preserves the field when it has at least one entry.
282
140
  */
283
141
  lintIgnore?: string[];
142
+ /** Project config, used only when opt-in patchPolicy is present. */
143
+ config?: FireForgeConfig;
284
144
  }
285
145
  /**
286
146
  * Read-only planning function — computes everything a real export would
@@ -5,8 +5,13 @@ import { toError } from '../utils/errors.js';
5
5
  import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
7
  import { PATCH_CATEGORIES } from '../utils/validation.js';
8
- import { discoverPatches, isNewFilePatch, withPatchDirectoryLock } from './patch-apply.js';
8
+ import { discoverPatches, withPatchDirectoryLock } from './patch-apply.js';
9
+ import { findAllPatchesForFilesWithDetails, } from './patch-export-coverage.js';
9
10
  import { addPatchToManifest, loadPatchesManifest, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest.js';
11
+ import { allocatePolicyOrder, enforcePatchPolicy } from './patch-policy.js';
12
+ export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, } from './patch-export-coverage.js';
13
+ export { mutatePatchMetadata, updatePatchMetadata, } from './patch-export-metadata.js';
14
+ export { updatePatchAndMetadata } from './patch-export-update.js';
10
15
  /**
11
16
  * Gets the next patch number for a new patch.
12
17
  * @param patchesDir - Path to the patches directory
@@ -71,7 +76,16 @@ export async function commitExportedPatch(input) {
71
76
  sourceEsrVersion: input.sourceEsrVersion,
72
77
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
73
78
  ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
79
+ ...(input.config !== undefined ? { config: input.config } : {}),
74
80
  });
81
+ if (input.config !== undefined) {
82
+ enforcePatchPolicy({
83
+ config: input.config,
84
+ manifest: plan.manifestAfter,
85
+ command: input.policyCommand ?? 'export',
86
+ forceUnsafe: input.forceUnsafe === true,
87
+ });
88
+ }
75
89
  const patchPath = plan.patchPath;
76
90
  const originalPatchContent = (await pathExists(patchPath)) ? await readText(patchPath) : null;
77
91
  const removedPatchContents = new Map();
@@ -143,7 +157,7 @@ export function parseFilename(filename) {
143
157
  if (PATCH_CATEGORIES.includes(category)) {
144
158
  return {
145
159
  order: parseInt(orderStr, 10),
146
- category: category,
160
+ category,
147
161
  name,
148
162
  };
149
163
  }
@@ -183,194 +197,6 @@ export async function findExistingPatchForFile(patchesDir, filePath) {
183
197
  export async function updatePatch(patchPath, newContent) {
184
198
  await writeText(patchPath, newContent);
185
199
  }
186
- /**
187
- * Updates a patch file body and its manifest row under the same patch
188
- * directory lock. Intended for commands like `re-export --files` where the
189
- * file body and `filesAffected` metadata must move together.
190
- *
191
- * If the manifest write fails after the patch body has been rewritten, the
192
- * original patch content is restored best-effort before the error is
193
- * re-thrown.
194
- *
195
- * @param patchesDir - Path to the patches directory
196
- * @param filename - Target patch filename
197
- * @param newContent - New patch body
198
- * @param updates - Metadata fields to merge into the existing row
199
- * @param onCommitted - Optional hook that runs inside the same lock after
200
- * the mutation succeeds. See {@link UpdatePatchCommittedHook}.
201
- */
202
- export async function updatePatchAndMetadata(patchesDir, filename, newContent, updates, onCommitted) {
203
- await withPatchDirectoryLock(patchesDir, async () => {
204
- const manifest = await loadPatchesManifest(patchesDir);
205
- if (!manifest) {
206
- throw new Error('Cannot update patch metadata: patches.json is missing.');
207
- }
208
- const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
209
- if (patchIndex === -1) {
210
- throw new Error(`Cannot update patch metadata: ${filename} not found in patches.json.`);
211
- }
212
- const patchPath = join(patchesDir, filename);
213
- if (!(await pathExists(patchPath))) {
214
- throw new Error(`Cannot update patch: patch file is missing on disk: ${filename}`);
215
- }
216
- const originalContent = await readText(patchPath);
217
- const existingPatch = manifest.patches[patchIndex];
218
- manifest.patches[patchIndex] = { ...existingPatch, ...updates };
219
- let patchWritten = false;
220
- try {
221
- await writeText(patchPath, newContent);
222
- patchWritten = true;
223
- await savePatchesManifest(patchesDir, manifest);
224
- }
225
- catch (error) {
226
- if (patchWritten) {
227
- try {
228
- await writeText(patchPath, originalContent);
229
- }
230
- catch (rollbackError) {
231
- warn(`Rollback warning: could not restore ${filename} after metadata write failed: ${toError(rollbackError).message}`);
232
- }
233
- }
234
- throw error;
235
- }
236
- if (onCommitted) {
237
- try {
238
- await onCommitted();
239
- }
240
- catch (hookError) {
241
- warn(`History log append failed after updatePatchAndMetadata committed (${filename}): ` +
242
- toError(hookError).message);
243
- }
244
- }
245
- });
246
- }
247
- /**
248
- * Merges `updates` onto `existing` and removes the listed `unset`
249
- * fields. The unset path is an explicit switch over the
250
- * {@link ClearablePatchMetadataField} union rather than a dynamic
251
- * `delete obj[k]` so the typecheck-time guarantee that only optional
252
- * fields can be cleared survives the runtime erasure — and so the lint
253
- * rule against dynamic deletes does not have to be silenced. Adding a
254
- * new clearable field requires extending both the union and this
255
- * switch in lockstep, which is exactly the constraint we want.
256
- */
257
- function applyMetadataUpdate(existing, updates, unset) {
258
- const next = { ...existing, ...updates };
259
- for (const field of unset) {
260
- switch (field) {
261
- case 'tier':
262
- delete next.tier;
263
- break;
264
- case 'lintIgnore':
265
- delete next.lintIgnore;
266
- break;
267
- }
268
- }
269
- return next;
270
- }
271
- /**
272
- * Updates metadata for a patch in the manifest.
273
- *
274
- * Required-field updates go through the `updates` partial. Clearing an
275
- * optional field (e.g. removing the `tier` override) goes through
276
- * `unsetFields` because TypeScript's `exactOptionalPropertyTypes` does
277
- * not let `Partial<PatchMetadata>` carry an explicit `undefined` value
278
- * for fields whose declared type does not include `undefined`. The
279
- * implementation deletes the listed keys from the merged record before
280
- * writing, so the on-disk JSON omits them and the validator's
281
- * "preserve only when present" contract is preserved.
282
- *
283
- * @param patchesDir - Path to the patches directory
284
- * @param filename - Patch filename
285
- * @param updates - Field values to set. Pass an empty object when only
286
- * clearing fields.
287
- * @param unsetFields - Optional fields to remove from the entry (so
288
- * serialization drops them).
289
- */
290
- export async function updatePatchMetadata(patchesDir, filename, updates, unsetFields = []) {
291
- await withPatchDirectoryLock(patchesDir, async () => {
292
- const manifest = await loadPatchesManifest(patchesDir);
293
- if (!manifest)
294
- return;
295
- const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
296
- if (patchIndex === -1)
297
- return;
298
- const existingPatch = manifest.patches[patchIndex];
299
- if (existingPatch) {
300
- manifest.patches[patchIndex] = applyMetadataUpdate(existingPatch, updates, unsetFields);
301
- await savePatchesManifest(patchesDir, manifest);
302
- }
303
- });
304
- }
305
- /**
306
- * Reads a patch's metadata under the directory lock, applies a mutator
307
- * function to compute the update, and writes the result back — all
308
- * under a single lock so a concurrent writer cannot interleave a
309
- * read-modify-write cycle. Useful for operations that need to compute
310
- * the new value from the old (e.g. unioning a `lintIgnore` list,
311
- * removing a specific entry), which {@link updatePatchMetadata}'s flat
312
- * merge cannot express on its own.
313
- *
314
- * The mutator returns `{ set, unset }` so it can both write fields
315
- * and drop optional ones. `set` and `unset` are merged before write:
316
- * `set` runs first via spread, then `unset` deletes the listed keys.
317
- *
318
- * @returns The pre/post metadata pair when the patch is found and the
319
- * write succeeds; `null` when the manifest is missing or the named
320
- * patch is not in it. Callers should treat `null` as "no-op, nothing
321
- * to log".
322
- */
323
- export async function mutatePatchMetadata(patchesDir, filename, mutator) {
324
- return await withPatchDirectoryLock(patchesDir, async () => {
325
- const manifest = await loadPatchesManifest(patchesDir);
326
- if (!manifest)
327
- return null;
328
- const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
329
- if (patchIndex === -1)
330
- return null;
331
- const existingPatch = manifest.patches[patchIndex];
332
- if (!existingPatch)
333
- return null;
334
- const { set = {}, unset = [] } = mutator(existingPatch);
335
- const updatedPatch = applyMetadataUpdate(existingPatch, set, unset);
336
- manifest.patches[patchIndex] = updatedPatch;
337
- await savePatchesManifest(patchesDir, manifest);
338
- return { before: existingPatch, after: updatedPatch };
339
- });
340
- }
341
- /**
342
- * Finds patches that are completely superseded by newer patches.
343
- * A patch is superseded if all its affected files are covered by newer patches.
344
- * @param patchesDir - Path to the patches directory
345
- * @param newPatchFiles - Files affected by the new patch
346
- * @param excludeFilename - Filename to exclude from results (the new patch itself)
347
- * @returns Superseded patches
348
- */
349
- export async function findSupersededPatches(patchesDir, newPatchFiles, excludeFilename) {
350
- const manifest = await loadPatchesManifest(patchesDir);
351
- if (!manifest)
352
- return [];
353
- const patches = await discoverPatches(patchesDir);
354
- const superseded = [];
355
- for (const metadata of manifest.patches) {
356
- // Skip the new patch itself
357
- if (excludeFilename && metadata.filename === excludeFilename)
358
- continue;
359
- // Check if this is a "new file" patch (single file, created from scratch)
360
- // A patch is superseded if it's a single-file new-file patch and
361
- // the new patch covers the same file
362
- if (metadata.filesAffected.length === 1) {
363
- const affectedFile = metadata.filesAffected[0];
364
- if (affectedFile && newPatchFiles.includes(affectedFile)) {
365
- const patch = patches.find((p) => p.filename === metadata.filename);
366
- if (patch && (await isNewFilePatch(patch.path))) {
367
- superseded.push(patch);
368
- }
369
- }
370
- }
371
- }
372
- return superseded;
373
- }
374
200
  /**
375
201
  * Deletes a patch file and removes it from the manifest.
376
202
  * @param patchesDir - Path to the patches directory
@@ -410,76 +236,6 @@ export async function deletePatch(patchesDir, filename) {
410
236
  }
411
237
  });
412
238
  }
413
- /**
414
- * Checks whether a patch is fully covered by a new export.
415
- * A patch is fully covered when every file it affects is present in the new export.
416
- * @param patchFiles - Files affected by the existing patch
417
- * @param targetFiles - Files affected by the new export
418
- * @returns Coverage report with the triggering file list when `covered` is true
419
- */
420
- export function isPatchFullyCovered(patchFiles, targetFiles) {
421
- if (patchFiles.length === 0) {
422
- return { covered: false, byFiles: [] };
423
- }
424
- const targetFileSet = new Set(targetFiles);
425
- const covered = patchFiles.every((file) => targetFileSet.has(file));
426
- return {
427
- covered,
428
- byFiles: covered ? [...patchFiles] : [],
429
- };
430
- }
431
- /**
432
- * Finds patches whose filesAffected entries are fully covered by the specified files.
433
- * Used for complete supersession when exporting full-file patches.
434
- * @param patchesDir - Path to the patches directory
435
- * @param targetFiles - Files affected by the new export
436
- * @param excludeFilename - Filename to exclude from results (the new patch itself)
437
- * @returns Patches that are fully covered by the new export
438
- */
439
- export async function findAllPatchesForFiles(patchesDir, targetFiles, excludeFilename) {
440
- const manifest = await loadPatchesManifest(patchesDir);
441
- if (!manifest)
442
- return [];
443
- const patches = await discoverPatches(patchesDir);
444
- const superseded = [];
445
- for (const metadata of manifest.patches) {
446
- // Skip the new patch itself
447
- if (excludeFilename && metadata.filename === excludeFilename)
448
- continue;
449
- if (isPatchFullyCovered(metadata.filesAffected, targetFiles).covered) {
450
- const patch = patches.find((p) => p.filename === metadata.filename);
451
- if (patch) {
452
- superseded.push(patch);
453
- }
454
- }
455
- }
456
- return superseded;
457
- }
458
- /**
459
- * Resolves coverage details for every existing patch that the new export
460
- * would fully cover. Mirrors {@link findAllPatchesForFiles} but returns the
461
- * widened {@link PatchCoverage.byFiles} list per match so callers can render
462
- * a per-patch breakdown.
463
- */
464
- export async function findAllPatchesForFilesWithDetails(patchesDir, targetFiles, excludeFilename) {
465
- const manifest = await loadPatchesManifest(patchesDir);
466
- if (!manifest)
467
- return [];
468
- const patches = await discoverPatches(patchesDir);
469
- const results = [];
470
- for (const metadata of manifest.patches) {
471
- if (excludeFilename && metadata.filename === excludeFilename)
472
- continue;
473
- const coverage = isPatchFullyCovered(metadata.filesAffected, targetFiles);
474
- if (!coverage.covered)
475
- continue;
476
- const patch = patches.find((p) => p.filename === metadata.filename);
477
- if (!patch)
478
- continue;
479
- results.push({ patch, coverage, metadata });
480
- }
481
- return results;
482
- }
483
239
  /**
484
240
  * Internal planning helper. Does NOT take the patch directory lock — the
485
241
  * caller must already hold it — because the two public entry points
@@ -489,7 +245,13 @@ export async function findAllPatchesForFilesWithDetails(patchesDir, targetFiles,
489
245
  * instead of by parallel implementations that can drift.
490
246
  */
491
247
  async function computeExportPlanUnderLock(input) {
492
- const patchFilename = await getNextPatchFilename(input.patchesDir, input.category, input.name);
248
+ const manifestBefore = await loadPatchesManifest(input.patchesDir);
249
+ const policyOrder = input.config !== undefined
250
+ ? allocatePolicyOrder(input.config, manifestBefore?.patches ?? [], input.category)
251
+ : null;
252
+ const patchFilename = policyOrder !== null
253
+ ? `${String(policyOrder).padStart(3, '0')}-${input.category}-${sanitizeName(input.name)}.patch`
254
+ : await getNextPatchFilename(input.patchesDir, input.category, input.name);
493
255
  const patchPath = join(input.patchesDir, patchFilename);
494
256
  const metadata = {
495
257
  filename: patchFilename,
@@ -511,7 +273,6 @@ async function computeExportPlanUnderLock(input) {
511
273
  coveredByFiles: m.coverage.byFiles,
512
274
  }));
513
275
  const supersededPatches = supersedeMatches.map((m) => m.patch);
514
- const manifestBefore = await loadPatchesManifest(input.patchesDir);
515
276
  const supersededSet = new Set(supersededDetails.map((s) => s.filename));
516
277
  const afterPatches = (manifestBefore?.patches ?? []).filter((p) => !supersededSet.has(p.filename) && p.filename !== patchFilename);
517
278
  afterPatches.push(metadata);
@@ -3,7 +3,7 @@
3
3
  * Schema validation for patches.json manifest data.
4
4
  */
5
5
  import { parseObject } from '../utils/parse.js';
6
- import { isArray, isObject, isValidFirefoxVersion, isValidPatchCategory, PATCH_CATEGORIES, } from '../utils/validation.js';
6
+ import { isArray, isObject, isValidFirefoxVersion, PATCH_CATEGORIES } from '../utils/validation.js';
7
7
  /**
8
8
  * Validates a single patch metadata entry from raw data.
9
9
  * @param data - Raw data to validate
@@ -18,7 +18,7 @@ export function validatePatchMetadata(data, index) {
18
18
  const createdAt = rec.string('createdAt');
19
19
  const sourceEsrVersion = rec.string('sourceEsrVersion');
20
20
  const order = rec.nonNegativeInteger('order');
21
- const category = rec.stringEnum('category', (v) => isValidPatchCategory(v), `one of: ${PATCH_CATEGORIES.join(', ')}`);
21
+ const category = rec.validatedString('category', (value) => /^[a-z][a-z0-9-]*$/.test(value), 'a lowercase category identifier (letters, numbers, hyphens)');
22
22
  if (!isValidFirefoxVersion(sourceEsrVersion)) {
23
23
  throw new Error(`patches[${index}].sourceEsrVersion must be a valid Firefox version string`);
24
24
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Project-specific patch queue policy evaluation.
3
+ *
4
+ * The policy is opt-in via `fireforge.json#patchPolicy`. Callers feed this
5
+ * module either the current manifest (`verify`, `lint --per-patch`) or a
6
+ * projected manifest assembled before a mutation commits (`export`,
7
+ * `patch reorder`, etc.).
8
+ */
9
+ import type { PatchesManifest, PatchMetadata } from '../types/commands/index.js';
10
+ import type { FireForgeConfig } from '../types/config.js';
11
+ /** Default patch filename contract used when a policy omits `filenamePattern`. */
12
+ export declare const DEFAULT_PATCH_POLICY_FILENAME_PATTERN = "^(?<order>\\d{3})-(?<category>[a-z][a-z0-9-]*)-(?<slug>[a-z0-9-]+)\\.patch$";
13
+ /** Stable issue codes returned by patch policy evaluation. */
14
+ export type PatchPolicyIssueCode = 'filename-pattern' | 'filename-captures' | 'filename-metadata-mismatch' | 'category-range' | 'reserved-range' | 'reserved-documentation' | 'reserved-files' | 'description-required' | 'numeric-gap';
15
+ /** A single patch policy validation finding. */
16
+ export interface PatchPolicyIssue {
17
+ code: PatchPolicyIssueCode;
18
+ filename: string;
19
+ message: string;
20
+ severity: 'error' | 'warning';
21
+ }
22
+ /** Input for enforcing policy during mutating commands. */
23
+ export interface PatchPolicyEnforcementInput {
24
+ config: FireForgeConfig;
25
+ manifest: PatchesManifest;
26
+ command: string;
27
+ forceUnsafe?: boolean;
28
+ }
29
+ /** Returns true when the loaded config includes an opt-in patch policy. */
30
+ export declare function hasPatchPolicy(config: FireForgeConfig): boolean;
31
+ /** Returns valid categories for prompts and CLI validation under the config. */
32
+ export declare function getPatchPolicyCategories(config: FireForgeConfig): string[];
33
+ /** Checks whether a category is accepted by legacy defaults or the policy ranges. */
34
+ export declare function isCategoryAllowedByConfig(config: FireForgeConfig, category: string): boolean;
35
+ /** Evaluates an entire patch manifest against the configured policy. */
36
+ export declare function evaluatePatchPolicy(config: FireForgeConfig, manifest: PatchesManifest): PatchPolicyIssue[];
37
+ /** Builds a sorted manifest snapshot from projected patch metadata. */
38
+ export declare function buildProjectedManifest(current: PatchesManifest | null, patches: PatchMetadata[]): PatchesManifest;
39
+ /** Applies a filename/order rename projection to a manifest without mutating it. */
40
+ export declare function applyRenameMapToManifest(manifest: PatchesManifest, renameMap: ReadonlyMap<string, {
41
+ newFilename: string;
42
+ newOrder: number;
43
+ }>): PatchesManifest;
44
+ /** Enforces patch policy according to the configured mutation mode. */
45
+ export declare function enforcePatchPolicy(input: PatchPolicyEnforcementInput): void;
46
+ /** Allocates the next available order inside the configured ranges for a category. */
47
+ export declare function allocatePolicyOrder(config: FireForgeConfig, patches: readonly PatchMetadata[], category: string): number | null;