@hominis/fireforge 0.36.0 → 0.38.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 +26 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +6 -6
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +5 -1
- package/dist/src/commands/export-flow.js +10 -7
- package/dist/src/commands/export-placement-gate.js +3 -5
- 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.js +12 -5
- package/dist/src/commands/furnace/index.js +23 -18
- 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.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +49 -4
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- 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 +25 -6
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test-register.js +6 -5
- 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 +40 -23
- 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 +58 -0
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +51 -7
- 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 +6 -1
- 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-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +76 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +25 -7
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- 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 +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +69 -1
- package/dist/src/core/test-stale-check.js +134 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +84 -7
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -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
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fireforge patch move-files <from> <to>` previews the explicit
|
|
3
|
-
* re-export choreography needed to move file ownership between two
|
|
3
|
+
* re-export choreography needed to move file ownership between two
|
|
4
|
+
* patches. With `--create --order <n>` the target patch is created and
|
|
5
|
+
* the files move into it as one transaction (see move-files-create.ts).
|
|
4
6
|
*/
|
|
5
7
|
import { Command } from 'commander';
|
|
6
8
|
import type { CommandContext } from '../../types/cli.js';
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
2
|
/**
|
|
3
3
|
* `fireforge patch move-files <from> <to>` previews the explicit
|
|
4
|
-
* re-export choreography needed to move file ownership between two
|
|
4
|
+
* re-export choreography needed to move file ownership between two
|
|
5
|
+
* patches. With `--create --order <n>` the target patch is created and
|
|
6
|
+
* the files move into it as one transaction (see move-files-create.ts).
|
|
5
7
|
*/
|
|
6
8
|
import { relative } from 'node:path';
|
|
7
9
|
import { Command } from 'commander';
|
|
@@ -11,6 +13,8 @@ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-ma
|
|
|
11
13
|
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
12
14
|
import { pathExists } from '../../utils/fs.js';
|
|
13
15
|
import { info, intro, note, outro, warn } from '../../utils/logger.js';
|
|
16
|
+
import { commanderArgParser, pickDefined } from '../../utils/options.js';
|
|
17
|
+
import { patchMoveFilesCreateCommand } from './move-files-create.js';
|
|
14
18
|
function collectOption(value, previous) {
|
|
15
19
|
previous.push(value);
|
|
16
20
|
return previous;
|
|
@@ -67,6 +71,19 @@ function computeFileMovePlan(source, target, files) {
|
|
|
67
71
|
* @param options - Files to move and display mode
|
|
68
72
|
*/
|
|
69
73
|
export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdentifier, options = {}) {
|
|
74
|
+
if (options.create === true && options.order === undefined) {
|
|
75
|
+
throw new InvalidArgumentError('--create requires --order <n> to place the new patch.', '--create');
|
|
76
|
+
}
|
|
77
|
+
if (options.create !== true && options.order !== undefined) {
|
|
78
|
+
throw new InvalidArgumentError('--order is only valid together with --create.', '--order');
|
|
79
|
+
}
|
|
80
|
+
if (options.create === true && options.order !== undefined) {
|
|
81
|
+
await patchMoveFilesCreateCommand(projectRoot, fromIdentifier, toIdentifier, {
|
|
82
|
+
...options,
|
|
83
|
+
order: options.order,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
70
87
|
intro('FireForge patch move-files');
|
|
71
88
|
if (fromIdentifier === toIdentifier) {
|
|
72
89
|
throw new InvalidArgumentError('Source and target patch identifiers must be different.', 'patch move-files');
|
|
@@ -118,7 +135,8 @@ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdent
|
|
|
118
135
|
note(`${dryRunSource}\n${dryRunTarget}`, 'Preview commands');
|
|
119
136
|
note(`${applySource}\n${applyTarget}`, 'Apply commands');
|
|
120
137
|
info('Tip: to move files into a brand-new patch in one transaction (including ' +
|
|
121
|
-
'staged-dependency owner rewrites),
|
|
138
|
+
'staged-dependency owner rewrites), re-run with --create --order <n> to create ' +
|
|
139
|
+
'the target patch and move the files in one step, or use "fireforge patch split".');
|
|
122
140
|
outro('Move plan complete - no changes made');
|
|
123
141
|
}
|
|
124
142
|
/**
|
|
@@ -131,9 +149,36 @@ export function registerPatchMoveFiles(parent, context) {
|
|
|
131
149
|
const { getProjectRoot, withErrorHandling } = context;
|
|
132
150
|
parent
|
|
133
151
|
.command('move-files <from> <to>')
|
|
134
|
-
.description('Preview the re-export commands needed to move file ownership between patches
|
|
152
|
+
.description('Preview the re-export commands needed to move file ownership between patches, ' +
|
|
153
|
+
'or create the target patch and move the files in one transaction with --create --order <n>.')
|
|
135
154
|
.option('--file <path>', 'File path relative to engine/ to move (repeatable)', collectOption, [])
|
|
155
|
+
.option('--create', 'Create <to> as a new patch and move the files into it (requires --order)')
|
|
156
|
+
.option('--order <n>', 'Exact sparse order for the created patch (only valid with --create)', commanderArgParser((raw) => {
|
|
157
|
+
const n = Number.parseInt(raw, 10);
|
|
158
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
159
|
+
throw new InvalidArgumentError(`--order must be a positive integer, got "${raw}".`, '--order');
|
|
160
|
+
}
|
|
161
|
+
return n;
|
|
162
|
+
}))
|
|
163
|
+
.option('--category <category>', "Category for the created patch (default: the source patch's)")
|
|
164
|
+
.option('--description <desc>', 'Description for the created patch')
|
|
165
|
+
.option('--dry-run', 'Show what would happen without writing')
|
|
166
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
167
|
+
.option('--force-unsafe', 'Bypass projected-lint refusals')
|
|
168
|
+
.option('--skip-lint', 'Skip per-patch lint of the projected bodies')
|
|
136
169
|
.action(withErrorHandling(async (from, to, options) => {
|
|
137
|
-
await patchMoveFilesCommand(getProjectRoot(), from, to,
|
|
170
|
+
await patchMoveFilesCommand(getProjectRoot(), from, to, {
|
|
171
|
+
...pickDefined({
|
|
172
|
+
file: options.file,
|
|
173
|
+
create: options.create,
|
|
174
|
+
order: options.order,
|
|
175
|
+
category: options.category,
|
|
176
|
+
description: options.description,
|
|
177
|
+
dryRun: options.dryRun,
|
|
178
|
+
yes: options.yes,
|
|
179
|
+
forceUnsafe: options.forceUnsafe,
|
|
180
|
+
skipLint: options.skipLint,
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
138
183
|
}));
|
|
139
184
|
}
|
|
@@ -18,7 +18,17 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { Command } from 'commander';
|
|
20
20
|
import type { CommandContext } from '../../types/cli.js';
|
|
21
|
-
import type { PatchSplitOptions } from '../../types/commands/index.js';
|
|
21
|
+
import type { PatchMetadata, PatchSplitOptions } from '../../types/commands/index.js';
|
|
22
|
+
import type { FireForgeConfig } from '../../types/config.js';
|
|
23
|
+
import { type SplitPlan } from './split-plan.js';
|
|
24
|
+
/**
|
|
25
|
+
* Commits a confirmed split under the patch directory lock: renumber →
|
|
26
|
+
* write new patch body → write shrunken source body → single manifest
|
|
27
|
+
* rewrite (new row + shrunken source row + owner rewrites). On any
|
|
28
|
+
* failure the steps are rolled back in reverse order. Exported for
|
|
29
|
+
* `patch move-files --create`, which commits the same transaction shape.
|
|
30
|
+
*/
|
|
31
|
+
export declare function commitPatchSplit(patchesDir: string, plan: SplitPlan, newMetadata: PatchMetadata, options: Pick<PatchSplitOptions, 'yes' | 'forceUnsafe'>, config: FireForgeConfig): Promise<void>;
|
|
22
32
|
/**
|
|
23
33
|
* Runs the `patch split` command: plans the split, lints the projection,
|
|
24
34
|
* confirms, and commits transactionally.
|
|
@@ -38,9 +38,10 @@ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSpli
|
|
|
38
38
|
* Commits a confirmed split under the patch directory lock: renumber →
|
|
39
39
|
* write new patch body → write shrunken source body → single manifest
|
|
40
40
|
* rewrite (new row + shrunken source row + owner rewrites). On any
|
|
41
|
-
* failure the steps are rolled back in reverse order.
|
|
41
|
+
* failure the steps are rolled back in reverse order. Exported for
|
|
42
|
+
* `patch move-files --create`, which commits the same transaction shape.
|
|
42
43
|
*/
|
|
43
|
-
async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
|
|
44
|
+
export async function commitPatchSplit(patchesDir, plan, newMetadata, options, config) {
|
|
44
45
|
await withPatchDirectoryLock(patchesDir, async () => {
|
|
45
46
|
const manifest = await loadPatchesManifest(patchesDir);
|
|
46
47
|
if (!manifest)
|
|
@@ -49,7 +50,7 @@ async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
|
|
|
49
50
|
if (!current || current.filesAffected.join('\n') !== plan.source.filesAffected.join('\n')) {
|
|
50
51
|
throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
|
|
51
52
|
}
|
|
52
|
-
const currentPlacement = await resolvePlacementPlan(patchesDir, plan.placementOptions, plan.category, plan.name);
|
|
53
|
+
const currentPlacement = await resolvePlacementPlan(patchesDir, plan.placementOptions, plan.category, plan.name, config);
|
|
53
54
|
if (!placementPlansEqual(currentPlacement, plan.placement)) {
|
|
54
55
|
throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
|
|
55
56
|
}
|
|
@@ -193,7 +194,7 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
|
|
|
193
194
|
after: options.after ??
|
|
194
195
|
(options.order === undefined && options.before === undefined ? source.filename : undefined),
|
|
195
196
|
});
|
|
196
|
-
const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name);
|
|
197
|
+
const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name, config);
|
|
197
198
|
const plan = {
|
|
198
199
|
source,
|
|
199
200
|
movedFiles,
|
|
@@ -240,7 +241,7 @@ export async function patchSplitCommand(projectRoot, sourceId, options) {
|
|
|
240
241
|
outro('Split cancelled');
|
|
241
242
|
return;
|
|
242
243
|
}
|
|
243
|
-
await commitPatchSplit(paths.patches, plan, newMetadata, options);
|
|
244
|
+
await commitPatchSplit(paths.patches, plan, newMetadata, options, config);
|
|
244
245
|
success(`Split ${source.filename}: ${placement.newFilename} now owns ${plan.movedFiles.length} file(s)`);
|
|
245
246
|
if (plan.ownerRewrites.length > 0) {
|
|
246
247
|
info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
|
|
@@ -10,7 +10,7 @@ import type { PatchStagedDependencyOptions } from '../../types/commands/index.js
|
|
|
10
10
|
*
|
|
11
11
|
* @param projectRoot - Project root directory
|
|
12
12
|
* @param identifier - Patch filename, ordinal, or manifest name
|
|
13
|
-
* @param options - Mutation mode and
|
|
13
|
+
* @param options - Mutation mode, entry kind, and declaration fields
|
|
14
14
|
*/
|
|
15
15
|
export declare function patchStagedDependencyCommand(projectRoot: string, identifier: string, options?: PatchStagedDependencyOptions): Promise<void>;
|
|
16
16
|
/**
|
|
@@ -23,6 +23,19 @@ function modeFromOptions(options) {
|
|
|
23
23
|
}
|
|
24
24
|
return adding ? 'add' : removing ? 'remove' : 'clear';
|
|
25
25
|
}
|
|
26
|
+
function kindFromOptions(options) {
|
|
27
|
+
const kind = options.kind ?? 'import';
|
|
28
|
+
if (kind !== 'import' && kind !== 'registration') {
|
|
29
|
+
throw new InvalidArgumentError(`--kind must be "import" or "registration" (got "${kind}").`, 'patch staged-dependency');
|
|
30
|
+
}
|
|
31
|
+
if (kind === 'import' && options.line !== undefined) {
|
|
32
|
+
throw new InvalidArgumentError('--line only applies to --kind registration; import declarations use --specifier.', 'patch staged-dependency');
|
|
33
|
+
}
|
|
34
|
+
if (kind === 'registration' && options.specifier !== undefined) {
|
|
35
|
+
throw new InvalidArgumentError('--specifier only applies to --kind import; registration declarations use --line.', 'patch staged-dependency');
|
|
36
|
+
}
|
|
37
|
+
return kind;
|
|
38
|
+
}
|
|
26
39
|
function requireForwardImportOptions(options, mode) {
|
|
27
40
|
if (!options.file || !options.specifier || !options.creates) {
|
|
28
41
|
throw new InvalidArgumentError(`--${mode} requires --file, --specifier, and --creates.`, 'patch staged-dependency');
|
|
@@ -38,102 +51,149 @@ function requireForwardImportOptions(options, mode) {
|
|
|
38
51
|
dependency.reason = options.reason;
|
|
39
52
|
return dependency;
|
|
40
53
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
54
|
+
function requireRegistrationOptions(options, mode) {
|
|
55
|
+
if (!options.file || !options.line || !options.creates) {
|
|
56
|
+
throw new InvalidArgumentError(`--${mode} --kind registration requires --file, --line, and --creates.`, 'patch staged-dependency');
|
|
57
|
+
}
|
|
58
|
+
const dependency = {
|
|
59
|
+
file: options.file,
|
|
60
|
+
line: options.line,
|
|
61
|
+
creates: options.creates,
|
|
62
|
+
};
|
|
63
|
+
if (options.owner !== undefined)
|
|
64
|
+
dependency.owner = options.owner;
|
|
65
|
+
if (options.reason !== undefined)
|
|
66
|
+
dependency.reason = options.reason;
|
|
67
|
+
return dependency;
|
|
68
|
+
}
|
|
69
|
+
function importView(dependency) {
|
|
70
|
+
return { ...dependency, matcher: dependency.specifier };
|
|
43
71
|
}
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
const reason = dependency.reason ? ` reason="${dependency.reason}"` : '';
|
|
47
|
-
return `${dependency.file} imports "${dependency.specifier}" from ${dependency.creates}${owner}${reason}`;
|
|
72
|
+
function registrationView(dependency) {
|
|
73
|
+
return { ...dependency, matcher: dependency.line };
|
|
48
74
|
}
|
|
49
|
-
function
|
|
50
|
-
return
|
|
51
|
-
dependency.specifier !== target.specifier ||
|
|
52
|
-
dependency.creates !== target.creates ||
|
|
53
|
-
(target.owner !== undefined && dependency.owner !== target.owner));
|
|
75
|
+
function dependencyKey(view) {
|
|
76
|
+
return [view.file, view.matcher, view.creates, view.owner ?? ''].join('\0');
|
|
54
77
|
}
|
|
55
|
-
function
|
|
78
|
+
function dependencyLabel(kind, view) {
|
|
79
|
+
const owner = view.owner ? ` owner=${view.owner}` : '';
|
|
80
|
+
const reason = view.reason ? ` reason="${view.reason}"` : '';
|
|
81
|
+
if (kind === 'import') {
|
|
82
|
+
return `${view.file} imports "${view.matcher}" from ${view.creates}${owner}${reason}`;
|
|
83
|
+
}
|
|
84
|
+
return `${view.file} registers "${view.matcher}" for ${view.creates}${owner}${reason}`;
|
|
85
|
+
}
|
|
86
|
+
function matchesTarget(view, target) {
|
|
87
|
+
return (view.file === target.file &&
|
|
88
|
+
view.matcher === target.matcher &&
|
|
89
|
+
view.creates === target.creates &&
|
|
90
|
+
(target.owner === undefined || view.owner === target.owner));
|
|
91
|
+
}
|
|
92
|
+
function applyMode(existing, mode, target, toView, addition) {
|
|
56
93
|
if (mode === 'clear')
|
|
57
94
|
return [];
|
|
58
|
-
if (!
|
|
95
|
+
if (!target)
|
|
59
96
|
return [...existing];
|
|
60
97
|
if (mode === 'remove')
|
|
61
|
-
return
|
|
62
|
-
const seen = new Set(existing.map(dependencyKey));
|
|
63
|
-
if (seen.has(dependencyKey(
|
|
98
|
+
return existing.filter((entry) => !matchesTarget(toView(entry), target));
|
|
99
|
+
const seen = new Set(existing.map((entry) => dependencyKey(toView(entry))));
|
|
100
|
+
if (seen.has(dependencyKey(target)) || addition === undefined)
|
|
64
101
|
return [...existing];
|
|
65
|
-
return [...existing,
|
|
102
|
+
return [...existing, addition];
|
|
66
103
|
}
|
|
67
104
|
/**
|
|
68
105
|
* Renders a one-line summary of a staged-dependency metadata change.
|
|
69
106
|
*/
|
|
70
|
-
function describeStagedDependencyChange(
|
|
107
|
+
function describeStagedDependencyChange(beforeCount, afterCount, mode, kind, target) {
|
|
108
|
+
const noun = kind === 'import' ? 'staged forward-import' : 'staged registration';
|
|
71
109
|
if (mode === 'clear') {
|
|
72
|
-
return
|
|
110
|
+
return beforeCount === 0
|
|
73
111
|
? 'stagedDependencies was already empty — no change'
|
|
74
|
-
: `cleared ${
|
|
112
|
+
: `cleared ${beforeCount} staged dependency declaration(s)`;
|
|
75
113
|
}
|
|
76
|
-
if (!
|
|
114
|
+
if (!target)
|
|
77
115
|
return 'stagedDependencies unchanged';
|
|
78
116
|
if (mode === 'add') {
|
|
79
|
-
return
|
|
80
|
-
?
|
|
81
|
-
: `added
|
|
117
|
+
return afterCount === beforeCount
|
|
118
|
+
? `${noun} already present: ${dependencyLabel(kind, target)}`
|
|
119
|
+
: `added ${noun}: ${dependencyLabel(kind, target)}`;
|
|
82
120
|
}
|
|
83
|
-
return
|
|
84
|
-
? `no
|
|
85
|
-
: `removed ${
|
|
121
|
+
return afterCount === beforeCount
|
|
122
|
+
? `no ${noun} matched: ${dependencyLabel(kind, target)}`
|
|
123
|
+
: `removed ${beforeCount - afterCount} ${noun} declaration(s): ${dependencyLabel(kind, target)}`;
|
|
124
|
+
}
|
|
125
|
+
function countStagedEntries(staged) {
|
|
126
|
+
return (staged?.forwardImports?.length ?? 0) + (staged?.registrations?.length ?? 0);
|
|
86
127
|
}
|
|
87
128
|
/**
|
|
88
129
|
* Runs the metadata-only staged-dependency mutation command.
|
|
89
130
|
*
|
|
90
131
|
* @param projectRoot - Project root directory
|
|
91
132
|
* @param identifier - Patch filename, ordinal, or manifest name
|
|
92
|
-
* @param options - Mutation mode and
|
|
133
|
+
* @param options - Mutation mode, entry kind, and declaration fields
|
|
93
134
|
*/
|
|
94
135
|
export async function patchStagedDependencyCommand(projectRoot, identifier, options = {}) {
|
|
95
136
|
const isDryRun = options.dryRun === true;
|
|
96
137
|
intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
|
|
97
138
|
const mode = modeFromOptions(options);
|
|
98
|
-
const
|
|
139
|
+
const kind = kindFromOptions(options);
|
|
140
|
+
const importDependency = mode === 'clear' || kind !== 'import' ? undefined : requireForwardImportOptions(options, mode);
|
|
141
|
+
const registrationDependency = mode === 'clear' || kind !== 'registration'
|
|
142
|
+
? undefined
|
|
143
|
+
: requireRegistrationOptions(options, mode);
|
|
144
|
+
const target = importDependency !== undefined
|
|
145
|
+
? importView(importDependency)
|
|
146
|
+
: registrationDependency !== undefined
|
|
147
|
+
? registrationView(registrationDependency)
|
|
148
|
+
: undefined;
|
|
99
149
|
const { paths, manifest } = await requirePatchQueue(projectRoot);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
150
|
+
const targetPatch = requirePatchTarget(identifier, manifest.patches);
|
|
151
|
+
const applyToStaged = (staged) => {
|
|
152
|
+
if (mode === 'clear')
|
|
153
|
+
return {};
|
|
154
|
+
const forwardImports = applyMode(staged?.forwardImports ?? [], kind === 'import' ? mode : 'add', kind === 'import' ? target : undefined, importView, importDependency);
|
|
155
|
+
const registrations = applyMode(staged?.registrations ?? [], kind === 'registration' ? mode : 'add', kind === 'registration' ? target : undefined, registrationView, registrationDependency);
|
|
156
|
+
return {
|
|
157
|
+
...(forwardImports.length > 0 ? { forwardImports } : {}),
|
|
158
|
+
...(registrations.length > 0 ? { registrations } : {}),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
const existing = targetPatch.stagedDependencies;
|
|
162
|
+
const projected = applyToStaged(existing);
|
|
163
|
+
const summary = describeStagedDependencyChange(countStagedEntries(existing), countStagedEntries(projected), mode, kind, target);
|
|
104
164
|
if (isDryRun) {
|
|
105
|
-
info(`[dry-run] ${
|
|
165
|
+
info(`[dry-run] ${targetPatch.filename}: ${summary}.`);
|
|
106
166
|
outro('Dry run complete — no changes made');
|
|
107
167
|
return;
|
|
108
168
|
}
|
|
109
|
-
const result = await mutatePatchMetadata(paths.patches,
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
if (after.length === 0)
|
|
169
|
+
const result = await mutatePatchMetadata(paths.patches, targetPatch.filename, (current) => {
|
|
170
|
+
const after = applyToStaged(current.stagedDependencies);
|
|
171
|
+
if (countStagedEntries(after) === 0)
|
|
113
172
|
return { unset: ['stagedDependencies'] };
|
|
114
|
-
return { set: { stagedDependencies:
|
|
173
|
+
return { set: { stagedDependencies: after } };
|
|
115
174
|
});
|
|
116
175
|
if (!result) {
|
|
117
|
-
throw new GeneralError(`Patch ${
|
|
176
|
+
throw new GeneralError(`Patch ${targetPatch.filename} disappeared from the manifest during the update. Re-run after investigating.`);
|
|
118
177
|
}
|
|
119
|
-
const before = result.before.stagedDependencies
|
|
120
|
-
const after = result.after.stagedDependencies
|
|
121
|
-
info(`${
|
|
178
|
+
const before = result.before.stagedDependencies;
|
|
179
|
+
const after = result.after.stagedDependencies;
|
|
180
|
+
info(`${targetPatch.filename}: ${describeStagedDependencyChange(countStagedEntries(before), countStagedEntries(after), mode, kind, target)}.`);
|
|
122
181
|
try {
|
|
123
182
|
await appendHistory(paths.patches, {
|
|
124
183
|
operation: 'patch-staged-dependency',
|
|
125
184
|
args: {
|
|
126
|
-
filename:
|
|
185
|
+
filename: targetPatch.filename,
|
|
127
186
|
mode,
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
kind,
|
|
188
|
+
before: before ?? {},
|
|
189
|
+
after: after ?? {},
|
|
130
190
|
},
|
|
131
191
|
...(options.yes === true ? { yes: true } : {}),
|
|
132
192
|
result: 'ok',
|
|
133
193
|
});
|
|
134
194
|
}
|
|
135
195
|
catch (historyError) {
|
|
136
|
-
warn(`History log append failed after patch staged-dependency committed (${
|
|
196
|
+
warn(`History log append failed after patch staged-dependency committed (${targetPatch.filename}): ${toError(historyError).message}`);
|
|
137
197
|
}
|
|
138
198
|
outro('Patch staged-dependency complete');
|
|
139
199
|
}
|
|
@@ -148,11 +208,13 @@ export function registerPatchStagedDependency(parent, context) {
|
|
|
148
208
|
parent
|
|
149
209
|
.command('staged-dependency <name>')
|
|
150
210
|
.description('Edit PatchMetadata.stagedDependencies on a single patch (no .patch body rewrite).')
|
|
151
|
-
.option('--add', 'Add a staged
|
|
152
|
-
.option('--remove', 'Remove matching staged
|
|
211
|
+
.option('--add', 'Add a staged dependency declaration')
|
|
212
|
+
.option('--remove', 'Remove matching staged dependency declaration(s)')
|
|
153
213
|
.option('--clear', 'Drop the stagedDependencies field entirely')
|
|
154
|
-
.option('--
|
|
155
|
-
.option('--
|
|
214
|
+
.option('--kind <kind>', 'Declaration shape: "import" (forward import, the default) or "registration" (jar.mn packaging line, customElements or actor registration)')
|
|
215
|
+
.option('--file <path>', 'Declaring file path relative to engine/')
|
|
216
|
+
.option('--specifier <specifier>', 'Exact import specifier as it appears in source (--kind import)')
|
|
217
|
+
.option('--line <text>', 'Registration/packaging line as the patch adds it, compared whitespace-trimmed (--kind registration)')
|
|
156
218
|
.option('--creates <path>', 'Later-created file path relative to engine/')
|
|
157
219
|
.option('--owner <patch>', 'Exact later patch filename expected to create --creates')
|
|
158
220
|
.option('--reason <text>', 'Human-readable rationale stored with the declaration')
|