@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
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Project-specific patch queue policy evaluation.
|
|
4
|
+
*
|
|
5
|
+
* The policy is opt-in via `fireforge.json#patchPolicy`. Callers feed this
|
|
6
|
+
* module either the current manifest (`verify`, `lint --per-patch`) or a
|
|
7
|
+
* projected manifest assembled before a mutation commits (`export`,
|
|
8
|
+
* `patch reorder`, etc.).
|
|
9
|
+
*/
|
|
10
|
+
import { InvalidArgumentError } from '../errors/base.js';
|
|
11
|
+
import { warn } from '../utils/logger.js';
|
|
12
|
+
import { PATCH_CATEGORIES } from '../utils/validation.js';
|
|
13
|
+
/** Default patch filename contract used when a policy omits `filenamePattern`. */
|
|
14
|
+
export const DEFAULT_PATCH_POLICY_FILENAME_PATTERN = '^(?<order>\\d{3})-(?<category>[a-z][a-z0-9-]*)-(?<slug>[a-z0-9-]+)\\.patch$';
|
|
15
|
+
function policy(config) {
|
|
16
|
+
return config.patchPolicy;
|
|
17
|
+
}
|
|
18
|
+
function mutationMode(config) {
|
|
19
|
+
return policy(config)?.mutationMode ?? 'error';
|
|
20
|
+
}
|
|
21
|
+
function issueSeverity(config) {
|
|
22
|
+
return mutationMode(config) === 'warn' ? 'warning' : 'error';
|
|
23
|
+
}
|
|
24
|
+
/** Returns true when the loaded config includes an opt-in patch policy. */
|
|
25
|
+
export function hasPatchPolicy(config) {
|
|
26
|
+
return policy(config) !== undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Returns valid categories for prompts and CLI validation under the config. */
|
|
29
|
+
export function getPatchPolicyCategories(config) {
|
|
30
|
+
const cfg = policy(config);
|
|
31
|
+
if (!cfg)
|
|
32
|
+
return [...PATCH_CATEGORIES];
|
|
33
|
+
return Array.from(new Set(cfg.ranges.map((range) => range.category))).sort((a, b) => a.localeCompare(b));
|
|
34
|
+
}
|
|
35
|
+
/** Checks whether a category is accepted by legacy defaults or the policy ranges. */
|
|
36
|
+
export function isCategoryAllowedByConfig(config, category) {
|
|
37
|
+
if (!/^[a-z][a-z0-9-]*$/.test(category))
|
|
38
|
+
return false;
|
|
39
|
+
const cfg = policy(config);
|
|
40
|
+
if (!cfg)
|
|
41
|
+
return PATCH_CATEGORIES.includes(category);
|
|
42
|
+
return cfg.ranges.some((range) => range.category === category);
|
|
43
|
+
}
|
|
44
|
+
function rangeLabel(range) {
|
|
45
|
+
return `${String(range.from).padStart(3, '0')}-${String(range.to).padStart(3, '0')}`;
|
|
46
|
+
}
|
|
47
|
+
function categoryRangeLabel(ranges, category) {
|
|
48
|
+
const matches = ranges.filter((range) => range.category === category);
|
|
49
|
+
if (matches.length === 0)
|
|
50
|
+
return '(no configured range)';
|
|
51
|
+
return matches.map(rangeLabel).join(', ');
|
|
52
|
+
}
|
|
53
|
+
function reservedRangeForOrder(cfg, order) {
|
|
54
|
+
return cfg.reservedRanges?.find((range) => order >= range.from && order <= range.to) ?? null;
|
|
55
|
+
}
|
|
56
|
+
function categoryRangeForOrder(cfg, category, order) {
|
|
57
|
+
return (cfg.ranges.find((range) => {
|
|
58
|
+
return range.category === category && order >= range.from && order <= range.to;
|
|
59
|
+
}) ?? null);
|
|
60
|
+
}
|
|
61
|
+
function anyRangeForOrder(cfg, order) {
|
|
62
|
+
return cfg.ranges.find((range) => order >= range.from && order <= range.to) ?? null;
|
|
63
|
+
}
|
|
64
|
+
function compileFilenamePattern(cfg) {
|
|
65
|
+
return new RegExp(cfg.filenamePattern ?? DEFAULT_PATCH_POLICY_FILENAME_PATTERN);
|
|
66
|
+
}
|
|
67
|
+
function parseFilenameWithPolicy(cfg, patch, severity) {
|
|
68
|
+
if (cfg.filenamePattern === undefined) {
|
|
69
|
+
const defaultMatch = /^(?<order>\d{3})-(?<rest>[a-z0-9-]+)\.patch$/.exec(patch.filename);
|
|
70
|
+
const orderRaw = defaultMatch?.groups?.['order'];
|
|
71
|
+
const rest = defaultMatch?.groups?.['rest'];
|
|
72
|
+
if (!orderRaw || !rest) {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
code: 'filename-pattern',
|
|
76
|
+
filename: patch.filename,
|
|
77
|
+
severity,
|
|
78
|
+
message: `${patch.filename} does not match the default patchPolicy filename pattern ` +
|
|
79
|
+
'(NNN-category-slug.patch).',
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
const categories = Array.from(new Set(cfg.ranges.map((range) => range.category))).sort((a, b) => b.length - a.length);
|
|
84
|
+
const category = categories.find((candidate) => rest.startsWith(`${candidate}-`));
|
|
85
|
+
if (!category) {
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
code: 'filename-captures',
|
|
89
|
+
filename: patch.filename,
|
|
90
|
+
severity,
|
|
91
|
+
message: `${patch.filename} does not encode one of the configured patchPolicy categories.`,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
const slug = rest.slice(category.length + 1);
|
|
96
|
+
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
code: 'filename-captures',
|
|
100
|
+
filename: patch.filename,
|
|
101
|
+
severity,
|
|
102
|
+
message: `${patch.filename} does not encode a lowercase slug after category "${category}".`,
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
return { order: Number.parseInt(orderRaw, 10), category };
|
|
107
|
+
}
|
|
108
|
+
const pattern = compileFilenamePattern(cfg);
|
|
109
|
+
const match = pattern.exec(patch.filename);
|
|
110
|
+
if (!match) {
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
code: 'filename-pattern',
|
|
114
|
+
filename: patch.filename,
|
|
115
|
+
severity,
|
|
116
|
+
message: `${patch.filename} does not match patchPolicy.filenamePattern ` +
|
|
117
|
+
`(${cfg.filenamePattern ?? DEFAULT_PATCH_POLICY_FILENAME_PATTERN}).`,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
const groups = match.groups;
|
|
122
|
+
const orderRaw = groups?.['order'];
|
|
123
|
+
const category = groups?.['category'];
|
|
124
|
+
const slug = groups?.['slug'];
|
|
125
|
+
if (!orderRaw || !category || slug === undefined) {
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
code: 'filename-captures',
|
|
129
|
+
filename: patch.filename,
|
|
130
|
+
severity,
|
|
131
|
+
message: 'patchPolicy.filenamePattern must expose named captures "order", "category", and "slug".',
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
const order = Number.parseInt(orderRaw, 10);
|
|
136
|
+
if (!Number.isInteger(order)) {
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
code: 'filename-captures',
|
|
140
|
+
filename: patch.filename,
|
|
141
|
+
severity,
|
|
142
|
+
message: `${patch.filename} has a non-numeric order capture "${orderRaw}".`,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
return { order, category };
|
|
147
|
+
}
|
|
148
|
+
function evaluatePatchMetadata(cfg, patch, severity) {
|
|
149
|
+
const issues = [];
|
|
150
|
+
const parsed = parseFilenameWithPolicy(cfg, patch, severity);
|
|
151
|
+
if (Array.isArray(parsed)) {
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
if (parsed.order !== patch.order || parsed.category !== patch.category) {
|
|
155
|
+
issues.push({
|
|
156
|
+
code: 'filename-metadata-mismatch',
|
|
157
|
+
filename: patch.filename,
|
|
158
|
+
severity,
|
|
159
|
+
message: `${patch.filename} encodes order/category ${parsed.order}/${parsed.category}, ` +
|
|
160
|
+
`but patches.json records ${patch.order}/${patch.category}.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (cfg.requireDescription === true && patch.description.trim() === '') {
|
|
164
|
+
issues.push({
|
|
165
|
+
code: 'description-required',
|
|
166
|
+
filename: patch.filename,
|
|
167
|
+
severity,
|
|
168
|
+
message: `${patch.filename} has an empty description, but patchPolicy.requireDescription is true.`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const reserved = reservedRangeForOrder(cfg, patch.order);
|
|
172
|
+
if (reserved) {
|
|
173
|
+
const allowed = reserved.allowed.find((entry) => entry.filename === patch.filename);
|
|
174
|
+
if (!allowed) {
|
|
175
|
+
issues.push({
|
|
176
|
+
code: 'reserved-range',
|
|
177
|
+
filename: patch.filename,
|
|
178
|
+
severity,
|
|
179
|
+
message: `${patch.filename} is in reserved range ${rangeLabel(reserved)}. ` +
|
|
180
|
+
'Reserved ranges require an exact patchPolicy.reservedRanges[].allowed filename exception.',
|
|
181
|
+
});
|
|
182
|
+
return issues;
|
|
183
|
+
}
|
|
184
|
+
if (!allowed.adr && !allowed.documentation) {
|
|
185
|
+
issues.push({
|
|
186
|
+
code: 'reserved-documentation',
|
|
187
|
+
filename: patch.filename,
|
|
188
|
+
severity,
|
|
189
|
+
message: `${patch.filename} is allowlisted for reserved range ${rangeLabel(reserved)}, ` +
|
|
190
|
+
'but the allowlist entry must include either "adr" or "documentation".',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (allowed.files !== undefined) {
|
|
194
|
+
const allowedFiles = new Set(allowed.files);
|
|
195
|
+
const extraFiles = patch.filesAffected.filter((file) => !allowedFiles.has(file));
|
|
196
|
+
if (extraFiles.length > 0) {
|
|
197
|
+
issues.push({
|
|
198
|
+
code: 'reserved-files',
|
|
199
|
+
filename: patch.filename,
|
|
200
|
+
severity,
|
|
201
|
+
message: `${patch.filename} is allowlisted for reserved range ${rangeLabel(reserved)}, ` +
|
|
202
|
+
`but touches file(s) outside its reserved allowlist: ${extraFiles.join(', ')}.`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return issues;
|
|
207
|
+
}
|
|
208
|
+
const matchingRange = categoryRangeForOrder(cfg, patch.category, patch.order);
|
|
209
|
+
if (!matchingRange) {
|
|
210
|
+
const owner = anyRangeForOrder(cfg, patch.order);
|
|
211
|
+
const expected = categoryRangeLabel(cfg.ranges, patch.category);
|
|
212
|
+
const actual = owner !== null
|
|
213
|
+
? `${String(patch.order).padStart(3, '0')} is configured for ${owner.category} (${rangeLabel(owner)})`
|
|
214
|
+
: `${String(patch.order).padStart(3, '0')} is outside all configured ranges`;
|
|
215
|
+
issues.push({
|
|
216
|
+
code: 'category-range',
|
|
217
|
+
filename: patch.filename,
|
|
218
|
+
severity,
|
|
219
|
+
message: `${patch.category} patches must use ${expected}; ${actual}. ` +
|
|
220
|
+
`Choose a ${patch.category} order in ${expected} or configure an explicit reserved-range exception.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return issues;
|
|
224
|
+
}
|
|
225
|
+
function evaluateGaps(cfg, patches, severity) {
|
|
226
|
+
if (cfg.allowGaps !== false)
|
|
227
|
+
return [];
|
|
228
|
+
const issues = [];
|
|
229
|
+
for (const range of cfg.ranges) {
|
|
230
|
+
const occupied = patches
|
|
231
|
+
.filter((patch) => {
|
|
232
|
+
return (patch.category === range.category &&
|
|
233
|
+
patch.order >= range.from &&
|
|
234
|
+
patch.order <= range.to &&
|
|
235
|
+
reservedRangeForOrder(cfg, patch.order) === null);
|
|
236
|
+
})
|
|
237
|
+
.map((patch) => patch.order)
|
|
238
|
+
.sort((a, b) => a - b);
|
|
239
|
+
if (occupied.length <= 1)
|
|
240
|
+
continue;
|
|
241
|
+
const occupiedSet = new Set(occupied);
|
|
242
|
+
const first = occupied[0];
|
|
243
|
+
const last = occupied[occupied.length - 1];
|
|
244
|
+
const missing = [];
|
|
245
|
+
for (let order = first; order <= last; order++) {
|
|
246
|
+
if (!occupiedSet.has(order) && reservedRangeForOrder(cfg, order) === null) {
|
|
247
|
+
missing.push(order);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (missing.length > 0) {
|
|
251
|
+
issues.push({
|
|
252
|
+
code: 'numeric-gap',
|
|
253
|
+
filename: `${range.category}:${rangeLabel(range)}`,
|
|
254
|
+
severity,
|
|
255
|
+
message: `${range.category} range ${rangeLabel(range)} has numeric gap(s): ` +
|
|
256
|
+
missing.map((order) => String(order).padStart(3, '0')).join(', ') +
|
|
257
|
+
'. patchPolicy.allowGaps is false.',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return issues;
|
|
262
|
+
}
|
|
263
|
+
/** Evaluates an entire patch manifest against the configured policy. */
|
|
264
|
+
export function evaluatePatchPolicy(config, manifest) {
|
|
265
|
+
const cfg = policy(config);
|
|
266
|
+
if (!cfg)
|
|
267
|
+
return [];
|
|
268
|
+
const severity = issueSeverity(config);
|
|
269
|
+
const issues = manifest.patches.flatMap((patch) => evaluatePatchMetadata(cfg, patch, severity));
|
|
270
|
+
issues.push(...evaluateGaps(cfg, manifest.patches, severity));
|
|
271
|
+
return issues;
|
|
272
|
+
}
|
|
273
|
+
/** Builds a sorted manifest snapshot from projected patch metadata. */
|
|
274
|
+
export function buildProjectedManifest(current, patches) {
|
|
275
|
+
const next = patches
|
|
276
|
+
.map((patch) => ({ ...patch, filesAffected: [...patch.filesAffected] }))
|
|
277
|
+
.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
|
|
278
|
+
return {
|
|
279
|
+
version: current?.version ?? 1,
|
|
280
|
+
patches: next,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Applies a filename/order rename projection to a manifest without mutating it. */
|
|
284
|
+
export function applyRenameMapToManifest(manifest, renameMap) {
|
|
285
|
+
return buildProjectedManifest(manifest, manifest.patches.map((patch) => {
|
|
286
|
+
const rename = renameMap.get(patch.filename);
|
|
287
|
+
if (!rename)
|
|
288
|
+
return patch;
|
|
289
|
+
return {
|
|
290
|
+
...patch,
|
|
291
|
+
filename: rename.newFilename,
|
|
292
|
+
order: rename.newOrder,
|
|
293
|
+
};
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
/** Enforces patch policy according to the configured mutation mode. */
|
|
297
|
+
export function enforcePatchPolicy(input) {
|
|
298
|
+
if (!hasPatchPolicy(input.config))
|
|
299
|
+
return;
|
|
300
|
+
const issues = evaluatePatchPolicy(input.config, input.manifest);
|
|
301
|
+
if (issues.length === 0)
|
|
302
|
+
return;
|
|
303
|
+
const mode = mutationMode(input.config);
|
|
304
|
+
const details = issues.map((issue) => ` [${issue.code}] ${issue.message}`);
|
|
305
|
+
if (mode === 'warn') {
|
|
306
|
+
warn(`${input.command}: patch policy warning(s):`);
|
|
307
|
+
for (const detail of details)
|
|
308
|
+
warn(detail);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (mode === 'force' && input.forceUnsafe === true) {
|
|
312
|
+
warn(`${input.command}: bypassing patch policy violation(s) because --force-unsafe was provided:`);
|
|
313
|
+
for (const detail of details)
|
|
314
|
+
warn(detail);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const suffix = mode === 'force'
|
|
318
|
+
? '\n\nPass --force-unsafe only if you intentionally accept this policy violation.'
|
|
319
|
+
: '';
|
|
320
|
+
throw new InvalidArgumentError(`${input.command} would violate patchPolicy:\n${details.join('\n')}${suffix}`, mode === 'force' ? '--force-unsafe' : 'patchPolicy');
|
|
321
|
+
}
|
|
322
|
+
/** Allocates the next available order inside the configured ranges for a category. */
|
|
323
|
+
export function allocatePolicyOrder(config, patches, category) {
|
|
324
|
+
const cfg = policy(config);
|
|
325
|
+
if (!cfg)
|
|
326
|
+
return null;
|
|
327
|
+
const ranges = cfg.ranges
|
|
328
|
+
.filter((range) => range.category === category)
|
|
329
|
+
.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
330
|
+
if (ranges.length === 0)
|
|
331
|
+
return null;
|
|
332
|
+
const occupied = new Set(patches.map((patch) => patch.order));
|
|
333
|
+
const highestInRanges = patches.reduce((highest, patch) => {
|
|
334
|
+
if (!ranges.some((range) => patch.order >= range.from && patch.order <= range.to)) {
|
|
335
|
+
return highest;
|
|
336
|
+
}
|
|
337
|
+
return highest === null ? patch.order : Math.max(highest, patch.order);
|
|
338
|
+
}, null);
|
|
339
|
+
const start = highestInRanges === null ? ranges[0]?.from : highestInRanges + 1;
|
|
340
|
+
if (start === undefined)
|
|
341
|
+
return null;
|
|
342
|
+
for (const range of ranges) {
|
|
343
|
+
for (let order = Math.max(start, range.from); order <= range.to; order++) {
|
|
344
|
+
if (!occupied.has(order))
|
|
345
|
+
return order;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
//# sourceMappingURL=patch-policy.js.map
|
|
@@ -530,6 +530,8 @@ export interface PatchRenameOptions {
|
|
|
530
530
|
dryRun?: boolean;
|
|
531
531
|
/** Skip the confirmation prompt (required for non-TTY). */
|
|
532
532
|
yes?: boolean;
|
|
533
|
+
/** Bypass force-mode patchPolicy refusals. */
|
|
534
|
+
forceUnsafe?: boolean;
|
|
533
535
|
}
|
|
534
536
|
/**
|
|
535
537
|
* Options for the patch reorder command.
|
|
@@ -24,6 +24,55 @@ export interface BuildConfig {
|
|
|
24
24
|
/** Number of parallel jobs for mach build */
|
|
25
25
|
jobs?: number;
|
|
26
26
|
}
|
|
27
|
+
/** Enforcement mode for patch policy violations during mutating commands. */
|
|
28
|
+
export type PatchPolicyMutationMode = 'error' | 'warn' | 'force';
|
|
29
|
+
/** A category-owned numeric range in the patch queue. */
|
|
30
|
+
export interface PatchPolicyRange {
|
|
31
|
+
/** Inclusive lower bound. */
|
|
32
|
+
from: number;
|
|
33
|
+
/** Inclusive upper bound. */
|
|
34
|
+
to: number;
|
|
35
|
+
/** Category that owns this range. */
|
|
36
|
+
category: string;
|
|
37
|
+
}
|
|
38
|
+
/** A single allowlisted reserved-range patch exception. */
|
|
39
|
+
export interface PatchPolicyReservedAllowedPatch {
|
|
40
|
+
/** Exact patch filename allowed in the reserved range. */
|
|
41
|
+
filename: string;
|
|
42
|
+
/** Optional exact filesAffected allowlist for this reserved patch. */
|
|
43
|
+
files?: string[];
|
|
44
|
+
/** Project-relative ADR path documenting the exception. */
|
|
45
|
+
adr?: string;
|
|
46
|
+
/** Project-relative documentation path documenting the exception. */
|
|
47
|
+
documentation?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Reserved numeric range for exceptional patches. */
|
|
50
|
+
export interface PatchPolicyReservedRange {
|
|
51
|
+
/** Inclusive lower bound. */
|
|
52
|
+
from: number;
|
|
53
|
+
/** Inclusive upper bound. */
|
|
54
|
+
to: number;
|
|
55
|
+
/** Exact patch exceptions allowed in this reserved range. */
|
|
56
|
+
allowed: PatchPolicyReservedAllowedPatch[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Optional project-specific patch queue policy. When absent, FireForge keeps
|
|
60
|
+
* its historical broad category + numeric ordering behaviour.
|
|
61
|
+
*/
|
|
62
|
+
export interface PatchPolicyConfig {
|
|
63
|
+
/** Regex with named captures: order, category, slug. */
|
|
64
|
+
filenamePattern?: string;
|
|
65
|
+
/** Require non-empty patch descriptions. Default false. */
|
|
66
|
+
requireDescription?: boolean;
|
|
67
|
+
/** Allow numeric gaps within configured category ranges. Default true. */
|
|
68
|
+
allowGaps?: boolean;
|
|
69
|
+
/** How mutating commands handle policy violations. Default "error". */
|
|
70
|
+
mutationMode?: PatchPolicyMutationMode;
|
|
71
|
+
/** Category-owned numeric ranges. */
|
|
72
|
+
ranges: PatchPolicyRange[];
|
|
73
|
+
/** Reserved exception ranges. */
|
|
74
|
+
reservedRanges?: PatchPolicyReservedRange[];
|
|
75
|
+
}
|
|
27
76
|
/**
|
|
28
77
|
* Main fireforge.json configuration schema.
|
|
29
78
|
*/
|
|
@@ -46,6 +95,8 @@ export interface FireForgeConfig {
|
|
|
46
95
|
wire?: WireConfig;
|
|
47
96
|
/** Patch lint configuration */
|
|
48
97
|
patchLint?: PatchLintConfig;
|
|
98
|
+
/** Optional project-specific patch queue policy. */
|
|
99
|
+
patchPolicy?: PatchPolicyConfig;
|
|
49
100
|
/** Typecheck command configuration (CI-grade, whole-project) */
|
|
50
101
|
typecheck?: TypecheckConfig;
|
|
51
102
|
/**
|