@hominis/fireforge 0.21.1 → 0.21.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/README.md +41 -0
- package/dist/src/commands/export-all.js +9 -6
- package/dist/src/commands/export-flow.d.ts +9 -0
- package/dist/src/commands/export-flow.js +29 -1
- package/dist/src/commands/export-shared.d.ts +1 -1
- package/dist/src/commands/export-shared.js +12 -13
- package/dist/src/commands/export.js +39 -4
- package/dist/src/commands/lint.js +9 -0
- package/dist/src/commands/patch/rename.js +40 -9
- package/dist/src/commands/patch/reorder.js +17 -3
- package/dist/src/commands/re-export-files.js +16 -1
- package/dist/src/commands/re-export.js +21 -10
- package/dist/src/commands/verify.js +15 -1
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate-patch-policy.d.ts +7 -0
- package/dist/src/core/config-validate-patch-policy.js +176 -0
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/patch-export-coverage.d.ts +58 -0
- package/dist/src/core/patch-export-coverage.js +103 -0
- package/dist/src/core/patch-export-metadata.d.ts +36 -0
- package/dist/src/core/patch-export-metadata.js +69 -0
- package/dist/src/core/patch-export-update.d.ts +20 -0
- package/dist/src/core/patch-export-update.js +67 -0
- package/dist/src/core/patch-export.d.ts +13 -153
- package/dist/src/core/patch-export.js +23 -262
- package/dist/src/core/patch-manifest-validate.js +2 -2
- package/dist/src/core/patch-policy.d.ts +47 -0
- package/dist/src/core/patch-policy.js +350 -0
- package/dist/src/types/commands/options.d.ts +2 -0
- package/dist/src/types/commands/patches.d.ts +1 -1
- package/dist/src/types/config.d.ts +51 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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>;
|