@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +6 -6
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  6. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  7. package/dist/src/commands/doctor.js +2 -0
  8. package/dist/src/commands/download.js +5 -2
  9. package/dist/src/commands/export-flow.d.ts +5 -1
  10. package/dist/src/commands/export-flow.js +10 -7
  11. package/dist/src/commands/export-placement-gate.js +3 -5
  12. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  13. package/dist/src/commands/export-placement-policy.js +32 -29
  14. package/dist/src/commands/export.js +12 -5
  15. package/dist/src/commands/furnace/index.js +23 -18
  16. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  17. package/dist/src/commands/patch/move-files-create.js +116 -0
  18. package/dist/src/commands/patch/move-files.d.ts +3 -1
  19. package/dist/src/commands/patch/move-files.js +49 -4
  20. package/dist/src/commands/patch/split.d.ts +11 -1
  21. package/dist/src/commands/patch/split.js +6 -5
  22. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  23. package/dist/src/commands/patch/staged-dependency.js +113 -51
  24. package/dist/src/commands/re-export-files.js +4 -0
  25. package/dist/src/commands/re-export-scan.d.ts +17 -0
  26. package/dist/src/commands/re-export-scan.js +51 -0
  27. package/dist/src/commands/re-export.js +25 -6
  28. package/dist/src/commands/source.js +28 -3
  29. package/dist/src/commands/test-diagnose.js +20 -1
  30. package/dist/src/commands/test-register.js +6 -5
  31. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  32. package/dist/src/commands/test-stale-gate.js +81 -0
  33. package/dist/src/commands/test.js +40 -23
  34. package/dist/src/core/build-audit.d.ts +12 -0
  35. package/dist/src/core/build-audit.js +14 -0
  36. package/dist/src/core/build-baseline-types.d.ts +58 -0
  37. package/dist/src/core/build-baseline.d.ts +10 -2
  38. package/dist/src/core/build-baseline.js +51 -7
  39. package/dist/src/core/config-paths.d.ts +1 -1
  40. package/dist/src/core/config-paths.js +1 -0
  41. package/dist/src/core/config-validate.js +6 -1
  42. package/dist/src/core/engine-session-lock.d.ts +10 -1
  43. package/dist/src/core/engine-session-lock.js +28 -2
  44. package/dist/src/core/file-lock.d.ts +33 -0
  45. package/dist/src/core/file-lock.js +32 -6
  46. package/dist/src/core/firefox-archive.d.ts +2 -1
  47. package/dist/src/core/firefox-archive.js +32 -8
  48. package/dist/src/core/firefox.d.ts +8 -3
  49. package/dist/src/core/firefox.js +11 -6
  50. package/dist/src/core/furnace-config-custom.js +10 -0
  51. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  52. package/dist/src/core/furnace-stale-export.js +123 -0
  53. package/dist/src/core/furnace-validate-compatibility.js +9 -2
  54. package/dist/src/core/furnace-validate-helpers.js +28 -1
  55. package/dist/src/core/furnace-validate-structure.js +3 -2
  56. package/dist/src/core/license-headers.d.ts +12 -0
  57. package/dist/src/core/license-headers.js +76 -1
  58. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  59. package/dist/src/core/mach-known-noise-filter.js +193 -0
  60. package/dist/src/core/mach.d.ts +8 -0
  61. package/dist/src/core/mach.js +36 -3
  62. package/dist/src/core/patch-lint-cross.js +80 -2
  63. package/dist/src/core/patch-lint.d.ts +3 -0
  64. package/dist/src/core/patch-lint.js +25 -7
  65. package/dist/src/core/patch-manifest-io.d.ts +3 -2
  66. package/dist/src/core/patch-manifest-io.js +27 -16
  67. package/dist/src/core/patch-manifest-validate.js +24 -0
  68. package/dist/src/core/status-classify.d.ts +11 -2
  69. package/dist/src/core/status-classify.js +49 -25
  70. package/dist/src/core/test-harness-crash.d.ts +16 -0
  71. package/dist/src/core/test-harness-crash.js +55 -0
  72. package/dist/src/core/test-stale-check.d.ts +69 -1
  73. package/dist/src/core/test-stale-check.js +134 -1
  74. package/dist/src/types/commands/index.d.ts +1 -1
  75. package/dist/src/types/commands/options.d.ts +84 -7
  76. package/dist/src/types/commands/patches.d.ts +29 -0
  77. package/dist/src/types/config.d.ts +7 -0
  78. package/dist/src/types/furnace.d.ts +13 -0
  79. package/dist/src/utils/options.d.ts +20 -0
  80. package/dist/src/utils/options.js +39 -0
  81. package/dist/src/utils/process-group.d.ts +33 -0
  82. package/dist/src/utils/process-group.js +161 -0
  83. package/dist/src/utils/process.d.ts +15 -0
  84. package/dist/src/utils/process.js +88 -44
  85. package/dist/src/utils/validation.d.ts +5 -0
  86. package/dist/src/utils/validation.js +7 -0
  87. 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
- .action(withErrorHandling(async (name, options) => {
35
- const parsed = pickDefined(options ?? {});
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
- .action(withErrorHandling(async (name, options) => {
53
- const parsed = pickDefined(options ?? {});
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
- .action(withErrorHandling(async (options) => {
70
- const parsed = pickDefined(options);
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 patches.
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 patches.
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), use "fireforge patch split" instead.');
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, options);
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 forward-import fields
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 dependencyKey(dependency) {
42
- return [dependency.file, dependency.specifier, dependency.creates, dependency.owner ?? ''].join('\0');
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 dependencyLabel(dependency) {
45
- const owner = dependency.owner ? ` owner=${dependency.owner}` : '';
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 removeMatching(existing, target) {
50
- return existing.filter((dependency) => dependency.file !== target.file ||
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 applyMode(existing, mode, dependency) {
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 (!dependency)
95
+ if (!target)
59
96
  return [...existing];
60
97
  if (mode === 'remove')
61
- return removeMatching(existing, dependency);
62
- const seen = new Set(existing.map(dependencyKey));
63
- if (seen.has(dependencyKey(dependency)))
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, dependency];
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(before, after, mode, dependency) {
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 before.length === 0
110
+ return beforeCount === 0
73
111
  ? 'stagedDependencies was already empty — no change'
74
- : `cleared ${before.length} staged forward-import declaration(s)`;
112
+ : `cleared ${beforeCount} staged dependency declaration(s)`;
75
113
  }
76
- if (!dependency)
114
+ if (!target)
77
115
  return 'stagedDependencies unchanged';
78
116
  if (mode === 'add') {
79
- return after.length === before.length
80
- ? `staged forward-import already present: ${dependencyLabel(dependency)}`
81
- : `added staged forward-import: ${dependencyLabel(dependency)}`;
117
+ return afterCount === beforeCount
118
+ ? `${noun} already present: ${dependencyLabel(kind, target)}`
119
+ : `added ${noun}: ${dependencyLabel(kind, target)}`;
82
120
  }
83
- return after.length === before.length
84
- ? `no staged forward-import matched: ${dependencyLabel(dependency)}`
85
- : `removed ${before.length - after.length} staged forward-import declaration(s): ${dependencyLabel(dependency)}`;
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 forward-import fields
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 dependency = mode === 'clear' ? undefined : requireForwardImportOptions(options, mode);
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 target = requirePatchTarget(identifier, manifest.patches);
101
- const existing = target.stagedDependencies?.forwardImports ?? [];
102
- const projected = applyMode(existing, mode, dependency);
103
- const summary = describeStagedDependencyChange(existing, projected, mode, dependency);
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] ${target.filename}: ${summary}.`);
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, target.filename, (current) => {
110
- const before = current.stagedDependencies?.forwardImports ?? [];
111
- const after = applyMode(before, mode, dependency);
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: { forwardImports: after } } };
173
+ return { set: { stagedDependencies: after } };
115
174
  });
116
175
  if (!result) {
117
- throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during the update. Re-run after investigating.`);
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?.forwardImports ?? [];
120
- const after = result.after.stagedDependencies?.forwardImports ?? [];
121
- info(`${target.filename}: ${describeStagedDependencyChange(before, after, mode, dependency)}.`);
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: target.filename,
185
+ filename: targetPatch.filename,
127
186
  mode,
128
- before,
129
- after,
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 (${target.filename}): ${toError(historyError).message}`);
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 forward-import declaration')
152
- .option('--remove', 'Remove matching staged forward-import declaration(s)')
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('--file <path>', 'Importing file path relative to engine/')
155
- .option('--specifier <specifier>', 'Exact import specifier as it appears in source')
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')