@hominis/fireforge 0.21.1 → 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 (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
@@ -8,6 +8,7 @@ import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
8
8
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
9
9
  import { updatePatchAndMetadata } from '../core/patch-export.js';
10
10
  import { getClaimedFiles, loadPatchesManifest, resolvePatchIdentifier, stampPatchVersions, } from '../core/patch-manifest.js';
11
+ import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
11
12
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
12
13
  import { toError } from '../utils/errors.js';
13
14
  import { pathExists } from '../utils/fs.js';
@@ -218,6 +219,21 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
218
219
  const effectiveLintIgnore = mergedIgnoreSet.size > 0 ? [...mergedIgnoreSet] : undefined;
219
220
  const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
220
221
  const effectiveTier = options.tier ?? patch.tier;
222
+ const updates = {
223
+ filesAffected: currentFilesAffected,
224
+ };
225
+ if (options.tier !== undefined) {
226
+ updates.tier = options.tier;
227
+ }
228
+ if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
229
+ updates.lintIgnore = effectiveLintIgnore;
230
+ }
231
+ enforcePatchPolicy({
232
+ config,
233
+ manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === patch.filename ? { ...entry, ...updates } : entry)),
234
+ command: 're-export',
235
+ forceUnsafe: options.forceUnsafe === true,
236
+ });
221
237
  await runPatchLint(paths.engine, existingFiles, diffContent, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
222
238
  if (isDryRun) {
223
239
  info(`[dry-run] ${patch.filename}: ${existingFiles.length} file(s)`);
@@ -235,16 +251,11 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
235
251
  // sequence allows a concurrent `resolve` / `rebase --continue` / `patch
236
252
  // compact` / `patch reorder` to rewrite the manifest between the two
237
253
  // writes and leave patch body and `filesAffected` disagreeing.
238
- const updates = {
239
- filesAffected: currentFilesAffected,
240
- };
241
- if (options.tier !== undefined) {
242
- updates.tier = options.tier;
243
- }
244
- if (effectiveLintIgnore !== undefined && flagIgnoreSet.size > 0) {
245
- updates.lintIgnore = effectiveLintIgnore;
246
- }
247
- await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates);
254
+ await updatePatchAndMetadata(paths.patches, patch.filename, diffContent, updates, undefined, {
255
+ config,
256
+ command: 're-export',
257
+ forceUnsafe: options.forceUnsafe === true,
258
+ });
248
259
  // Keep the in-memory manifest in sync so subsequent iterations (notably
249
260
  // `--all --scan`, where `getClaimedFiles` reads from this manifest) see
250
261
  // the just-written `filesAffected`. The on-disk write above is the
@@ -15,9 +15,10 @@
15
15
  * treat the output as pass/fail.
16
16
  */
17
17
  import { join } from 'node:path';
18
- import { getProjectPaths } from '../core/config.js';
18
+ import { getProjectPaths, loadConfig } from '../core/config.js';
19
19
  import { buildPatchQueueContext, lintPatchQueue } from '../core/patch-lint.js';
20
20
  import { loadPatchesManifest, validatePatchesManifestConsistency } from '../core/patch-manifest.js';
21
+ import { evaluatePatchPolicy } from '../core/patch-policy.js';
21
22
  import { collectPatchRegistrationReferences } from '../core/patch-registration-refs.js';
22
23
  import { GeneralError } from '../errors/base.js';
23
24
  import { pathExists, readText } from '../utils/fs.js';
@@ -108,6 +109,7 @@ function detectCrossPatchFileClaims(manifestPatches) {
108
109
  export async function verifyCommand(projectRoot) {
109
110
  intro('FireForge Verify');
110
111
  const paths = getProjectPaths(projectRoot);
112
+ const config = await loadConfig(projectRoot);
111
113
  if (!(await pathExists(paths.patches))) {
112
114
  info('No patches directory. Nothing to verify.');
113
115
  outro('Verify clean');
@@ -129,6 +131,18 @@ export async function verifyCommand(projectRoot) {
129
131
  // same path in filesAffected. Not caught by per-patch consistency.
130
132
  const manifest = await loadPatchesManifest(paths.patches);
131
133
  if (manifest) {
134
+ const policyIssues = evaluatePatchPolicy(config, manifest);
135
+ if (policyIssues.length > 0) {
136
+ warn(`Patch policy issues (${policyIssues.length}):`);
137
+ for (const issue of policyIssues) {
138
+ const label = issue.severity === 'error' ? 'ERROR' : 'WARN';
139
+ warn(` ${label} [${issue.code}] ${issue.filename}: ${issue.message}`);
140
+ if (issue.severity === 'error')
141
+ errorCount += 1;
142
+ else
143
+ warningCount += 1;
144
+ }
145
+ }
132
146
  const crossClaims = detectCrossPatchFileClaims(manifest.patches);
133
147
  if (crossClaims.length > 0) {
134
148
  warn(`Cross-patch filesAffected conflicts (${crossClaims.length}):`);
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
17
17
  /** Name of the source directory */
18
18
  export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
- export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "typecheck", "markerComment"];
20
+ export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "patchPolicy", "typecheck", "markerComment"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "firefox.sha256", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsStrict", "patchLint.checkJsCompilerOptions", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "patchPolicy", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -28,6 +28,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
28
28
  'license',
29
29
  'wire',
30
30
  'patchLint',
31
+ 'patchPolicy',
31
32
  'typecheck',
32
33
  'markerComment',
33
34
  ];
@@ -55,6 +56,7 @@ export const SUPPORTED_CONFIG_PATHS = [
55
56
  'patchLint.jsdocClassMethods',
56
57
  'patchLint.testAssertionFloor',
57
58
  'patchLint.chromeScriptJsDoc',
59
+ 'patchPolicy',
58
60
  'typecheck',
59
61
  'typecheck.projects',
60
62
  'typecheck.extraShim',
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Validation helpers for `fireforge.json#patchPolicy`.
3
+ */
4
+ import type { PatchPolicyConfig } from '../types/config.js';
5
+ import { parseObject } from '../utils/parse.js';
6
+ /** Parses and validates the optional patch policy config block. */
7
+ export declare function parsePatchPolicyBlock(rec: ReturnType<typeof parseObject>): PatchPolicyConfig;
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Validation helpers for `fireforge.json#patchPolicy`.
4
+ */
5
+ import { ConfigError } from '../errors/config.js';
6
+ import { toError } from '../utils/errors.js';
7
+ import { parseObject } from '../utils/parse.js';
8
+ import { isContainedRelativePath } from '../utils/paths.js';
9
+ const PATCH_POLICY_MUTATION_MODES = ['error', 'warn', 'force'];
10
+ function optionalConfigString(rec, key, label) {
11
+ const value = rec.raw(key);
12
+ if (value === undefined)
13
+ return undefined;
14
+ if (typeof value !== 'string') {
15
+ throw new ConfigError(`Config field "${label}" must be a string`);
16
+ }
17
+ return value;
18
+ }
19
+ function parsePositiveRangeEndpoint(raw, label) {
20
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) {
21
+ throw new ConfigError(`Config field "${label}" must be a positive integer`);
22
+ }
23
+ return raw;
24
+ }
25
+ function parsePatchPolicyCategory(raw, label) {
26
+ if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]*$/.test(raw)) {
27
+ throw new ConfigError(`Config field "${label}" must be a lowercase category identifier (letters, numbers, hyphens)`);
28
+ }
29
+ return raw;
30
+ }
31
+ function parsePatchPolicyRange(raw, label) {
32
+ let rec;
33
+ try {
34
+ rec = parseObject(raw, label);
35
+ }
36
+ catch {
37
+ throw new ConfigError(`Config field "${label}" must be an object`);
38
+ }
39
+ const from = parsePositiveRangeEndpoint(rec.raw('from'), `${label}.from`);
40
+ const to = parsePositiveRangeEndpoint(rec.raw('to'), `${label}.to`);
41
+ if (to < from) {
42
+ throw new ConfigError(`Config field "${label}.to" must be greater than or equal to from`);
43
+ }
44
+ return {
45
+ from,
46
+ to,
47
+ category: parsePatchPolicyCategory(rec.raw('category'), `${label}.category`),
48
+ };
49
+ }
50
+ function parsePatchPolicyDocumentPath(raw, label) {
51
+ if (raw === undefined)
52
+ return undefined;
53
+ if (typeof raw !== 'string' || raw.trim() === '') {
54
+ throw new ConfigError(`Config field "${label}" must be a non-empty string`);
55
+ }
56
+ if (!isContainedRelativePath(raw)) {
57
+ throw new ConfigError(`Config field "${label}" must be a project-relative path`);
58
+ }
59
+ return raw;
60
+ }
61
+ function parseReservedAllowedPatch(raw, label) {
62
+ let rec;
63
+ try {
64
+ rec = parseObject(raw, label);
65
+ }
66
+ catch {
67
+ throw new ConfigError(`Config field "${label}" must be an object`);
68
+ }
69
+ const filename = optionalConfigString(rec, 'filename', `${label}.filename`);
70
+ if (filename === undefined || filename.trim() === '') {
71
+ throw new ConfigError(`Config field "${label}.filename" must be a non-empty string`);
72
+ }
73
+ const files = rec.raw('files');
74
+ let parsedFiles;
75
+ if (files !== undefined) {
76
+ if (!Array.isArray(files) || files.some((value) => typeof value !== 'string')) {
77
+ throw new ConfigError(`Config field "${label}.files" must be an array of strings`);
78
+ }
79
+ parsedFiles = files;
80
+ }
81
+ const adr = parsePatchPolicyDocumentPath(rec.raw('adr'), `${label}.adr`);
82
+ const documentation = parsePatchPolicyDocumentPath(rec.raw('documentation'), `${label}.documentation`);
83
+ const out = { filename };
84
+ if (parsedFiles !== undefined)
85
+ out.files = parsedFiles;
86
+ if (adr !== undefined)
87
+ out.adr = adr;
88
+ if (documentation !== undefined)
89
+ out.documentation = documentation;
90
+ return out;
91
+ }
92
+ function parsePatchPolicyReservedRange(raw, label) {
93
+ let rec;
94
+ try {
95
+ rec = parseObject(raw, label);
96
+ }
97
+ catch {
98
+ throw new ConfigError(`Config field "${label}" must be an object`);
99
+ }
100
+ const from = parsePositiveRangeEndpoint(rec.raw('from'), `${label}.from`);
101
+ const to = parsePositiveRangeEndpoint(rec.raw('to'), `${label}.to`);
102
+ if (to < from) {
103
+ throw new ConfigError(`Config field "${label}.to" must be greater than or equal to from`);
104
+ }
105
+ const allowedRaw = rec.raw('allowed');
106
+ if (!Array.isArray(allowedRaw)) {
107
+ throw new ConfigError(`Config field "${label}.allowed" must be an array`);
108
+ }
109
+ return {
110
+ from,
111
+ to,
112
+ allowed: allowedRaw.map((entry, index) => parseReservedAllowedPatch(entry, `${label}.allowed[${String(index)}]`)),
113
+ };
114
+ }
115
+ function assertPolicyRangesDoNotOverlap(ranges, label) {
116
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
117
+ for (let i = 1; i < sorted.length; i++) {
118
+ const previous = sorted[i - 1];
119
+ const current = sorted[i];
120
+ if (previous && current && current.from <= previous.to) {
121
+ throw new ConfigError(`Config field "${label}" must not contain overlapping ranges (${previous.from}-${previous.to} overlaps ${current.from}-${current.to})`);
122
+ }
123
+ }
124
+ }
125
+ /** Parses and validates the optional patch policy config block. */
126
+ export function parsePatchPolicyBlock(rec) {
127
+ const out = { ranges: [] };
128
+ const filenamePattern = optionalConfigString(rec, 'filenamePattern', 'patchPolicy.filenamePattern');
129
+ if (filenamePattern !== undefined) {
130
+ try {
131
+ new RegExp(filenamePattern);
132
+ }
133
+ catch (error) {
134
+ throw new ConfigError(`Config field "patchPolicy.filenamePattern" must be a valid regular expression: ${toError(error).message}`);
135
+ }
136
+ out.filenamePattern = filenamePattern;
137
+ }
138
+ const requireDescription = rec.raw('requireDescription');
139
+ if (requireDescription !== undefined) {
140
+ if (typeof requireDescription !== 'boolean') {
141
+ throw new ConfigError('Config field "patchPolicy.requireDescription" must be a boolean');
142
+ }
143
+ out.requireDescription = requireDescription;
144
+ }
145
+ const allowGaps = rec.raw('allowGaps');
146
+ if (allowGaps !== undefined) {
147
+ if (typeof allowGaps !== 'boolean') {
148
+ throw new ConfigError('Config field "patchPolicy.allowGaps" must be a boolean');
149
+ }
150
+ out.allowGaps = allowGaps;
151
+ }
152
+ const mutationMode = rec.raw('mutationMode');
153
+ if (mutationMode !== undefined) {
154
+ if (typeof mutationMode !== 'string' ||
155
+ !PATCH_POLICY_MUTATION_MODES.includes(mutationMode)) {
156
+ throw new ConfigError(`Config field "patchPolicy.mutationMode" must be one of: ${PATCH_POLICY_MUTATION_MODES.join(', ')}`);
157
+ }
158
+ out.mutationMode = mutationMode;
159
+ }
160
+ const rangesRaw = rec.raw('ranges');
161
+ if (!Array.isArray(rangesRaw) || rangesRaw.length === 0) {
162
+ throw new ConfigError('Config field "patchPolicy.ranges" must be a non-empty array');
163
+ }
164
+ out.ranges = rangesRaw.map((entry, index) => parsePatchPolicyRange(entry, `patchPolicy.ranges[${String(index)}]`));
165
+ assertPolicyRangesDoNotOverlap(out.ranges, 'patchPolicy.ranges');
166
+ const reservedRangesRaw = rec.raw('reservedRanges');
167
+ if (reservedRangesRaw !== undefined) {
168
+ if (!Array.isArray(reservedRangesRaw)) {
169
+ throw new ConfigError('Config field "patchPolicy.reservedRanges" must be an array');
170
+ }
171
+ out.reservedRanges = reservedRangesRaw.map((entry, index) => parsePatchPolicyReservedRange(entry, `patchPolicy.reservedRanges[${String(index)}]`));
172
+ assertPolicyRangesDoNotOverlap(out.reservedRanges, 'patchPolicy.reservedRanges');
173
+ }
174
+ return out;
175
+ }
176
+ //# sourceMappingURL=config-validate-patch-policy.js.map
@@ -8,6 +8,7 @@ import { parseObject } from '../utils/parse.js';
8
8
  import { isContainedRelativePath, isExplicitAbsolutePath } from '../utils/paths.js';
9
9
  import { isValidAppId, isValidFirefoxVersion, isValidProjectLicense, PROJECT_LICENSES, validateFirefoxProductVersionCompatibility, } from '../utils/validation.js';
10
10
  import { SUPPORTED_CONFIG_ROOT_KEYS } from './config-paths.js';
11
+ import { parsePatchPolicyBlock } from './config-validate-patch-policy.js';
11
12
  /**
12
13
  * Validates a raw config object and returns a typed FireForgeConfig.
13
14
  * @param data - Raw data to validate
@@ -136,6 +137,11 @@ export function validateConfig(data) {
136
137
  if (patchLintRec) {
137
138
  config.patchLint = parsePatchLintBlock(patchLintRec);
138
139
  }
140
+ // PatchPolicy
141
+ const patchPolicyRec = optionalConfigObject(rec, 'patchPolicy');
142
+ if (patchPolicyRec) {
143
+ config.patchPolicy = parsePatchPolicyBlock(patchPolicyRec);
144
+ }
139
145
  // Typecheck (top-level, distinct from patchLint — see TypecheckConfig docs).
140
146
  const typecheckRec = optionalConfigObject(rec, 'typecheck');
141
147
  if (typecheckRec) {
@@ -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>;