@hominis/fireforge 0.21.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +41 -0
  3. package/dist/src/commands/config.js +5 -0
  4. package/dist/src/commands/export-all.js +10 -6
  5. package/dist/src/commands/export-flow.d.ts +10 -0
  6. package/dist/src/commands/export-flow.js +50 -2
  7. package/dist/src/commands/export-shared.d.ts +1 -1
  8. package/dist/src/commands/export-shared.js +12 -13
  9. package/dist/src/commands/export.js +40 -4
  10. package/dist/src/commands/furnace/create-templates.js +10 -3
  11. package/dist/src/commands/furnace/create.js +1 -0
  12. package/dist/src/commands/furnace/deploy.js +1 -1
  13. package/dist/src/commands/furnace/validation-output.d.ts +2 -2
  14. package/dist/src/commands/furnace/validation-output.js +20 -4
  15. package/dist/src/commands/lint.js +9 -0
  16. package/dist/src/commands/patch/rename.js +40 -9
  17. package/dist/src/commands/patch/reorder.js +17 -3
  18. package/dist/src/commands/re-export-files.js +16 -1
  19. package/dist/src/commands/re-export.js +21 -10
  20. package/dist/src/commands/verify.js +15 -1
  21. package/dist/src/core/config-paths.d.ts +2 -2
  22. package/dist/src/core/config-paths.js +2 -0
  23. package/dist/src/core/config-validate-patch-policy.d.ts +7 -0
  24. package/dist/src/core/config-validate-patch-policy.js +176 -0
  25. package/dist/src/core/config-validate.js +6 -0
  26. package/dist/src/core/furnace-config-order.d.ts +7 -0
  27. package/dist/src/core/furnace-config-order.js +86 -0
  28. package/dist/src/core/furnace-config.js +13 -1
  29. package/dist/src/core/furnace-validate.js +3 -0
  30. package/dist/src/core/patch-export-coverage.d.ts +58 -0
  31. package/dist/src/core/patch-export-coverage.js +103 -0
  32. package/dist/src/core/patch-export-metadata.d.ts +36 -0
  33. package/dist/src/core/patch-export-metadata.js +69 -0
  34. package/dist/src/core/patch-export-update.d.ts +20 -0
  35. package/dist/src/core/patch-export-update.js +67 -0
  36. package/dist/src/core/patch-export.d.ts +13 -153
  37. package/dist/src/core/patch-export.js +23 -262
  38. package/dist/src/core/patch-manifest-validate.js +2 -2
  39. package/dist/src/core/patch-policy.d.ts +47 -0
  40. package/dist/src/core/patch-policy.js +350 -0
  41. package/dist/src/types/commands/options.d.ts +2 -0
  42. package/dist/src/types/commands/patches.d.ts +1 -1
  43. package/dist/src/types/config.d.ts +51 -0
  44. package/package.json +1 -1
@@ -8,6 +8,7 @@ import { isObject, isString } from '../utils/validation.js';
8
8
  import { FIREFORGE_DIR } from './config.js';
9
9
  import { parseStringArray } from './furnace-config-array-utils.js';
10
10
  import { parseCustomConfig } from './furnace-config-custom.js';
11
+ import { orderFurnaceConfigForWrite } from './furnace-config-order.js';
11
12
  import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
12
13
  import { resolveFtlDir } from './furnace-constants.js';
13
14
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
@@ -410,7 +411,18 @@ export async function loadFurnaceConfig(root) {
410
411
  */
411
412
  export async function writeFurnaceConfig(root, config) {
412
413
  const paths = getFurnacePaths(root);
413
- await writeJson(paths.furnaceConfig, config);
414
+ let existing;
415
+ if (await pathExists(paths.furnaceConfig)) {
416
+ try {
417
+ const raw = await readJson(paths.furnaceConfig);
418
+ if (isObject(raw))
419
+ existing = raw;
420
+ }
421
+ catch {
422
+ existing = undefined;
423
+ }
424
+ }
425
+ await writeJson(paths.furnaceConfig, orderFurnaceConfigForWrite(existing, config));
414
426
  }
415
427
  /**
416
428
  * Stamps every override's `baseVersion` to the supplied version. Used by
@@ -233,6 +233,9 @@ async function findOrphanXpcshellScaffolds(root, config) {
233
233
  for (const entry of entries) {
234
234
  if (known.has(entry))
235
235
  continue;
236
+ const chromeDocPackagingTest = join(parentAbs, entry, `test_${entry}_packaging.js`);
237
+ if (await pathExists(chromeDocPackagingTest))
238
+ continue;
236
239
  issues.push({
237
240
  component: entry,
238
241
  severity: 'error',
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Patch coverage and supersession helpers used by export planning.
3
+ */
4
+ import type { PatchInfo, PatchMetadata } from '../types/commands/index.js';
5
+ /**
6
+ * Finds patches that are completely superseded by newer patches.
7
+ * A patch is superseded if all its affected files are covered by newer patches.
8
+ * @param patchesDir - Path to the patches directory
9
+ * @param newPatchFiles - Files affected by the new patch
10
+ * @param excludeFilename - Filename to exclude from results (the new patch itself)
11
+ * @returns Superseded patches
12
+ */
13
+ export declare function findSupersededPatches(patchesDir: string, newPatchFiles: string[], excludeFilename?: string): Promise<PatchInfo[]>;
14
+ /**
15
+ * Report whether a patch is fully covered by a new export, and which of its
16
+ * files caused the coverage.
17
+ */
18
+ export interface PatchCoverage {
19
+ covered: boolean;
20
+ byFiles: string[];
21
+ }
22
+ /**
23
+ * Checks whether a patch is fully covered by a new export.
24
+ * A patch is fully covered when every file it affects is present in the new export.
25
+ * @param patchFiles - Files affected by the existing patch
26
+ * @param targetFiles - Files affected by the new export
27
+ * @returns Coverage report with the triggering file list when `covered` is true
28
+ */
29
+ export declare function isPatchFullyCovered(patchFiles: string[], targetFiles: string[]): PatchCoverage;
30
+ /**
31
+ * Finds patches whose filesAffected entries are fully covered by the specified files.
32
+ * Used for complete supersession when exporting full-file patches.
33
+ * @param patchesDir - Path to the patches directory
34
+ * @param targetFiles - Files affected by the new export
35
+ * @param excludeFilename - Filename to exclude from results (the new patch itself)
36
+ * @returns Patches that are fully covered by the new export
37
+ */
38
+ export declare function findAllPatchesForFiles(patchesDir: string, targetFiles: string[], excludeFilename?: string): Promise<PatchInfo[]>;
39
+ /**
40
+ * Describes which files in a covered patch triggered its supersession.
41
+ * Returned from `planExport` so dry-run previews can render a complete
42
+ * "moved / removed" picture rather than a bare patch count.
43
+ */
44
+ export interface SupersedeCoverageDetail {
45
+ /** Existing patch filename. */
46
+ filename: string;
47
+ /** Files the existing patch claimed that the new export also claims. */
48
+ coveredByFiles: string[];
49
+ }
50
+ /**
51
+ * Resolves coverage details for every existing patch that the new export
52
+ * would fully cover.
53
+ */
54
+ export declare function findAllPatchesForFilesWithDetails(patchesDir: string, targetFiles: string[], excludeFilename?: string): Promise<{
55
+ patch: PatchInfo;
56
+ coverage: PatchCoverage;
57
+ metadata: PatchMetadata;
58
+ }[]>;
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Patch coverage and supersession helpers used by export planning.
4
+ */
5
+ import { discoverPatches, isNewFilePatch } from './patch-apply.js';
6
+ import { loadPatchesManifest } from './patch-manifest.js';
7
+ /**
8
+ * Finds patches that are completely superseded by newer patches.
9
+ * A patch is superseded if all its affected files are covered by newer patches.
10
+ * @param patchesDir - Path to the patches directory
11
+ * @param newPatchFiles - Files affected by the new patch
12
+ * @param excludeFilename - Filename to exclude from results (the new patch itself)
13
+ * @returns Superseded patches
14
+ */
15
+ export async function findSupersededPatches(patchesDir, newPatchFiles, excludeFilename) {
16
+ const manifest = await loadPatchesManifest(patchesDir);
17
+ if (!manifest)
18
+ return [];
19
+ const patches = await discoverPatches(patchesDir);
20
+ const superseded = [];
21
+ for (const metadata of manifest.patches) {
22
+ if (excludeFilename && metadata.filename === excludeFilename)
23
+ continue;
24
+ if (metadata.filesAffected.length === 1) {
25
+ const affectedFile = metadata.filesAffected[0];
26
+ if (affectedFile && newPatchFiles.includes(affectedFile)) {
27
+ const patch = patches.find((p) => p.filename === metadata.filename);
28
+ if (patch && (await isNewFilePatch(patch.path))) {
29
+ superseded.push(patch);
30
+ }
31
+ }
32
+ }
33
+ }
34
+ return superseded;
35
+ }
36
+ /**
37
+ * Checks whether a patch is fully covered by a new export.
38
+ * A patch is fully covered when every file it affects is present in the new export.
39
+ * @param patchFiles - Files affected by the existing patch
40
+ * @param targetFiles - Files affected by the new export
41
+ * @returns Coverage report with the triggering file list when `covered` is true
42
+ */
43
+ export function isPatchFullyCovered(patchFiles, targetFiles) {
44
+ if (patchFiles.length === 0) {
45
+ return { covered: false, byFiles: [] };
46
+ }
47
+ const targetFileSet = new Set(targetFiles);
48
+ const covered = patchFiles.every((file) => targetFileSet.has(file));
49
+ return {
50
+ covered,
51
+ byFiles: covered ? [...patchFiles] : [],
52
+ };
53
+ }
54
+ /**
55
+ * Finds patches whose filesAffected entries are fully covered by the specified files.
56
+ * Used for complete supersession when exporting full-file patches.
57
+ * @param patchesDir - Path to the patches directory
58
+ * @param targetFiles - Files affected by the new export
59
+ * @param excludeFilename - Filename to exclude from results (the new patch itself)
60
+ * @returns Patches that are fully covered by the new export
61
+ */
62
+ export async function findAllPatchesForFiles(patchesDir, targetFiles, excludeFilename) {
63
+ const manifest = await loadPatchesManifest(patchesDir);
64
+ if (!manifest)
65
+ return [];
66
+ const patches = await discoverPatches(patchesDir);
67
+ const superseded = [];
68
+ for (const metadata of manifest.patches) {
69
+ if (excludeFilename && metadata.filename === excludeFilename)
70
+ continue;
71
+ if (isPatchFullyCovered(metadata.filesAffected, targetFiles).covered) {
72
+ const patch = patches.find((p) => p.filename === metadata.filename);
73
+ if (patch) {
74
+ superseded.push(patch);
75
+ }
76
+ }
77
+ }
78
+ return superseded;
79
+ }
80
+ /**
81
+ * Resolves coverage details for every existing patch that the new export
82
+ * would fully cover.
83
+ */
84
+ export async function findAllPatchesForFilesWithDetails(patchesDir, targetFiles, excludeFilename) {
85
+ const manifest = await loadPatchesManifest(patchesDir);
86
+ if (!manifest)
87
+ return [];
88
+ const patches = await discoverPatches(patchesDir);
89
+ const results = [];
90
+ for (const metadata of manifest.patches) {
91
+ if (excludeFilename && metadata.filename === excludeFilename)
92
+ continue;
93
+ const coverage = isPatchFullyCovered(metadata.filesAffected, targetFiles);
94
+ if (!coverage.covered)
95
+ continue;
96
+ const patch = patches.find((p) => p.filename === metadata.filename);
97
+ if (!patch)
98
+ continue;
99
+ results.push({ patch, coverage, metadata });
100
+ }
101
+ return results;
102
+ }
103
+ //# sourceMappingURL=patch-export-coverage.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Manifest metadata mutation helpers for patch export commands.
3
+ */
4
+ import type { PatchMetadata } from '../types/commands/index.js';
5
+ /**
6
+ * Optional `PatchMetadata` keys safe to clear via the helpers below.
7
+ */
8
+ export type ClearablePatchMetadataField = 'tier' | 'lintIgnore';
9
+ /**
10
+ * Updates metadata for a patch in the manifest.
11
+ *
12
+ * @param patchesDir - Path to the patches directory
13
+ * @param filename - Patch filename
14
+ * @param updates - Field values to set
15
+ * @param unsetFields - Optional fields to remove from the entry
16
+ */
17
+ export declare function updatePatchMetadata(patchesDir: string, filename: string, updates: Partial<PatchMetadata>, unsetFields?: ReadonlyArray<ClearablePatchMetadataField>): Promise<void>;
18
+ /** Return shape from a `mutatePatchMetadata` mutator. */
19
+ export interface PatchMetadataMutation {
20
+ /** Field values to set on the entry. */
21
+ set?: Partial<PatchMetadata>;
22
+ /** Optional fields to remove from the entry entirely. */
23
+ unset?: ReadonlyArray<ClearablePatchMetadataField>;
24
+ }
25
+ /** Result of a successful `mutatePatchMetadata` call. */
26
+ export interface PatchMetadataMutationResult {
27
+ /** Pre-mutation snapshot of the patch's metadata. */
28
+ before: PatchMetadata;
29
+ /** Post-mutation state of the patch's metadata. */
30
+ after: PatchMetadata;
31
+ }
32
+ /**
33
+ * Reads a patch's metadata under the directory lock, applies a mutator
34
+ * function to compute the update, and writes the result back.
35
+ */
36
+ export declare function mutatePatchMetadata(patchesDir: string, filename: string, mutator: (existing: PatchMetadata) => PatchMetadataMutation): Promise<PatchMetadataMutationResult | null>;
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Manifest metadata mutation helpers for patch export commands.
4
+ */
5
+ import { withPatchDirectoryLock } from './patch-apply.js';
6
+ import { loadPatchesManifest, savePatchesManifest } from './patch-manifest.js';
7
+ /**
8
+ * Merges `updates` onto `existing` and removes the listed optional fields.
9
+ */
10
+ function applyMetadataUpdate(existing, updates, unset) {
11
+ const next = { ...existing, ...updates };
12
+ for (const field of unset) {
13
+ switch (field) {
14
+ case 'tier':
15
+ delete next.tier;
16
+ break;
17
+ case 'lintIgnore':
18
+ delete next.lintIgnore;
19
+ break;
20
+ }
21
+ }
22
+ return next;
23
+ }
24
+ /**
25
+ * Updates metadata for a patch in the manifest.
26
+ *
27
+ * @param patchesDir - Path to the patches directory
28
+ * @param filename - Patch filename
29
+ * @param updates - Field values to set
30
+ * @param unsetFields - Optional fields to remove from the entry
31
+ */
32
+ export async function updatePatchMetadata(patchesDir, filename, updates, unsetFields = []) {
33
+ await withPatchDirectoryLock(patchesDir, async () => {
34
+ const manifest = await loadPatchesManifest(patchesDir);
35
+ if (!manifest)
36
+ return;
37
+ const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
38
+ if (patchIndex === -1)
39
+ return;
40
+ const existingPatch = manifest.patches[patchIndex];
41
+ if (existingPatch) {
42
+ manifest.patches[patchIndex] = applyMetadataUpdate(existingPatch, updates, unsetFields);
43
+ await savePatchesManifest(patchesDir, manifest);
44
+ }
45
+ });
46
+ }
47
+ /**
48
+ * Reads a patch's metadata under the directory lock, applies a mutator
49
+ * function to compute the update, and writes the result back.
50
+ */
51
+ export async function mutatePatchMetadata(patchesDir, filename, mutator) {
52
+ return await withPatchDirectoryLock(patchesDir, async () => {
53
+ const manifest = await loadPatchesManifest(patchesDir);
54
+ if (!manifest)
55
+ return null;
56
+ const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
57
+ if (patchIndex === -1)
58
+ return null;
59
+ const existingPatch = manifest.patches[patchIndex];
60
+ if (!existingPatch)
61
+ return null;
62
+ const { set = {}, unset = [] } = mutator(existingPatch);
63
+ const updatedPatch = applyMetadataUpdate(existingPatch, set, unset);
64
+ manifest.patches[patchIndex] = updatedPatch;
65
+ await savePatchesManifest(patchesDir, manifest);
66
+ return { before: existingPatch, after: updatedPatch };
67
+ });
68
+ }
69
+ //# sourceMappingURL=patch-export-metadata.js.map
@@ -0,0 +1,20 @@
1
+ import type { PatchMetadata } from '../types/commands/index.js';
2
+ import type { FireForgeConfig } from '../types/config.js';
3
+ /**
4
+ * Optional post-commit hook for {@link updatePatchAndMetadata}. Runs inside
5
+ * the patch directory lock after the mutation has succeeded but before the
6
+ * lock is released.
7
+ */
8
+ export type UpdatePatchCommittedHook = () => Promise<void>;
9
+ /** Optional policy gate run against the under-lock projected manifest. */
10
+ export interface UpdatePatchPolicyGate {
11
+ config: FireForgeConfig;
12
+ command: string;
13
+ forceUnsafe?: boolean;
14
+ }
15
+ /**
16
+ * Updates a patch file body and its manifest row under the same patch
17
+ * directory lock. Intended for commands like `re-export --files` where the
18
+ * file body and `filesAffected` metadata must move together.
19
+ */
20
+ export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook, policyGate?: UpdatePatchPolicyGate): Promise<void>;
@@ -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