@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.
- package/CHANGELOG.md +2 -0
- package/README.md +41 -0
- package/dist/src/commands/config.js +5 -0
- package/dist/src/commands/export-all.js +10 -6
- package/dist/src/commands/export-flow.d.ts +10 -0
- package/dist/src/commands/export-flow.js +50 -2
- 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 +40 -4
- package/dist/src/commands/furnace/create-templates.js +10 -3
- package/dist/src/commands/furnace/create.js +1 -0
- package/dist/src/commands/furnace/deploy.js +1 -1
- package/dist/src/commands/furnace/validation-output.d.ts +2 -2
- package/dist/src/commands/furnace/validation-output.js +20 -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/furnace-config-order.d.ts +7 -0
- package/dist/src/core/furnace-config-order.js +86 -0
- package/dist/src/core/furnace-config.js +13 -1
- package/dist/src/core/furnace-validate.js +3 -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
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { rename as fsRename } from 'node:fs/promises';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
-
import { getProjectPaths } from '../../core/config.js';
|
|
21
|
+
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
22
22
|
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
23
23
|
import { sanitizeName } from '../../core/patch-export.js';
|
|
24
24
|
import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
|
|
25
25
|
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
26
26
|
import { loadPatchesManifest, resolvePatchIdentifier, savePatchesManifest, } from '../../core/patch-manifest.js';
|
|
27
|
+
import { buildProjectedManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
|
|
27
28
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
28
29
|
import { toError } from '../../utils/errors.js';
|
|
29
30
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -66,23 +67,40 @@ async function commitRenameUnderLock(input) {
|
|
|
66
67
|
if (!before) {
|
|
67
68
|
throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename.`);
|
|
68
69
|
}
|
|
70
|
+
let oldPath;
|
|
71
|
+
let newPath;
|
|
69
72
|
if (filenameChanging) {
|
|
70
73
|
const collisionInLock = fresh.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
|
|
71
74
|
if (collisionInLock) {
|
|
72
75
|
throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch claimed that filename concurrently.`, 'patch rename');
|
|
73
76
|
}
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
oldPath = join(patchesDir, target.filename);
|
|
78
|
+
newPath = join(patchesDir, newFilename);
|
|
76
79
|
if (await pathExists(newPath)) {
|
|
77
80
|
throw new InvalidArgumentError(`Cannot rename: ${newFilename} already exists on disk. Resolve manually before retrying.`, 'patch rename');
|
|
78
81
|
}
|
|
79
|
-
await fsRename(oldPath, newPath);
|
|
80
82
|
fresh.patches[idx] = {
|
|
81
83
|
...before,
|
|
82
84
|
filename: newFilename,
|
|
83
85
|
name: newName,
|
|
84
86
|
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
85
87
|
};
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
fresh.patches[idx] = {
|
|
91
|
+
...before,
|
|
92
|
+
...(nameChanging ? { name: newName } : {}),
|
|
93
|
+
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
enforcePatchPolicy({
|
|
97
|
+
config: input.config,
|
|
98
|
+
manifest: buildProjectedManifest(fresh, fresh.patches),
|
|
99
|
+
command: 'patch rename',
|
|
100
|
+
forceUnsafe: input.forceUnsafe === true,
|
|
101
|
+
});
|
|
102
|
+
if (filenameChanging && oldPath !== undefined && newPath !== undefined) {
|
|
103
|
+
await fsRename(oldPath, newPath);
|
|
86
104
|
try {
|
|
87
105
|
await savePatchesManifest(patchesDir, fresh);
|
|
88
106
|
}
|
|
@@ -97,11 +115,6 @@ async function commitRenameUnderLock(input) {
|
|
|
97
115
|
}
|
|
98
116
|
}
|
|
99
117
|
else {
|
|
100
|
-
fresh.patches[idx] = {
|
|
101
|
-
...before,
|
|
102
|
-
...(nameChanging ? { name: newName } : {}),
|
|
103
|
-
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
104
|
-
};
|
|
105
118
|
await savePatchesManifest(patchesDir, fresh);
|
|
106
119
|
}
|
|
107
120
|
try {
|
|
@@ -115,6 +128,7 @@ async function commitRenameUnderLock(input) {
|
|
|
115
128
|
...(descriptionChanging ? { oldDescription: target.description, newDescription } : {}),
|
|
116
129
|
},
|
|
117
130
|
...(input.yes === true ? { yes: true } : {}),
|
|
131
|
+
...(input.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
118
132
|
result: 'ok',
|
|
119
133
|
});
|
|
120
134
|
}
|
|
@@ -138,6 +152,7 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
|
|
|
138
152
|
throw new InvalidArgumentError('Specify --to <new-name>. The new name is sanitised into the filename slug the same way `export --name` is.', 'patch rename');
|
|
139
153
|
}
|
|
140
154
|
const paths = getProjectPaths(projectRoot);
|
|
155
|
+
const config = await loadConfig(projectRoot);
|
|
141
156
|
if (!(await pathExists(paths.patches))) {
|
|
142
157
|
throw new GeneralError('Patches directory not found.');
|
|
143
158
|
}
|
|
@@ -187,6 +202,19 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
|
|
|
187
202
|
if (descriptionChanging) {
|
|
188
203
|
summary.push(`description: "${target.description || '(none)'}" → "${options.description ?? '(none)'}"`);
|
|
189
204
|
}
|
|
205
|
+
enforcePatchPolicy({
|
|
206
|
+
config,
|
|
207
|
+
manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === target.filename
|
|
208
|
+
? {
|
|
209
|
+
...entry,
|
|
210
|
+
filename: newFilename,
|
|
211
|
+
name: nameChanging ? (options.to ?? entry.name) : entry.name,
|
|
212
|
+
...(descriptionChanging ? { description: options.description ?? '' } : {}),
|
|
213
|
+
}
|
|
214
|
+
: entry)),
|
|
215
|
+
command: 'patch rename',
|
|
216
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
217
|
+
});
|
|
190
218
|
const decision = await confirmDestructive({
|
|
191
219
|
operation: 'patch-rename',
|
|
192
220
|
title: `Rename ${target.filename}`,
|
|
@@ -213,6 +241,8 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
|
|
|
213
241
|
nameChanging,
|
|
214
242
|
descriptionChanging,
|
|
215
243
|
...(options.yes === true ? { yes: true } : {}),
|
|
244
|
+
...(options.forceUnsafe === true ? { forceUnsafe: true } : {}),
|
|
245
|
+
config,
|
|
216
246
|
});
|
|
217
247
|
if (filenameChanging) {
|
|
218
248
|
info(`${target.filename} → ${newFilename}`);
|
|
@@ -237,6 +267,7 @@ export function registerPatchRename(parent, context) {
|
|
|
237
267
|
.option('--description <text>', 'Replacement description (omit to leave description unchanged)')
|
|
238
268
|
.option('--dry-run', 'Show what would change without writing')
|
|
239
269
|
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
270
|
+
.option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
|
|
240
271
|
.action(withErrorHandling(async (name, options) => {
|
|
241
272
|
await patchRenameCommand(getProjectRoot(), name, pickDefined(options));
|
|
242
273
|
}));
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* before any bytes move.
|
|
10
10
|
*/
|
|
11
11
|
import { Option } from 'commander';
|
|
12
|
-
import { getProjectPaths } from '../../core/config.js';
|
|
12
|
+
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
13
13
|
import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
|
|
14
14
|
import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
|
|
15
15
|
import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
|
|
16
16
|
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
17
17
|
import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
|
|
18
|
+
import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
|
|
18
19
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
19
20
|
import { toError } from '../../utils/errors.js';
|
|
20
21
|
import { pathExists } from '../../utils/fs.js';
|
|
@@ -184,7 +185,7 @@ function resolveDestination(target, manifestPatches, options) {
|
|
|
184
185
|
}
|
|
185
186
|
return { destinationOrder: anchor.order + 1, anchorFilename: anchor.filename };
|
|
186
187
|
}
|
|
187
|
-
async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, buildHistoryEntry) {
|
|
188
|
+
async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, config, buildHistoryEntry) {
|
|
188
189
|
await withPatchDirectoryLock(patchesDir, async () => {
|
|
189
190
|
const currentManifest = await loadPatchesManifest(patchesDir);
|
|
190
191
|
if (!currentManifest || currentManifest.patches.length === 0) {
|
|
@@ -216,6 +217,12 @@ async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename,
|
|
|
216
217
|
if (!renameMapsEqual(renameMap, currentRenameMap)) {
|
|
217
218
|
throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder to recompute the rename plan.');
|
|
218
219
|
}
|
|
220
|
+
enforcePatchPolicy({
|
|
221
|
+
config,
|
|
222
|
+
manifest: applyRenameMapToManifest(currentManifest, currentRenameMap),
|
|
223
|
+
command: 'patch reorder',
|
|
224
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
225
|
+
});
|
|
219
226
|
const currentProjected = projectReorder(await buildPatchQueueContext(patchesDir), currentRenameMap);
|
|
220
227
|
const currentConflicts = lintPatchQueue(currentProjected).filter((i) => i.severity === 'error');
|
|
221
228
|
if (currentConflicts.length > 0 && options.forceUnsafe !== true) {
|
|
@@ -262,6 +269,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
|
|
|
262
269
|
throw new InvalidArgumentError('--to, --before, and --after are mutually exclusive.', 'patch reorder');
|
|
263
270
|
}
|
|
264
271
|
const paths = getProjectPaths(projectRoot);
|
|
272
|
+
const config = await loadConfig(projectRoot);
|
|
265
273
|
if (!(await pathExists(paths.patches))) {
|
|
266
274
|
throw new GeneralError('Patches directory not found.');
|
|
267
275
|
}
|
|
@@ -286,6 +294,12 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
|
|
|
286
294
|
const projected = projectReorder(baseCtx, renameMap);
|
|
287
295
|
const projectedIssues = lintPatchQueue(projected);
|
|
288
296
|
const errorIssues = projectedIssues.filter((i) => i.severity === 'error');
|
|
297
|
+
enforcePatchPolicy({
|
|
298
|
+
config,
|
|
299
|
+
manifest: applyRenameMapToManifest(manifest, renameMap),
|
|
300
|
+
command: 'patch reorder',
|
|
301
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
302
|
+
});
|
|
289
303
|
const conflicts = errorIssues.length > 0
|
|
290
304
|
? {
|
|
291
305
|
reason: `reorder would introduce ${errorIssues.length} cross-patch lint error(s)`,
|
|
@@ -344,7 +358,7 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
|
|
|
344
358
|
result: 'ok',
|
|
345
359
|
};
|
|
346
360
|
};
|
|
347
|
-
await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, buildHistoryEntry);
|
|
361
|
+
await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, config, buildHistoryEntry);
|
|
348
362
|
info(`Reordered ${renameMap.size} patch(es).`);
|
|
349
363
|
outro('Reorder complete');
|
|
350
364
|
}
|
|
@@ -6,6 +6,8 @@ import { computeProjectedLintRegressions } from '../core/lint-projection.js';
|
|
|
6
6
|
import { extractAffectedFiles } from '../core/patch-apply.js';
|
|
7
7
|
import { updatePatchAndMetadata } from '../core/patch-export.js';
|
|
8
8
|
import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
|
|
9
|
+
import { loadPatchesManifest } from '../core/patch-manifest.js';
|
|
10
|
+
import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
|
|
9
11
|
import { extractNewFileContentFromDiff } from '../core/patch-transform.js';
|
|
10
12
|
import { InvalidArgumentError } from '../errors/base.js';
|
|
11
13
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -163,6 +165,16 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
163
165
|
const ignoreChecks = effectiveLintIgnore ? new Set(effectiveLintIgnore) : undefined;
|
|
164
166
|
await runPatchLint(paths.engine, actualProjectedFiles, projectedDiff, config, options.skipLint, undefined, ignoreChecks, effectiveTier);
|
|
165
167
|
const conflicts = await runProjectedCrossPatchLint(paths.patches, target.filename, projectedDiff);
|
|
168
|
+
const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
|
|
169
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
170
|
+
if (manifest) {
|
|
171
|
+
enforcePatchPolicy({
|
|
172
|
+
config,
|
|
173
|
+
manifest: buildProjectedManifest(manifest, manifest.patches.map((entry) => entry.filename === target.filename ? { ...entry, ...filesUpdates } : entry)),
|
|
174
|
+
command: 're-export --files',
|
|
175
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
166
178
|
// Shrinks are destructive (previously-owned files become unmanaged).
|
|
167
179
|
// Additive-only changes still deserve a prompt because --files asserts
|
|
168
180
|
// an authoritative file set.
|
|
@@ -205,7 +217,6 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
205
217
|
// directory lock as the mutation (via the onCommitted hook) so two
|
|
206
218
|
// concurrent re-exports cannot interleave records and a crash between
|
|
207
219
|
// mutation and append cannot orphan the audit trail.
|
|
208
|
-
const filesUpdates = buildFilesModeMetadataUpdates(actualProjectedFiles, options, effectiveLintIgnore, flagIgnoreSet);
|
|
209
220
|
await updatePatchAndMetadata(paths.patches, target.filename, projectedDiff, filesUpdates, async () => {
|
|
210
221
|
await appendHistory(paths.patches, {
|
|
211
222
|
operation: 're-export-files',
|
|
@@ -219,6 +230,10 @@ export async function reExportFilesInPlace(paths, selectedPatches, options, conf
|
|
|
219
230
|
...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
|
|
220
231
|
result: 'ok',
|
|
221
232
|
});
|
|
233
|
+
}, {
|
|
234
|
+
config,
|
|
235
|
+
command: 're-export --files',
|
|
236
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
222
237
|
});
|
|
223
238
|
success(`Re-exported ${target.filename}`);
|
|
224
239
|
outro('Re-export complete');
|
|
@@ -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,7 @@
|
|
|
1
|
+
import type { FurnaceConfig } from '../types/furnace.js';
|
|
2
|
+
/**
|
|
3
|
+
* Orders furnace.json output using the existing file as the primary key
|
|
4
|
+
* sequence, preserving unknown extension keys and appending newly supported
|
|
5
|
+
* fields only when needed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function orderFurnaceConfigForWrite(existing: Record<string, unknown> | undefined, config: FurnaceConfig): Record<string, unknown>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isObject } from '../utils/validation.js';
|
|
2
|
+
const FURNACE_CONFIG_TOP_LEVEL_KEYS = new Set([
|
|
3
|
+
'version',
|
|
4
|
+
'componentPrefix',
|
|
5
|
+
'tokenPrefix',
|
|
6
|
+
'tokenAllowlist',
|
|
7
|
+
'platformPrefixes',
|
|
8
|
+
'runtimeVariables',
|
|
9
|
+
'tokenHostDocuments',
|
|
10
|
+
'ftlBasePath',
|
|
11
|
+
'scanPaths',
|
|
12
|
+
'stock',
|
|
13
|
+
'overrides',
|
|
14
|
+
'custom',
|
|
15
|
+
]);
|
|
16
|
+
function orderObjectLikeExisting(existing, next) {
|
|
17
|
+
if (!existing)
|
|
18
|
+
return next;
|
|
19
|
+
const ordered = {};
|
|
20
|
+
for (const key of Object.keys(existing)) {
|
|
21
|
+
if (Object.hasOwn(next, key)) {
|
|
22
|
+
ordered[key] = next[key];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const key of Object.keys(next)) {
|
|
26
|
+
if (!Object.hasOwn(ordered, key)) {
|
|
27
|
+
ordered[key] = next[key];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return ordered;
|
|
31
|
+
}
|
|
32
|
+
function orderComponentMapLikeExisting(existing, next) {
|
|
33
|
+
if (!isObject(next))
|
|
34
|
+
return next;
|
|
35
|
+
if (!isObject(existing))
|
|
36
|
+
return next;
|
|
37
|
+
const ordered = {};
|
|
38
|
+
for (const key of Object.keys(existing)) {
|
|
39
|
+
if (Object.hasOwn(next, key)) {
|
|
40
|
+
const existingValue = existing[key];
|
|
41
|
+
const nextValue = next[key];
|
|
42
|
+
ordered[key] =
|
|
43
|
+
isObject(existingValue) && isObject(nextValue)
|
|
44
|
+
? orderObjectLikeExisting(existingValue, nextValue)
|
|
45
|
+
: nextValue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const key of Object.keys(next)) {
|
|
49
|
+
if (!Object.hasOwn(ordered, key)) {
|
|
50
|
+
ordered[key] = next[key];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ordered;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Orders furnace.json output using the existing file as the primary key
|
|
57
|
+
* sequence, preserving unknown extension keys and appending newly supported
|
|
58
|
+
* fields only when needed.
|
|
59
|
+
*/
|
|
60
|
+
export function orderFurnaceConfigForWrite(existing, config) {
|
|
61
|
+
const next = config;
|
|
62
|
+
if (!existing)
|
|
63
|
+
return next;
|
|
64
|
+
const ordered = {};
|
|
65
|
+
for (const key of Object.keys(existing)) {
|
|
66
|
+
if (key === 'overrides' || key === 'custom') {
|
|
67
|
+
ordered[key] = orderComponentMapLikeExisting(existing[key], next[key]);
|
|
68
|
+
}
|
|
69
|
+
else if (Object.hasOwn(next, key)) {
|
|
70
|
+
ordered[key] = next[key];
|
|
71
|
+
}
|
|
72
|
+
else if (!FURNACE_CONFIG_TOP_LEVEL_KEYS.has(key)) {
|
|
73
|
+
ordered[key] = existing[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const key of Object.keys(next)) {
|
|
77
|
+
if (Object.hasOwn(ordered, key))
|
|
78
|
+
continue;
|
|
79
|
+
ordered[key] =
|
|
80
|
+
key === 'overrides' || key === 'custom'
|
|
81
|
+
? orderComponentMapLikeExisting(existing[key], next[key])
|
|
82
|
+
: next[key];
|
|
83
|
+
}
|
|
84
|
+
return ordered;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=furnace-config-order.js.map
|