@hominis/fireforge 0.37.0 → 0.39.0
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 +33 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +13 -8
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +6 -2
- package/dist/src/commands/export-flow.js +17 -11
- package/dist/src/commands/export-placement-gate.js +4 -6
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export-shared.js +17 -2
- package/dist/src/commands/export.js +5 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +15 -5
- package/dist/src/commands/lint.js +1 -1
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +74 -15
- package/dist/src/commands/patch/rename.js +4 -1
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +17 -6
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +17 -3
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +8 -2
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +52 -81
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +41 -0
- package/dist/src/core/build-baseline.d.ts +6 -1
- package/dist/src/core/build-baseline.js +48 -7
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +14 -1
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/license-headers.d.ts +24 -0
- package/dist/src/core/license-headers.js +103 -1
- package/dist/src/core/patch-export.d.ts +10 -1
- package/dist/src/core/patch-export.js +12 -2
- package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
- package/dist/src/core/patch-lint-checkjs.js +30 -14
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +29 -8
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-harness-crash.d.ts +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +56 -1
- package/dist/src/core/test-stale-check.js +114 -4
- package/dist/src/core/token-manager.js +39 -2
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/typecheck-shim.d.ts +13 -0
- package/dist/src/core/typecheck-shim.js +17 -2
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/types/commands/options.d.ts +69 -3
- package/dist/src/types/config.d.ts +22 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -10,44 +10,47 @@ function findReservedRange(config, order) {
|
|
|
10
10
|
return (config.patchPolicy?.reservedRanges?.find((range) => order >= range.from && order <= range.to) ??
|
|
11
11
|
null);
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Scans downward from just below the reserved block for the first order
|
|
15
|
+
* not occupied by an existing patch. Returns null when every positive
|
|
16
|
+
* order below the block is taken.
|
|
17
|
+
*/
|
|
18
|
+
function firstFreeOrderBelowReservedRange(patches, range) {
|
|
17
19
|
const occupied = new Set(patches.map((patch) => patch.order));
|
|
18
|
-
for (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return order;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
for (const range of ranges) {
|
|
25
|
-
for (let order = range.from; order <= range.to; order++) {
|
|
26
|
-
if (!occupied.has(order) && findReservedRange(config, order) === null)
|
|
27
|
-
return order;
|
|
28
|
-
}
|
|
20
|
+
for (let order = range.from - 1; order >= 1; order--) {
|
|
21
|
+
if (!occupied.has(order))
|
|
22
|
+
return order;
|
|
29
23
|
}
|
|
30
24
|
return null;
|
|
31
25
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Refuses positional placement plans whose renumber would touch a
|
|
28
|
+
* `patchPolicy.reservedRanges` block — either by moving a patch INTO a
|
|
29
|
+
* reserved range or by moving a patch that currently sits inside one.
|
|
30
|
+
* Throws a single up-front error for the first reserved range hit (the
|
|
31
|
+
* per-patch alternative surfaced one confusing finding per shifted
|
|
32
|
+
* patch), suggesting the first free `--order` below the block when one
|
|
33
|
+
* exists. Exact `--order` plans have an empty rename map and pass.
|
|
34
|
+
*/
|
|
35
|
+
export function assertPlacementAvoidsReservedRanges(plan, manifestPatches, config) {
|
|
34
36
|
if (config?.patchPolicy === undefined || plan.renameMap.size === 0)
|
|
35
37
|
return;
|
|
36
38
|
const byFilename = new Map(manifestPatches.map((patch) => [patch.filename, patch]));
|
|
37
39
|
for (const [filename, rename] of plan.renameMap) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const reserved = findReservedRange(config, patch.order);
|
|
40
|
+
const oldOrder = byFilename.get(filename)?.order;
|
|
41
|
+
const reserved = findReservedRange(config, rename.newOrder) ??
|
|
42
|
+
(oldOrder !== undefined ? findReservedRange(config, oldOrder) : null);
|
|
42
43
|
if (reserved === null)
|
|
43
44
|
continue;
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
const label = reservedRangeLabel(reserved);
|
|
46
|
+
const freeOrder = firstFreeOrderBelowReservedRange(manifestPatches, reserved);
|
|
47
|
+
if (freeOrder !== null) {
|
|
48
|
+
throw new InvalidArgumentError(`Positional insert would renumber the reserved range ${label}; ` +
|
|
49
|
+
`pass --order ${String(freeOrder).padStart(3, '0')} (first free order below the ` +
|
|
50
|
+
'reserved block) to place the new patch without renumbering reserved patches.', 'export placement');
|
|
51
|
+
}
|
|
52
|
+
throw new InvalidArgumentError(`Positional insert would renumber the reserved range ${label}; ` +
|
|
53
|
+
'no free order exists below the reserved block. Choose an unused --order outside ' +
|
|
54
|
+
'the reserved range or adjust patchPolicy.reservedRanges.', 'export placement');
|
|
52
55
|
}
|
|
53
56
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { confirm, select, text } from '@clack/prompts';
|
|
4
|
-
import { addLicenseHeaderToFile } from '../core/license-headers.js';
|
|
4
|
+
import { addLicenseHeaderToFile, hasThirdPartyPermissiveBanner } from '../core/license-headers.js';
|
|
5
5
|
import { findAllPatchesForFiles } from '../core/patch-export.js';
|
|
6
6
|
import { commentStyleForFile, detectNewFilesInDiff, isAcceptableNewFileHeader, lintExportedPatch, resolvePatchSizeTier, } from '../core/patch-lint.js';
|
|
7
7
|
import { resolvePatchOwnedSysMjs } from '../core/patch-lint-ownership.js';
|
|
@@ -218,6 +218,7 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
|
|
|
218
218
|
if (newFiles.size === 0)
|
|
219
219
|
return false;
|
|
220
220
|
const filesToFix = [];
|
|
221
|
+
const vendoredFiles = [];
|
|
221
222
|
for (const file of newFiles) {
|
|
222
223
|
const style = commentStyleForFile(file);
|
|
223
224
|
if (!style)
|
|
@@ -231,9 +232,23 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
|
|
|
231
232
|
// MPL block header on a derived JS/CSS file) would stack a second
|
|
232
233
|
// header on top of a legitimate one.
|
|
233
234
|
if (!isAcceptableNewFileHeader(file, content, style, license)) {
|
|
234
|
-
|
|
235
|
+
// A recognized third-party permissive banner marks the file as
|
|
236
|
+
// vendored: prepending the project's own license header would break
|
|
237
|
+
// byte-identity pins and mislicense upstream code (FORGE F15).
|
|
238
|
+
if (hasThirdPartyPermissiveBanner(content)) {
|
|
239
|
+
vendoredFiles.push(file);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
filesToFix.push(file);
|
|
243
|
+
}
|
|
235
244
|
}
|
|
236
245
|
}
|
|
246
|
+
if (vendoredFiles.length > 0) {
|
|
247
|
+
const vendoredList = vendoredFiles.map((f) => ` - ${f}`).join('\n');
|
|
248
|
+
info(`${vendoredFiles.length} new file(s) carry a third-party permissive license banner and ` +
|
|
249
|
+
'were left untouched (vendored); if the missing-license-header lint check flags them, ' +
|
|
250
|
+
`waive it with --lint-ignore missing-license-header:\n${vendoredList}`);
|
|
251
|
+
}
|
|
237
252
|
if (filesToFix.length === 0)
|
|
238
253
|
return false;
|
|
239
254
|
if (dryRun) {
|
|
@@ -20,7 +20,7 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
|
20
20
|
import { toError } from '../utils/errors.js';
|
|
21
21
|
import { ensureDir, pathExists } from '../utils/fs.js';
|
|
22
22
|
import { info, intro, outro, spinner, verbose, warn } from '../utils/logger.js';
|
|
23
|
-
import { commanderArgParser, pickDefined } from '../utils/options.js';
|
|
23
|
+
import { addWaitLockOption, commanderArgParser, pickDefined, resolveWaitLockSeconds, } from '../utils/options.js';
|
|
24
24
|
import { stripEnginePrefix } from '../utils/paths.js';
|
|
25
25
|
import { parsePositiveIntegerFlag } from '../utils/validation.js';
|
|
26
26
|
import { commitPlacementExport, renderDryRunPreview } from './export-flow.js';
|
|
@@ -400,7 +400,7 @@ export async function exportCommand(projectRoot, files, options) {
|
|
|
400
400
|
}
|
|
401
401
|
/** Registers the export command on the CLI program. */
|
|
402
402
|
export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
403
|
-
program
|
|
403
|
+
const exportCmd = program
|
|
404
404
|
.command('export <paths...>')
|
|
405
405
|
.description('Export new changes as a patch (use re-export to update existing patches)')
|
|
406
406
|
.option('-n, --name <name>', 'Name for the patch')
|
|
@@ -418,8 +418,8 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
418
418
|
.option('--allow-stale-furnace', 'Export the deployed engine copy even when the components/ source changed since the last furnace apply')
|
|
419
419
|
.option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
|
|
420
420
|
.addOption(new Option('--tier <tier>', 'Force a tier override on the new patch (only "branding" recognised)').choices(['branding']))
|
|
421
|
-
.option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], [])
|
|
422
|
-
|
|
421
|
+
.option('--lint-ignore <check-id>', 'Suppress a lint check on this patch (writes to PatchMetadata.lintIgnore; repeatable)', (value, prev) => [...prev, value], []);
|
|
422
|
+
addWaitLockOption(exportCmd).action(withErrorHandling(async (paths, options) => {
|
|
423
423
|
const { category, tier, lintIgnore, ...rest } = options;
|
|
424
424
|
const projectRoot = getProjectRoot();
|
|
425
425
|
await withEngineSessionLock(projectRoot, 'export', () => exportCommand(projectRoot, paths, {
|
|
@@ -427,6 +427,6 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
|
|
|
427
427
|
...(category !== undefined ? { category } : {}),
|
|
428
428
|
...(tier !== undefined ? { tier: tier } : {}),
|
|
429
429
|
...(lintIgnore !== undefined && lintIgnore.length > 0 ? { lintIgnore } : {}),
|
|
430
|
-
}));
|
|
430
|
+
}), { waitLockSeconds: resolveWaitLockSeconds(options.waitLock) });
|
|
431
431
|
}));
|
|
432
432
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import { withEngineSessionLock } from '../../core/engine-session-lock.js';
|
|
4
|
-
import { pickDefined } from '../../utils/options.js';
|
|
4
|
+
import { addWaitLockOption, pickDefined, resolveWaitLockSeconds } from '../../utils/options.js';
|
|
5
5
|
import { furnaceApplyCommand } from './apply.js';
|
|
6
6
|
import { furnaceChromeDocCreateCommand } from './chrome-doc.js';
|
|
7
7
|
import { furnaceChromeDocRemoveCommand } from './chrome-doc-remove.js';
|
|
@@ -19,61 +19,66 @@ import { furnaceScanCommand } from './scan.js';
|
|
|
19
19
|
import { furnaceStatusCommand } from './status.js';
|
|
20
20
|
import { furnaceSyncCommand } from './sync.js';
|
|
21
21
|
import { furnaceValidateCommand } from './validate.js';
|
|
22
|
-
async function runEngineLockedFurnaceCommand(context, command, operation) {
|
|
22
|
+
async function runEngineLockedFurnaceCommand(context, command, operation, waitLockSeconds) {
|
|
23
23
|
const projectRoot = context.getProjectRoot();
|
|
24
|
-
await withEngineSessionLock(projectRoot, command, () => operation(projectRoot)
|
|
24
|
+
await withEngineSessionLock(projectRoot, command, () => operation(projectRoot), {
|
|
25
|
+
waitLockSeconds,
|
|
26
|
+
});
|
|
25
27
|
}
|
|
26
28
|
function registerFurnaceApplyCommand(furnace, context) {
|
|
27
29
|
const { getProjectRoot, withErrorHandling } = context;
|
|
28
|
-
furnace
|
|
30
|
+
const apply = furnace
|
|
29
31
|
.command('apply [name]')
|
|
30
32
|
.description('Apply components to the engine (optionally a single component)')
|
|
31
33
|
.option('--dry-run', 'Show what would be changed without writing (reads may overlap concurrent mutations)')
|
|
32
34
|
.option('--force', 'Proceed despite baseVersion drift (stale overrides)')
|
|
33
|
-
.option('-w, --watch', 'Watch component directories and re-apply on changes')
|
|
34
|
-
|
|
35
|
-
const
|
|
35
|
+
.option('-w, --watch', 'Watch component directories and re-apply on changes');
|
|
36
|
+
addWaitLockOption(apply).action(withErrorHandling(async (name, options) => {
|
|
37
|
+
const { waitLock, ...rest } = options ?? {};
|
|
38
|
+
const parsed = pickDefined(rest);
|
|
36
39
|
const run = (projectRoot) => furnaceApplyCommand(projectRoot, name, parsed);
|
|
37
40
|
if (parsed.dryRun === true) {
|
|
38
41
|
await run(getProjectRoot());
|
|
39
42
|
return;
|
|
40
43
|
}
|
|
41
|
-
await runEngineLockedFurnaceCommand(context, 'furnace apply', run);
|
|
44
|
+
await runEngineLockedFurnaceCommand(context, 'furnace apply', run, resolveWaitLockSeconds(waitLock));
|
|
42
45
|
}));
|
|
43
46
|
}
|
|
44
47
|
function registerFurnaceDeployCommand(furnace, context) {
|
|
45
48
|
const { getProjectRoot, withErrorHandling } = context;
|
|
46
|
-
furnace
|
|
49
|
+
const deploy = furnace
|
|
47
50
|
.command('deploy [name]')
|
|
48
51
|
.description('Apply components and validate in one step')
|
|
49
52
|
.option('--dry-run', 'Show what would be changed without writing (reads may overlap concurrent mutations)')
|
|
50
53
|
.option('--force', 'Proceed despite baseVersion drift (stale overrides)')
|
|
51
|
-
.option('--skip-validate', 'Skip the validation step (apply only)')
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
+
.option('--skip-validate', 'Skip the validation step (apply only)');
|
|
55
|
+
addWaitLockOption(deploy).action(withErrorHandling(async (name, options) => {
|
|
56
|
+
const { waitLock, ...rest } = options ?? {};
|
|
57
|
+
const parsed = pickDefined(rest);
|
|
54
58
|
const run = (projectRoot) => furnaceDeployCommand(projectRoot, name, parsed);
|
|
55
59
|
if (parsed.dryRun === true) {
|
|
56
60
|
await run(getProjectRoot());
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
59
|
-
await runEngineLockedFurnaceCommand(context, 'furnace deploy', run);
|
|
63
|
+
await runEngineLockedFurnaceCommand(context, 'furnace deploy', run, resolveWaitLockSeconds(waitLock));
|
|
60
64
|
}));
|
|
61
65
|
}
|
|
62
66
|
function registerFurnaceSyncCommand(furnace, context) {
|
|
63
67
|
const { getProjectRoot, withErrorHandling } = context;
|
|
64
|
-
furnace
|
|
68
|
+
const sync = furnace
|
|
65
69
|
.command('sync')
|
|
66
70
|
.description('Refresh drifted overrides and re-apply all components (recommended after fireforge download)')
|
|
67
71
|
.option('--dry-run', 'Show what would change without modifying files (reads may overlap concurrent mutations)')
|
|
68
|
-
.addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']))
|
|
69
|
-
|
|
70
|
-
const
|
|
72
|
+
.addOption(new Option('-s, --strategy <strategy>', 'Auto-resolve merge conflicts (ours = keep local, theirs = accept upstream)').choices(['ours', 'theirs']));
|
|
73
|
+
addWaitLockOption(sync).action(withErrorHandling(async (options) => {
|
|
74
|
+
const { waitLock, ...rest } = options;
|
|
75
|
+
const parsed = pickDefined(rest);
|
|
71
76
|
const run = (projectRoot) => furnaceSyncCommand(projectRoot, parsed);
|
|
72
77
|
if (parsed.dryRun === true) {
|
|
73
78
|
await run(getProjectRoot());
|
|
74
79
|
return;
|
|
75
80
|
}
|
|
76
|
-
await runEngineLockedFurnaceCommand(context, 'furnace sync', run);
|
|
81
|
+
await runEngineLockedFurnaceCommand(context, 'furnace sync', run, resolveWaitLockSeconds(waitLock));
|
|
77
82
|
}));
|
|
78
83
|
}
|
|
79
84
|
/**
|
|
@@ -8,6 +8,7 @@ import { getDirtyFiles } from '../core/git-status.js';
|
|
|
8
8
|
import { applyPatchesWithContinue, countPatches, createPatchedContentComputer, discoverPatches, extractAffectedFiles, matchesUntilFilename, PatchError, } from '../core/patch-apply.js';
|
|
9
9
|
import { checkVersionCompatibility, loadPatchesManifest, validatePatchesManifestConsistency, validatePatchIntegrity, } from '../core/patch-manifest.js';
|
|
10
10
|
import { getPatchSourceVersion } from '../core/patch-source-metadata.js';
|
|
11
|
+
import { warnIfStaticComponentsStale } from '../core/test-stale-check.js';
|
|
11
12
|
import { GeneralError } from '../errors/base.js';
|
|
12
13
|
import { toError } from '../utils/errors.js';
|
|
13
14
|
import { pathExists, readText } from '../utils/fs.js';
|
|
@@ -419,6 +420,10 @@ export async function importCommand(projectRoot, options = {}) {
|
|
|
419
420
|
const suffix = result.autoResolved ? ' (auto-resolved)' : '';
|
|
420
421
|
success(` ${result.patch.filename}${suffix}`);
|
|
421
422
|
}
|
|
423
|
+
// The re-applied queue may have moved components.conf away from what
|
|
424
|
+
// the last full build compiled in — surface that now instead of at the
|
|
425
|
+
// next test refusal (FORGE F13).
|
|
426
|
+
await warnIfStaticComponentsStale(projectRoot, paths.engine);
|
|
422
427
|
outro('All patches applied successfully!');
|
|
423
428
|
}
|
|
424
429
|
catch (error) {
|
|
@@ -56,6 +56,11 @@ async function lintQueuedPatch(patch, lintCtx) {
|
|
|
56
56
|
});
|
|
57
57
|
const cached = getCachedPerPatchLintIssues(cache, patch.filename, cacheKey);
|
|
58
58
|
if (cached) {
|
|
59
|
+
// Returning before the empty-diff probe below is safe: the cache key
|
|
60
|
+
// hashes every affected engine file's content plus engineHeadSha, so
|
|
61
|
+
// an engine-side revert that would empty the diff always changes the
|
|
62
|
+
// key and misses the cache (pinned by the "engine-side content
|
|
63
|
+
// revert invalidates the per-patch cache" test — FORGE F5).
|
|
59
64
|
return { status: 'cached', existingFiles: existing, rawIssues: cached, usedCheckJs: false };
|
|
60
65
|
}
|
|
61
66
|
}
|
|
@@ -115,9 +120,14 @@ async function applyPerPatchResults(subset, results, issues, checkJs, cache) {
|
|
|
115
120
|
continue;
|
|
116
121
|
}
|
|
117
122
|
emitTierNotice(patch.filename, result.existingFiles, patch.tier);
|
|
118
|
-
// Run-level checkJs errors are emitted once, before the first
|
|
119
|
-
//
|
|
120
|
-
|
|
123
|
+
// Run-level checkJs errors are emitted once, before the first
|
|
124
|
+
// non-skipped patch's own issues — matching the serial emit point.
|
|
125
|
+
// Deliberately NOT gated on `result.usedCheckJs`: global findings are
|
|
126
|
+
// run-level, never cached, and an all-cache-hit run previously dropped
|
|
127
|
+
// them entirely, letting a warm run report fewer errors than a cold
|
|
128
|
+
// one (FORGE F5 hardening). PerRunCheckJs builds its program lazily,
|
|
129
|
+
// so the cost only materialises when checkJs is configured.
|
|
130
|
+
if (checkJs && !globalCheckJsEmitted) {
|
|
121
131
|
globalCheckJsEmitted = true;
|
|
122
132
|
issues.push(...(await checkJs.getGlobal()));
|
|
123
133
|
}
|
|
@@ -276,7 +286,7 @@ function buildPerRunCheckJs(projectRoot, paths, config, ctx) {
|
|
|
276
286
|
return {
|
|
277
287
|
ownedByPatch,
|
|
278
288
|
getGrouped: () => (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot)),
|
|
279
|
-
getGlobal: async () => (groupedPromise
|
|
289
|
+
getGlobal: async () => (await (groupedPromise ??= invokePatchLintCheckJsGrouped(paths.engine, resolvePatchOwnedSysMjs(new Set(), ctx), patchLint, projectRoot))).global,
|
|
280
290
|
};
|
|
281
291
|
}
|
|
282
292
|
/**
|
|
@@ -299,7 +309,7 @@ export async function lintPerPatch(projectRoot, paths, options = {}) {
|
|
|
299
309
|
const subsetNames = new Set(subset.map((p) => p.filename));
|
|
300
310
|
const isSubset = subset.length !== manifest.patches.length;
|
|
301
311
|
const config = await loadConfig(projectRoot);
|
|
302
|
-
const ctx = await buildPatchQueueContext(paths.patches);
|
|
312
|
+
const ctx = await buildPatchQueueContext(paths.patches, config);
|
|
303
313
|
// Queue-level findings (policy, cross-patch) are scoped to the requested
|
|
304
314
|
// subset: a 5-patch slice should not fail on a policy or forward-import
|
|
305
315
|
// problem owned entirely by patches the operator did not target.
|
|
@@ -461,7 +461,7 @@ export async function lintCommand(projectRoot, files, options = {}) {
|
|
|
461
461
|
// per-patch ownership resolver and the cross-patch rules.
|
|
462
462
|
let ctx;
|
|
463
463
|
if (await pathExists(paths.patches)) {
|
|
464
|
-
ctx = await buildPatchQueueContext(paths.patches);
|
|
464
|
+
ctx = await buildPatchQueueContext(paths.patches, config);
|
|
465
465
|
}
|
|
466
466
|
// Ad-hoc explicit-file-list mode evaluates the patch-size rules per
|
|
467
467
|
// owning patch (see `lintOwningPatchSizes`), so suppress the synthetic
|
|
@@ -26,6 +26,16 @@ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
|
26
26
|
import { toError } from '../../utils/errors.js';
|
|
27
27
|
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
28
28
|
import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
|
|
29
|
+
/**
|
|
30
|
+
* Printed whenever `--add` lands a new check id. The waiver this command
|
|
31
|
+
* writes turns per-patch lint green immediately, but consumer projects
|
|
32
|
+
* commonly audit lintIgnore lists against a reviewed allow-map — the
|
|
33
|
+
* discovery that the map also needs updating otherwise costs a full
|
|
34
|
+
* downstream gate run (FORGE F6).
|
|
35
|
+
*/
|
|
36
|
+
const LINT_IGNORE_REVIEW_WARNING = "This lint waiver is subject to the project's patch-policy review process. " +
|
|
37
|
+
'Downstream policy audits that mirror lintIgnore lists in a reviewed allow-map ' +
|
|
38
|
+
'will refuse this patch until the map is updated to include the new ID(s).';
|
|
29
39
|
/**
|
|
30
40
|
* Computes the post-mutation `lintIgnore` list for a given mode.
|
|
31
41
|
* Returns `undefined` when the result should drop the field from the
|
|
@@ -114,7 +124,10 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
|
|
|
114
124
|
// the flag only appeared in the history record — and suppressing lint
|
|
115
125
|
// findings is precisely the mutation an operator should consciously
|
|
116
126
|
// approve.
|
|
117
|
-
const
|
|
127
|
+
const currentList = target.lintIgnore ?? [];
|
|
128
|
+
const projectedList = applyMode(currentList, mode, values) ?? [];
|
|
129
|
+
const addsNewIds = mode === 'add' && projectedList.length > currentList.length;
|
|
130
|
+
const projectedSummary = describeChange(currentList, projectedList, mode, values);
|
|
118
131
|
const decision = await confirmDestructive({
|
|
119
132
|
operation: 'patch-lint-ignore',
|
|
120
133
|
title: `Patch lint-ignore: ${target.filename}`,
|
|
@@ -123,6 +136,9 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
|
|
|
123
136
|
dryRun: isDryRun,
|
|
124
137
|
});
|
|
125
138
|
if (decision === 'dry-run') {
|
|
139
|
+
if (addsNewIds) {
|
|
140
|
+
warn(LINT_IGNORE_REVIEW_WARNING);
|
|
141
|
+
}
|
|
126
142
|
outro('Dry run complete — no changes made');
|
|
127
143
|
return;
|
|
128
144
|
}
|
|
@@ -148,6 +164,9 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
|
|
|
148
164
|
const existing = result.before.lintIgnore ?? [];
|
|
149
165
|
const projected = result.after.lintIgnore ?? [];
|
|
150
166
|
info(`${target.filename}: ${describeChange(existing, projected, mode, values)}.`);
|
|
167
|
+
if (mode === 'add' && projected.length > existing.length) {
|
|
168
|
+
warn(LINT_IGNORE_REVIEW_WARNING);
|
|
169
|
+
}
|
|
151
170
|
try {
|
|
152
171
|
await appendHistory(paths.patches, {
|
|
153
172
|
operation: 'patch-lint-ignore',
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge patch move-files <from> <to> --create --order <n>` — creates
|
|
3
|
+
* the target patch at the requested sparse order and moves the files into
|
|
4
|
+
* it as one transaction. This is the transactional bootstrap of a split:
|
|
5
|
+
* without it, moving files into a not-yet-existing patch required a manual
|
|
6
|
+
* shrink-then-export dance with hand-repointed staged-dependency owners.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `patch split` end-to-end (same planning, projection lint,
|
|
9
|
+
* policy enforcement, and locked commit); the `<to>` argument becomes the
|
|
10
|
+
* new patch's name/slug the way `split --name` does.
|
|
11
|
+
*/
|
|
12
|
+
import type { PatchMoveFilesOptions } from '../../types/commands/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* Runs `patch move-files --create --order <n>`: plans the create+move as a
|
|
15
|
+
* split, lints the projection, confirms, and commits transactionally.
|
|
16
|
+
*/
|
|
17
|
+
export declare function patchMoveFilesCreateCommand(projectRoot: string, fromIdentifier: string, newPatchName: string, options: PatchMoveFilesOptions & {
|
|
18
|
+
order: number;
|
|
19
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch move-files <from> <to> --create --order <n>` — creates
|
|
4
|
+
* the target patch at the requested sparse order and moves the files into
|
|
5
|
+
* it as one transaction. This is the transactional bootstrap of a split:
|
|
6
|
+
* without it, moving files into a not-yet-existing patch required a manual
|
|
7
|
+
* shrink-then-export dance with hand-repointed staged-dependency owners.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `patch split` end-to-end (same planning, projection lint,
|
|
10
|
+
* policy enforcement, and locked commit); the `<to>` argument becomes the
|
|
11
|
+
* new patch's name/slug the way `split --name` does.
|
|
12
|
+
*/
|
|
13
|
+
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
14
|
+
import { confirmDestructive } from '../../core/destructive.js';
|
|
15
|
+
import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
|
|
16
|
+
import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
|
|
17
|
+
import { enforcePatchPolicy } from '../../core/patch-policy.js';
|
|
18
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
19
|
+
import { info, intro, outro, success } from '../../utils/logger.js';
|
|
20
|
+
import { resolvePlacementPlan } from '../export-flow.js';
|
|
21
|
+
import { runPatchLint } from '../export-shared.js';
|
|
22
|
+
import { commitPatchSplit } from './split.js';
|
|
23
|
+
import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, projectSplitManifest, runProjectedSplitLint, } from './split-plan.js';
|
|
24
|
+
/**
|
|
25
|
+
* Runs `patch move-files --create --order <n>`: plans the create+move as a
|
|
26
|
+
* split, lints the projection, confirms, and commits transactionally.
|
|
27
|
+
*/
|
|
28
|
+
export async function patchMoveFilesCreateCommand(projectRoot, fromIdentifier, newPatchName, options) {
|
|
29
|
+
intro(options.dryRun === true
|
|
30
|
+
? 'FireForge patch move-files --create (dry run)'
|
|
31
|
+
: 'FireForge patch move-files --create');
|
|
32
|
+
const paths = getProjectPaths(projectRoot);
|
|
33
|
+
const config = await loadConfig(projectRoot);
|
|
34
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
35
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
36
|
+
throw new GeneralError('No patches in manifest.');
|
|
37
|
+
}
|
|
38
|
+
const source = resolvePatchIdentifier(fromIdentifier, manifest.patches);
|
|
39
|
+
if (!source) {
|
|
40
|
+
throw new InvalidArgumentError(formatPatchNotFoundError(fromIdentifier, manifest.patches), 'patch move-files');
|
|
41
|
+
}
|
|
42
|
+
const existingTarget = resolvePatchIdentifier(newPatchName, manifest.patches);
|
|
43
|
+
if (existingTarget) {
|
|
44
|
+
throw new InvalidArgumentError(`--create target "${newPatchName}" already exists as ${existingTarget.filename}. ` +
|
|
45
|
+
'Omit --create to preview a move into the existing patch, or pick a new patch name.', '--create');
|
|
46
|
+
}
|
|
47
|
+
const movedFiles = [...new Set((options.file ?? []).map((f) => f.trim()).filter(Boolean))].sort();
|
|
48
|
+
if (movedFiles.length === 0) {
|
|
49
|
+
throw new InvalidArgumentError('Specify at least one --file path to move.', '--file');
|
|
50
|
+
}
|
|
51
|
+
assertSourceOwnsFiles(source, movedFiles);
|
|
52
|
+
const movedSet = new Set(movedFiles);
|
|
53
|
+
const remainingFiles = source.filesAffected.filter((f) => !movedSet.has(f));
|
|
54
|
+
if (remainingFiles.length === 0) {
|
|
55
|
+
throw new InvalidArgumentError(`Moving every file out of ${source.filename} would leave it empty. ` +
|
|
56
|
+
'Use "fireforge patch rename" / "fireforge patch reorder" to repurpose or move the whole patch instead.', '--file');
|
|
57
|
+
}
|
|
58
|
+
const movedDiff = await buildSplitDiff(paths.engine, movedFiles, 'moved', source.filename);
|
|
59
|
+
const remainingDiff = await buildSplitDiff(paths.engine, remainingFiles, 'remaining', source.filename);
|
|
60
|
+
const category = options.category ?? source.category;
|
|
61
|
+
const placementOptions = { order: options.order };
|
|
62
|
+
const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, newPatchName, config);
|
|
63
|
+
const plan = {
|
|
64
|
+
source,
|
|
65
|
+
movedFiles,
|
|
66
|
+
remainingFiles,
|
|
67
|
+
movedDiff,
|
|
68
|
+
remainingDiff,
|
|
69
|
+
placement,
|
|
70
|
+
placementOptions,
|
|
71
|
+
category,
|
|
72
|
+
name: newPatchName,
|
|
73
|
+
description: options.description ?? '',
|
|
74
|
+
ownerRewrites: findOwnerRewriteHolders(manifest.patches, source.filename, movedSet),
|
|
75
|
+
// Populated by runProjectedSplitLint below (forward edges into the new patch).
|
|
76
|
+
stagedDependencyAdditions: new Map(),
|
|
77
|
+
};
|
|
78
|
+
// Per-patch lint both projected bodies, threading the source patch's
|
|
79
|
+
// tier/lintIgnore so an intentional-advisory patch can still move files.
|
|
80
|
+
const ignoreChecks = source.lintIgnore ? new Set(source.lintIgnore) : undefined;
|
|
81
|
+
await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
|
|
82
|
+
await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
|
|
83
|
+
const { conflicts, stagedDependencyAdditions } = await runProjectedSplitLint(paths.patches, plan);
|
|
84
|
+
plan.stagedDependencyAdditions = stagedDependencyAdditions;
|
|
85
|
+
const newMetadata = buildNewPatchMetadata(plan, config);
|
|
86
|
+
enforcePatchPolicy({
|
|
87
|
+
config,
|
|
88
|
+
manifest: projectSplitManifest(manifest, plan, newMetadata),
|
|
89
|
+
command: 'patch move-files --create',
|
|
90
|
+
forceUnsafe: options.forceUnsafe === true,
|
|
91
|
+
});
|
|
92
|
+
const decision = await confirmDestructive({
|
|
93
|
+
operation: 'patch-move-files-create',
|
|
94
|
+
title: `Create ${placement.newFilename} (order ${placement.insertionOrder}) and move ${movedFiles.length} file(s) out of ${source.filename}`,
|
|
95
|
+
summary: buildSplitSummary(plan),
|
|
96
|
+
yes: options.yes === true,
|
|
97
|
+
dryRun: options.dryRun === true,
|
|
98
|
+
unsafeOverride: options.forceUnsafe === true,
|
|
99
|
+
conflicts,
|
|
100
|
+
});
|
|
101
|
+
if (decision === 'dry-run') {
|
|
102
|
+
outro('Dry run complete — no changes made');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (decision === 'cancelled') {
|
|
106
|
+
outro('Move cancelled');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await commitPatchSplit(paths.patches, plan, newMetadata, options, config);
|
|
110
|
+
success(`Created ${placement.newFilename} (order ${String(placement.insertionOrder).padStart(3, '0')}) ` +
|
|
111
|
+
`and moved ${plan.movedFiles.length} file(s) out of ${source.filename}`);
|
|
112
|
+
if (plan.ownerRewrites.length > 0) {
|
|
113
|
+
info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
outro('Move complete');
|
|
116
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional file-ownership move into an EXISTING patch (FORGE F4).
|
|
3
|
+
*
|
|
4
|
+
* Before 0.39.0, `patch move-files <from> <to>` with an existing target was
|
|
5
|
+
* preview-only: it printed two `re-export --files` commands whose real
|
|
6
|
+
* execution cost three further refusals (`--allow-shrink`, `--yes`, and a
|
|
7
|
+
* projected `duplicate-new-file-creation` unless the SHRINK landed first —
|
|
8
|
+
* while the printed plan listed the grow first). This module performs the
|
|
9
|
+
* same shrink→grow as one transaction under the patch-directory lock with
|
|
10
|
+
* rollback, mirroring `patch split` / `move-files --create`.
|
|
11
|
+
*
|
|
12
|
+
* Preconditions match `re-export`: the engine worktree must currently
|
|
13
|
+
* reflect both patches' content, because both bodies are regenerated from
|
|
14
|
+
* the worktree.
|
|
15
|
+
*/
|
|
16
|
+
import type { PatchMetadata, PatchMoveFilesOptions, PatchStagedForwardImport } from '../../types/commands/index.js';
|
|
17
|
+
import type { FireForgeConfig } from '../../types/config.js';
|
|
18
|
+
/** Everything the commit step needs, computed and confirmed up front. */
|
|
19
|
+
export interface MoveIntoPlan {
|
|
20
|
+
source: PatchMetadata;
|
|
21
|
+
target: PatchMetadata;
|
|
22
|
+
movedFiles: string[];
|
|
23
|
+
sourceAfter: string[];
|
|
24
|
+
targetAfter: string[];
|
|
25
|
+
sourceDiff: string;
|
|
26
|
+
targetDiff: string;
|
|
27
|
+
/** Patches (by filename) whose staged-dependency owners re-point to the target. */
|
|
28
|
+
ownerRewrites: string[];
|
|
29
|
+
/** Forward-import declarations the move introduces, keyed by importing patch. */
|
|
30
|
+
stagedDependencyAdditions: Map<string, PatchStagedForwardImport[]>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Plans, lints, confirms, and commits a file-ownership move into an
|
|
34
|
+
* existing patch. Called by `patchMoveFilesCommand` after its ownership
|
|
35
|
+
* pre-checks resolved and validated both patches.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runMoveFilesInto(args: {
|
|
38
|
+
enginePath: string;
|
|
39
|
+
patchesDir: string;
|
|
40
|
+
source: PatchMetadata;
|
|
41
|
+
target: PatchMetadata;
|
|
42
|
+
files: string[];
|
|
43
|
+
sourceAfter: string[];
|
|
44
|
+
targetAfter: string[];
|
|
45
|
+
options: PatchMoveFilesOptions;
|
|
46
|
+
config: FireForgeConfig;
|
|
47
|
+
}): Promise<void>;
|