@hominis/fireforge 0.30.0 → 0.31.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 (141) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +22 -5
  3. package/dist/src/commands/export-all.js +5 -15
  4. package/dist/src/commands/export-flow.d.ts +6 -0
  5. package/dist/src/commands/export-flow.js +6 -1
  6. package/dist/src/commands/export-placement-gate.d.ts +38 -0
  7. package/dist/src/commands/export-placement-gate.js +105 -0
  8. package/dist/src/commands/export-shared.d.ts +28 -0
  9. package/dist/src/commands/export-shared.js +36 -0
  10. package/dist/src/commands/export.js +47 -112
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
  13. package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
  14. package/dist/src/commands/furnace/create.d.ts +1 -2
  15. package/dist/src/commands/furnace/deploy.js +36 -114
  16. package/dist/src/commands/furnace/refresh.js +52 -32
  17. package/dist/src/commands/furnace/sync.js +2 -0
  18. package/dist/src/commands/import.js +108 -73
  19. package/dist/src/commands/lint-per-patch.d.ts +1 -1
  20. package/dist/src/commands/lint-per-patch.js +119 -78
  21. package/dist/src/commands/lint.d.ts +1 -58
  22. package/dist/src/commands/lint.js +96 -84
  23. package/dist/src/commands/patch/compact.d.ts +5 -2
  24. package/dist/src/commands/patch/compact.js +85 -25
  25. package/dist/src/commands/patch/delete.js +17 -17
  26. package/dist/src/commands/patch/index.js +2 -0
  27. package/dist/src/commands/patch/lint-ignore.js +3 -16
  28. package/dist/src/commands/patch/move-files.js +2 -0
  29. package/dist/src/commands/patch/patch-context.d.ts +41 -0
  30. package/dist/src/commands/patch/patch-context.js +53 -0
  31. package/dist/src/commands/patch/rename.js +10 -15
  32. package/dist/src/commands/patch/reorder.d.ts +0 -2
  33. package/dist/src/commands/patch/reorder.js +18 -19
  34. package/dist/src/commands/patch/split-plan.d.ts +66 -0
  35. package/dist/src/commands/patch/split-plan.js +178 -0
  36. package/dist/src/commands/patch/split.d.ts +30 -0
  37. package/dist/src/commands/patch/split.js +283 -0
  38. package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
  39. package/dist/src/commands/patch/staged-dependency.js +4 -17
  40. package/dist/src/commands/patch/tier.js +4 -17
  41. package/dist/src/commands/re-export-scan.js +8 -1
  42. package/dist/src/commands/rebase/summary.d.ts +1 -5
  43. package/dist/src/commands/rebase/summary.js +1 -1
  44. package/dist/src/commands/status-output.js +77 -68
  45. package/dist/src/commands/test-diagnose.d.ts +23 -0
  46. package/dist/src/commands/test-diagnose.js +210 -0
  47. package/dist/src/commands/test-run.d.ts +58 -0
  48. package/dist/src/commands/test-run.js +88 -0
  49. package/dist/src/commands/test.js +169 -257
  50. package/dist/src/commands/token.js +15 -1
  51. package/dist/src/commands/wire.js +109 -78
  52. package/dist/src/core/build-audit.d.ts +1 -1
  53. package/dist/src/core/build-audit.js +2 -46
  54. package/dist/src/core/build-baseline-types.d.ts +38 -0
  55. package/dist/src/core/build-baseline-types.js +10 -0
  56. package/dist/src/core/build-baseline.d.ts +1 -31
  57. package/dist/src/core/build-prepare.d.ts +1 -1
  58. package/dist/src/core/build-prepare.js +2 -45
  59. package/dist/src/core/config-paths.d.ts +0 -8
  60. package/dist/src/core/config-paths.js +4 -4
  61. package/dist/src/core/config-state.d.ts +0 -6
  62. package/dist/src/core/config-state.js +1 -1
  63. package/dist/src/core/config-validate-patch-policy.js +12 -13
  64. package/dist/src/core/config-validate.js +48 -28
  65. package/dist/src/core/engine-changes.d.ts +24 -0
  66. package/dist/src/core/engine-changes.js +64 -0
  67. package/dist/src/core/firefox-cache.d.ts +0 -5
  68. package/dist/src/core/firefox-cache.js +1 -1
  69. package/dist/src/core/firefox-download.d.ts +0 -6
  70. package/dist/src/core/firefox-download.js +1 -1
  71. package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
  72. package/dist/src/core/furnace-apply-helpers.js +11 -20
  73. package/dist/src/core/furnace-apply.d.ts +1 -1
  74. package/dist/src/core/furnace-apply.js +1 -1
  75. package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
  76. package/dist/src/core/furnace-checksum-utils.js +15 -0
  77. package/dist/src/core/furnace-config-validate.d.ts +31 -0
  78. package/dist/src/core/furnace-config-validate.js +133 -0
  79. package/dist/src/core/furnace-config.d.ts +4 -32
  80. package/dist/src/core/furnace-config.js +15 -111
  81. package/dist/src/core/furnace-constants.d.ts +0 -10
  82. package/dist/src/core/furnace-constants.js +2 -2
  83. package/dist/src/core/furnace-css-fragments.d.ts +79 -0
  84. package/dist/src/core/furnace-css-fragments.js +243 -0
  85. package/dist/src/core/furnace-jsconfig.d.ts +63 -0
  86. package/dist/src/core/furnace-jsconfig.js +171 -0
  87. package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
  88. package/dist/src/core/furnace-validate-helpers.js +40 -1
  89. package/dist/src/core/furnace-validate-registration.js +16 -1
  90. package/dist/src/core/furnace-validate.js +54 -2
  91. package/dist/src/core/git-file-ops.d.ts +0 -12
  92. package/dist/src/core/git-file-ops.js +2 -2
  93. package/dist/src/core/lint-cache.d.ts +3 -13
  94. package/dist/src/core/lint-cache.js +11 -5
  95. package/dist/src/core/mach.d.ts +5 -1
  96. package/dist/src/core/mach.js +6 -2
  97. package/dist/src/core/manifest-register.d.ts +5 -16
  98. package/dist/src/core/manifest-register.js +3 -1
  99. package/dist/src/core/patch-lint-checkjs.js +53 -7
  100. package/dist/src/core/patch-lint-jsdoc.js +63 -4
  101. package/dist/src/core/patch-lint-observer.d.ts +37 -0
  102. package/dist/src/core/patch-lint-observer.js +168 -0
  103. package/dist/src/core/patch-lint.js +132 -125
  104. package/dist/src/core/patch-manifest-io.d.ts +16 -0
  105. package/dist/src/core/patch-manifest-io.js +44 -2
  106. package/dist/src/core/patch-manifest-validate.d.ts +1 -8
  107. package/dist/src/core/patch-manifest-validate.js +1 -1
  108. package/dist/src/core/patch-manifest.d.ts +1 -1
  109. package/dist/src/core/patch-manifest.js +1 -1
  110. package/dist/src/core/patch-policy.d.ts +0 -4
  111. package/dist/src/core/patch-policy.js +10 -4
  112. package/dist/src/core/register-browser-content.d.ts +1 -1
  113. package/dist/src/core/register-module.d.ts +1 -1
  114. package/dist/src/core/register-result.d.ts +21 -0
  115. package/dist/src/core/register-result.js +9 -0
  116. package/dist/src/core/register-shared-css.d.ts +1 -1
  117. package/dist/src/core/register-test-manifest.d.ts +1 -1
  118. package/dist/src/core/test-harness-crash.d.ts +61 -0
  119. package/dist/src/core/test-harness-crash.js +140 -0
  120. package/dist/src/core/test-stale-check.d.ts +1 -1
  121. package/dist/src/core/test-stale-check.js +2 -46
  122. package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
  123. package/dist/src/core/test-xpcshell-retry.js +4 -2
  124. package/dist/src/core/token-dark-mode.js +14 -26
  125. package/dist/src/core/token-manager.d.ts +4 -0
  126. package/dist/src/core/token-manager.js +70 -16
  127. package/dist/src/core/typecheck-shim.d.ts +0 -21
  128. package/dist/src/core/typecheck-shim.js +26 -4
  129. package/dist/src/core/wire-utils.js +37 -44
  130. package/dist/src/types/commands/index.d.ts +1 -1
  131. package/dist/src/types/commands/options.d.ts +105 -0
  132. package/dist/src/types/furnace.d.ts +12 -1
  133. package/dist/src/utils/elapsed.d.ts +0 -2
  134. package/dist/src/utils/elapsed.js +1 -1
  135. package/dist/src/utils/fs.d.ts +0 -5
  136. package/dist/src/utils/fs.js +1 -1
  137. package/dist/src/utils/regex.d.ts +0 -6
  138. package/dist/src/utils/regex.js +3 -3
  139. package/dist/src/utils/validation.d.ts +0 -8
  140. package/dist/src/utils/validation.js +2 -2
  141. package/package.json +6 -4
@@ -3,38 +3,96 @@
3
3
  * `fireforge patch compact` — closes ordinal gaps in the patch queue.
4
4
  *
5
5
  * After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
6
- * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
7
- * in a single atomic operation, preserving relative order.
6
+ * This command renumbers patches to close those gaps in a single atomic
7
+ * operation, preserving relative order. Without a patch policy the whole
8
+ * queue is renumbered from 1; with `patchPolicy.ranges` configured the
9
+ * compaction is range-aware (each category range compacts independently,
10
+ * reserved ranges and out-of-range strays are left untouched).
8
11
  */
9
- import { getProjectPaths, loadConfig } from '../../core/config.js';
12
+ import { loadConfig } from '../../core/config.js';
10
13
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
11
14
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
12
15
  import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
13
16
  import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
14
17
  import { GeneralError } from '../../errors/base.js';
15
18
  import { toError } from '../../utils/errors.js';
16
- import { pathExists } from '../../utils/fs.js';
17
19
  import { info, intro, outro, warn } from '../../utils/logger.js';
18
20
  import { pickDefined } from '../../utils/options.js';
21
+ import { requirePatchQueue } from './patch-context.js';
19
22
  import { rebuildFilenameForOrder } from './reorder.js';
23
+ /** True when `order` falls inside a configured reserved range. */
24
+ function isReservedOrder(policyCfg, order) {
25
+ return (policyCfg.reservedRanges ?? []).some((r) => order >= r.from && order <= r.to);
26
+ }
20
27
  /**
21
- * Computes a rename map that assigns sequential ordinals (1, 2, 3, …)
22
- * to all patches, sorted by their current order.
28
+ * Computes a rename map that closes ordinal gaps.
29
+ *
30
+ * Without a patch policy, all patches are renumbered to 1, 2, 3, … in
31
+ * current sort order (historical behaviour). With `patchPolicy.ranges`
32
+ * configured, compaction happens *within* each category range instead:
33
+ * each range's members are renumbered consecutively starting at the
34
+ * range's first occupied ordinal, skipping reserved orders — mirroring
35
+ * what `evaluateGaps` treats as gapless under `allowGaps: false`.
36
+ * Reserved-range patches and patches outside their category's range are
37
+ * never moved (a global renumber would project them across range
38
+ * boundaries and trip `category-range` refusals).
23
39
  */
24
- function computeCompactRenameMap(patches) {
25
- const sorted = [...patches].sort((a, b) => a.order - b.order);
40
+ function computeCompactRenameMap(patches, policyCfg) {
41
+ if (!policyCfg || policyCfg.ranges.length === 0) {
42
+ const sorted = [...patches].sort((a, b) => a.order - b.order);
43
+ const renames = new Map();
44
+ for (const [i, patch] of sorted.entries()) {
45
+ const newOrder = i + 1;
46
+ if (patch.order !== newOrder) {
47
+ renames.set(patch.filename, {
48
+ newOrder,
49
+ newFilename: rebuildFilenameForOrder(patch, newOrder),
50
+ });
51
+ }
52
+ }
53
+ return renames;
54
+ }
26
55
  const renames = new Map();
27
- for (const [i, patch] of sorted.entries()) {
28
- const newOrder = i + 1;
29
- if (patch.order !== newOrder) {
30
- renames.set(patch.filename, {
31
- newOrder,
32
- newFilename: rebuildFilenameForOrder(patch, newOrder),
33
- });
56
+ for (const range of policyCfg.ranges) {
57
+ const members = patches
58
+ .filter((p) => p.category === range.category &&
59
+ p.order >= range.from &&
60
+ p.order <= range.to &&
61
+ !isReservedOrder(policyCfg, p.order))
62
+ .sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
63
+ if (members.length === 0)
64
+ continue;
65
+ // Anchor at the first occupied ordinal rather than range.from: gap
66
+ // evaluation only requires contiguity between first and last occupied,
67
+ // and anchoring minimizes renames.
68
+ let next = members[0].order;
69
+ for (const patch of members) {
70
+ while (isReservedOrder(policyCfg, next))
71
+ next++;
72
+ if (patch.order !== next) {
73
+ renames.set(patch.filename, {
74
+ newOrder: next,
75
+ newFilename: rebuildFilenameForOrder(patch, next),
76
+ });
77
+ }
78
+ next++;
34
79
  }
35
80
  }
36
81
  return renames;
37
82
  }
83
+ /**
84
+ * Patches a range-aware compact leaves in place because they sit outside
85
+ * their category's configured range (or outside all ranges) without a
86
+ * reserved-range exception. They already violate `category-range`; moving
87
+ * them is a policy decision compact must not make silently.
88
+ */
89
+ function findCompactStrays(patches, policyCfg) {
90
+ return patches.filter((p) => {
91
+ if (isReservedOrder(policyCfg, p.order))
92
+ return false;
93
+ return !policyCfg.ranges.some((range) => range.category === p.category && p.order >= range.from && p.order <= range.to);
94
+ });
95
+ }
38
96
  /**
39
97
  * Runs the `patch compact` command: renumbers all patches to close ordinal
40
98
  * gaps in a single atomic operation.
@@ -44,16 +102,18 @@ function computeCompactRenameMap(patches) {
44
102
  */
45
103
  export async function patchCompactCommand(projectRoot, options = {}) {
46
104
  intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
47
- const paths = getProjectPaths(projectRoot);
48
105
  const config = await loadConfig(projectRoot);
49
- if (!(await pathExists(paths.patches))) {
50
- throw new GeneralError('Patches directory not found.');
51
- }
52
- const manifest = await loadPatchesManifest(paths.patches);
53
- if (!manifest || manifest.patches.length === 0) {
54
- throw new GeneralError('No patches in manifest.');
106
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
107
+ const policyCfg = config.patchPolicy;
108
+ if (policyCfg && policyCfg.ranges.length > 0) {
109
+ const strays = findCompactStrays(manifest.patches, policyCfg);
110
+ for (const stray of strays) {
111
+ warn(`${stray.filename} (order ${stray.order}, category ${stray.category}) sits outside its ` +
112
+ 'configured category range; compact leaves it in place. Use "fireforge patch reorder" ' +
113
+ 'to move it into range first.');
114
+ }
55
115
  }
56
- const renameMap = computeCompactRenameMap(manifest.patches);
116
+ const renameMap = computeCompactRenameMap(manifest.patches, policyCfg);
57
117
  if (renameMap.size === 0) {
58
118
  info('Patch queue is already compact. Nothing to do.');
59
119
  outro('Compact complete (no-op)');
@@ -91,7 +151,7 @@ export async function patchCompactCommand(projectRoot, options = {}) {
91
151
  if (!currentManifest) {
92
152
  throw new GeneralError('Manifest disappeared while waiting for lock.');
93
153
  }
94
- const currentRenameMap = computeCompactRenameMap(currentManifest.patches);
154
+ const currentRenameMap = computeCompactRenameMap(currentManifest.patches, policyCfg);
95
155
  if (currentRenameMap.size === 0) {
96
156
  info('Patch queue was compacted by another process. Nothing to do.');
97
157
  return;
@@ -138,7 +198,7 @@ export function registerPatchCompact(parent, context) {
138
198
  const { getProjectRoot, withErrorHandling } = context;
139
199
  parent
140
200
  .command('compact')
141
- .description('Close ordinal gaps in the patch queue (renumber sequentially)')
201
+ .description('Close ordinal gaps in the patch queue (range-aware when patchPolicy.ranges is configured)')
142
202
  .option('--dry-run', 'Show what would happen without writing')
143
203
  .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
144
204
  .option('--force-unsafe', 'Bypass force-mode patchPolicy refusals')
@@ -8,17 +8,14 @@
8
8
  * `--dry-run`, and appends to `patches/.fireforge-history.jsonl` on success.
9
9
  */
10
10
  import { basename } from 'node:path';
11
- import { getProjectPaths } from '../../core/config.js';
12
11
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
13
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
14
12
  import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
15
13
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
16
- import { loadPatchesManifest, removePatchFileAndManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
17
- import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
14
+ import { removePatchFileAndManifest } from '../../core/patch-manifest.js';
18
15
  import { toError } from '../../utils/errors.js';
19
- import { pathExists } from '../../utils/fs.js';
20
16
  import { info, intro, outro, warn } from '../../utils/logger.js';
21
17
  import { pickDefined } from '../../utils/options.js';
18
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
22
19
  /**
23
20
  * Runs the `patch delete` command: removes a patch file and its manifest
24
21
  * row atomically, refusing when a later patch imports a leaf owned by the
@@ -30,18 +27,10 @@ import { pickDefined } from '../../utils/options.js';
30
27
  */
31
28
  export async function patchDeleteCommand(projectRoot, identifier, options = {}) {
32
29
  intro(options.dryRun ? 'FireForge patch delete (dry run)' : 'FireForge patch delete');
33
- const paths = getProjectPaths(projectRoot);
34
- if (!(await pathExists(paths.patches))) {
35
- throw new GeneralError('Patches directory not found. No patches to delete.');
36
- }
37
- const manifest = await loadPatchesManifest(paths.patches);
38
- if (!manifest || manifest.patches.length === 0) {
39
- throw new GeneralError('No patches in manifest.');
40
- }
41
- const target = resolvePatchIdentifier(identifier, manifest.patches);
42
- if (!target) {
43
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
44
- }
30
+ const { paths, manifest } = await requirePatchQueue(projectRoot, {
31
+ missingDirMessage: 'Patches directory not found. No patches to delete.',
32
+ });
33
+ const target = requirePatchTarget(identifier, manifest.patches);
45
34
  // Build the full queue context once so we can scan each patch's newFiles
46
35
  // without re-parsing for the dependency check below.
47
36
  const baseCtx = await buildPatchQueueContext(paths.patches);
@@ -113,6 +102,17 @@ export async function patchDeleteCommand(projectRoot, identifier, options = {})
113
102
  break;
114
103
  }
115
104
  }
105
+ // Staged-dependency declarations on other patches may name the deleted
106
+ // patch as their forward-import owner. The dangling reference also
107
+ // surfaces via cross-patch lint later, but warning here puts the exact
108
+ // cleanup command in front of the operator at decision time.
109
+ const danglingOwnerHolders = baseCtx.entries.filter((entry) => entry.filename !== target.filename &&
110
+ (entry.metadata?.stagedDependencies?.forwardImports ?? []).some((fi) => fi.owner === target.filename));
111
+ for (const holder of danglingOwnerHolders) {
112
+ warn(`${holder.filename} declares a staged dependency with owner ${target.filename}; ` +
113
+ `after the delete, update it via "fireforge patch staged-dependency ${holder.filename} --remove ..." ` +
114
+ 'or re-point the owner at the patch that will create the file.');
115
+ }
116
116
  const conflicts = dependents.length > 0
117
117
  ? {
118
118
  // Wording deliberately clarifies the *runtime* impact: `git apply`
@@ -13,6 +13,7 @@ import { registerPatchLintIgnore } from './lint-ignore.js';
13
13
  import { registerPatchMoveFiles } from './move-files.js';
14
14
  import { registerPatchRename } from './rename.js';
15
15
  import { registerPatchReorder } from './reorder.js';
16
+ import { registerPatchSplit } from './split.js';
16
17
  import { registerPatchStagedDependency } from './staged-dependency.js';
17
18
  import { registerPatchTier } from './tier.js';
18
19
  /**
@@ -40,6 +41,7 @@ export function registerPatch(program, context) {
40
41
  registerPatchMoveFiles(patch, context);
41
42
  registerPatchRename(patch, context);
42
43
  registerPatchReorder(patch, context);
44
+ registerPatchSplit(patch, context);
43
45
  registerPatchStagedDependency(patch, context);
44
46
  registerPatchTier(patch, context);
45
47
  }
@@ -19,15 +19,12 @@
19
19
  * write — important when an operator scripts repeated invocations or
20
20
  * runs `--add` and `--remove` back-to-back.
21
21
  */
22
- import { getProjectPaths } from '../../core/config.js';
23
22
  import { appendHistory } from '../../core/destructive.js';
24
23
  import { mutatePatchMetadata } from '../../core/patch-export.js';
25
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
26
- import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
27
24
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
28
25
  import { toError } from '../../utils/errors.js';
29
- import { pathExists } from '../../utils/fs.js';
30
26
  import { info, intro, outro, warn } from '../../utils/logger.js';
27
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
31
28
  /**
32
29
  * Computes the post-mutation `lintIgnore` list for a given mode.
33
30
  * Returns `undefined` when the result should drop the field from the
@@ -108,18 +105,8 @@ export async function patchLintIgnoreCommand(projectRoot, identifier, options =
108
105
  }
109
106
  const mode = adding ? 'add' : removing ? 'remove' : 'clear';
110
107
  const values = mode === 'add' ? (options.add ?? []) : mode === 'remove' ? (options.remove ?? []) : [];
111
- const paths = getProjectPaths(projectRoot);
112
- if (!(await pathExists(paths.patches))) {
113
- throw new GeneralError('Patches directory not found.');
114
- }
115
- const manifest = await loadPatchesManifest(paths.patches);
116
- if (!manifest || manifest.patches.length === 0) {
117
- throw new GeneralError('No patches in manifest.');
118
- }
119
- const target = resolvePatchIdentifier(identifier, manifest.patches);
120
- if (!target) {
121
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
122
- }
108
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
109
+ const target = requirePatchTarget(identifier, manifest.patches);
123
110
  if (isDryRun) {
124
111
  const existing = target.lintIgnore ?? [];
125
112
  const projected = applyMode(existing, mode, values) ?? [];
@@ -116,6 +116,8 @@ export async function patchMoveFilesCommand(projectRoot, fromIdentifier, toIdent
116
116
  const applyTarget = formatReExportCommand(target.filename, targetAfter, []);
117
117
  note(`${dryRunSource}\n${dryRunTarget}`, 'Preview commands');
118
118
  note(`${applySource}\n${applyTarget}`, 'Apply commands');
119
+ info('Tip: to move files into a brand-new patch in one transaction (including ' +
120
+ 'staged-dependency owner rewrites), use "fireforge patch split" instead.');
119
121
  outro('Move plan complete - no changes made');
120
122
  }
121
123
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared preamble for the patch subcommands: every mutation command starts
3
+ * by loading the project paths and the patches manifest and (for the
4
+ * single-patch commands) resolving the operator-supplied identifier. The
5
+ * sequence and its error wording were previously copied into each command;
6
+ * this module is the single source for both.
7
+ */
8
+ import type { PatchesManifest, PatchMetadata } from '../../types/commands/index.js';
9
+ import type { ProjectPaths } from '../../types/config.js';
10
+ /** Resolved project paths plus the non-empty patches manifest. */
11
+ export interface PatchQueueContext {
12
+ /** Project paths resolved from the project root. */
13
+ paths: ProjectPaths;
14
+ /** The loaded manifest; guaranteed to contain at least one patch. */
15
+ manifest: PatchesManifest;
16
+ }
17
+ /**
18
+ * Loads the project paths and the patches manifest, throwing the shared
19
+ * command-preamble errors when the patches directory is missing or the
20
+ * manifest has no patches.
21
+ *
22
+ * @param projectRoot - Root directory of the project
23
+ * @param options - Optional overrides for the preamble error wording
24
+ * @param options.missingDirMessage - Replacement for the default
25
+ * "Patches directory not found." error (e.g. `patch delete` appends
26
+ * "No patches to delete.")
27
+ * @returns The resolved paths and the non-empty manifest
28
+ */
29
+ export declare function requirePatchQueue(projectRoot: string, options?: {
30
+ missingDirMessage?: string;
31
+ }): Promise<PatchQueueContext>;
32
+ /**
33
+ * Resolves an operator-supplied patch identifier (order number, filename,
34
+ * or unique name fragment) against the manifest, throwing the shared
35
+ * not-found error with suggestions when no patch matches.
36
+ *
37
+ * @param identifier - Identifier as passed on the command line
38
+ * @param patches - Manifest rows to resolve against
39
+ * @returns The matching manifest row
40
+ */
41
+ export declare function requirePatchTarget(identifier: string, patches: PatchMetadata[]): PatchMetadata;
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shared preamble for the patch subcommands: every mutation command starts
4
+ * by loading the project paths and the patches manifest and (for the
5
+ * single-patch commands) resolving the operator-supplied identifier. The
6
+ * sequence and its error wording were previously copied into each command;
7
+ * this module is the single source for both.
8
+ */
9
+ import { getProjectPaths } from '../../core/config.js';
10
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
11
+ import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
12
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
13
+ import { pathExists } from '../../utils/fs.js';
14
+ /**
15
+ * Loads the project paths and the patches manifest, throwing the shared
16
+ * command-preamble errors when the patches directory is missing or the
17
+ * manifest has no patches.
18
+ *
19
+ * @param projectRoot - Root directory of the project
20
+ * @param options - Optional overrides for the preamble error wording
21
+ * @param options.missingDirMessage - Replacement for the default
22
+ * "Patches directory not found." error (e.g. `patch delete` appends
23
+ * "No patches to delete.")
24
+ * @returns The resolved paths and the non-empty manifest
25
+ */
26
+ export async function requirePatchQueue(projectRoot, options = {}) {
27
+ const paths = getProjectPaths(projectRoot);
28
+ if (!(await pathExists(paths.patches))) {
29
+ throw new GeneralError(options.missingDirMessage ?? 'Patches directory not found.');
30
+ }
31
+ const manifest = await loadPatchesManifest(paths.patches);
32
+ if (!manifest || manifest.patches.length === 0) {
33
+ throw new GeneralError('No patches in manifest.');
34
+ }
35
+ return { paths, manifest };
36
+ }
37
+ /**
38
+ * Resolves an operator-supplied patch identifier (order number, filename,
39
+ * or unique name fragment) against the manifest, throwing the shared
40
+ * not-found error with suggestions when no patch matches.
41
+ *
42
+ * @param identifier - Identifier as passed on the command line
43
+ * @param patches - Manifest rows to resolve against
44
+ * @returns The matching manifest row
45
+ */
46
+ export function requirePatchTarget(identifier, patches) {
47
+ const target = resolvePatchIdentifier(identifier, patches);
48
+ if (!target) {
49
+ throw new InvalidArgumentError(formatPatchNotFoundError(identifier, patches), identifier);
50
+ }
51
+ return target;
52
+ }
53
+ //# sourceMappingURL=patch-context.js.map
@@ -18,18 +18,18 @@
18
18
  */
19
19
  import { rename as fsRename } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
- import { getProjectPaths, loadConfig } from '../../core/config.js';
21
+ import { loadConfig } from '../../core/config.js';
22
22
  import { appendHistory, confirmDestructive } from '../../core/destructive.js';
23
23
  import { sanitizeName } from '../../core/patch-export.js';
24
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
24
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
26
- import { loadPatchesManifest, resolvePatchIdentifier, savePatchesManifest, } from '../../core/patch-manifest.js';
25
+ import { loadPatchesManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from '../../core/patch-manifest.js';
27
26
  import { buildProjectedManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
28
27
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
29
28
  import { toError } from '../../utils/errors.js';
30
29
  import { pathExists } from '../../utils/fs.js';
31
30
  import { info, intro, outro, warn } from '../../utils/logger.js';
32
31
  import { pickDefined } from '../../utils/options.js';
32
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
33
33
  /**
34
34
  * Pulls the ordinal-string + category prefix out of a patch filename so
35
35
  * the rename keeps the existing ordinal padding verbatim. Returning the
@@ -85,6 +85,11 @@ async function commitRenameUnderLock(input) {
85
85
  name: newName,
86
86
  ...(descriptionChanging ? { description: newDescription ?? '' } : {}),
87
87
  };
88
+ // Staged-dependency owners on other patches reference the old
89
+ // filename; remap them so forward-import declarations survive the
90
+ // rename instead of dangling.
91
+ const ownerLookup = (old) => old === target.filename ? newFilename : undefined;
92
+ fresh.patches = fresh.patches.map((p) => rewriteStagedDependencyOwners(p, ownerLookup));
88
93
  }
89
94
  else {
90
95
  fresh.patches[idx] = {
@@ -151,19 +156,9 @@ export async function patchRenameCommand(projectRoot, identifier, options = {})
151
156
  if (options.to === undefined || options.to.trim() === '') {
152
157
  throw new InvalidArgumentError('Specify --to <new-name>. The new name is sanitised into the filename slug the same way `export --name` is.', 'patch rename');
153
158
  }
154
- const paths = getProjectPaths(projectRoot);
155
159
  const config = await loadConfig(projectRoot);
156
- if (!(await pathExists(paths.patches))) {
157
- throw new GeneralError('Patches directory not found.');
158
- }
159
- const manifest = await loadPatchesManifest(paths.patches);
160
- if (!manifest || manifest.patches.length === 0) {
161
- throw new GeneralError('No patches in manifest.');
162
- }
163
- const target = resolvePatchIdentifier(identifier, manifest.patches);
164
- if (!target) {
165
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
166
- }
160
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
161
+ const target = requirePatchTarget(identifier, manifest.patches);
167
162
  const split = splitPatchFilename(target.filename);
168
163
  if (!split) {
169
164
  throw new GeneralError(`Cannot rename ${target.filename}: filename does not match the expected {ordinal}-{category}-{slug}.patch convention. Re-export the patch instead.`);
@@ -10,8 +10,6 @@
10
10
  import { Command } from 'commander';
11
11
  import type { CommandContext } from '../../types/cli.js';
12
12
  import type { PatchMetadata, PatchReorderOptions } from '../../types/commands/index.js';
13
- /** Zero-pads an ordinal number to the given width. */
14
- export declare function padOrder(value: number, width: number): string;
15
13
  /** Builds a new patch filename by replacing the numeric prefix with `newOrder`. */
16
14
  export declare function rebuildFilenameForOrder(existing: PatchMetadata, newOrder: number): string;
17
15
  /**
@@ -9,21 +9,20 @@
9
9
  * before any bytes move.
10
10
  */
11
11
  import { Option } from 'commander';
12
- import { getProjectPaths, loadConfig } from '../../core/config.js';
12
+ import { loadConfig } from '../../core/config.js';
13
13
  import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
14
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
15
14
  import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
16
15
  import { withPatchDirectoryLock } from '../../core/patch-lock.js';
17
- import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, } from '../../core/patch-manifest.js';
16
+ import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, rewriteStagedDependencyOwners, } from '../../core/patch-manifest.js';
18
17
  import { applyRenameMapToManifest, enforcePatchPolicy } from '../../core/patch-policy.js';
19
18
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
20
19
  import { toError } from '../../utils/errors.js';
21
- import { pathExists } from '../../utils/fs.js';
22
20
  import { info, intro, outro, warn } from '../../utils/logger.js';
23
21
  import { pickDefined } from '../../utils/options.js';
24
22
  import { parsePositiveIntegerFlag } from '../../utils/validation.js';
23
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
25
24
  /** Zero-pads an ordinal number to the given width. */
26
- export function padOrder(value, width) {
25
+ function padOrder(value, width) {
27
26
  return String(value).padStart(width, '0');
28
27
  }
29
28
  /** Builds a new patch filename by replacing the numeric prefix with `newOrder`. */
@@ -123,12 +122,22 @@ function renameMapsEqual(left, right) {
123
122
  * can run against the projected state without touching disk.
124
123
  */
125
124
  function projectReorder(base, renameMap) {
125
+ const ownerLookup = (oldFilename) => renameMap.get(oldFilename)?.newFilename;
126
126
  const projectedEntries = base.entries.map((entry) => {
127
+ // Project staged-dependency owner references through the rename map on
128
+ // every entry — owners point at *other* patches' filenames, so a
129
+ // projection that skips non-renamed entries would lint against stale
130
+ // owners and report false forward-import regressions.
131
+ const metadata = entry.metadata
132
+ ? rewriteStagedDependencyOwners(entry.metadata, ownerLookup)
133
+ : entry.metadata;
127
134
  const rename = renameMap.get(entry.filename);
128
- if (!rename)
129
- return entry;
135
+ if (!rename) {
136
+ return metadata === entry.metadata ? entry : { ...entry, metadata };
137
+ }
130
138
  return {
131
139
  ...entry,
140
+ metadata,
132
141
  filename: rename.newFilename,
133
142
  order: rename.newOrder,
134
143
  };
@@ -268,19 +277,9 @@ export async function patchReorderCommand(projectRoot, identifier, options = {})
268
277
  if (specifiedTargets > 1) {
269
278
  throw new InvalidArgumentError('--to, --before, and --after are mutually exclusive.', 'patch reorder');
270
279
  }
271
- const paths = getProjectPaths(projectRoot);
272
280
  const config = await loadConfig(projectRoot);
273
- if (!(await pathExists(paths.patches))) {
274
- throw new GeneralError('Patches directory not found.');
275
- }
276
- const manifest = await loadPatchesManifest(paths.patches);
277
- if (!manifest || manifest.patches.length === 0) {
278
- throw new GeneralError('No patches in manifest.');
279
- }
280
- const target = resolvePatchIdentifier(identifier, manifest.patches);
281
- if (!target) {
282
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
283
- }
281
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
282
+ const target = requirePatchTarget(identifier, manifest.patches);
284
283
  const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
285
284
  const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
286
285
  if (renameMap.size === 0) {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Planning helpers for `fireforge patch split`: ownership guards, diff
3
+ * construction from the worktree, staged-dependency owner-rewrite
4
+ * discovery, cross-patch lint projection, and policy-manifest projection.
5
+ * Split out of `split.ts` to keep both files within the per-file line
6
+ * budget; consumed only by the split command.
7
+ */
8
+ import { type ConflictReport } from '../../core/destructive.js';
9
+ import { buildProjectedManifest } from '../../core/patch-policy.js';
10
+ import type { PatchCategory, PatchMetadata } from '../../types/commands/index.js';
11
+ import type { FireForgeConfig } from '../../types/config.js';
12
+ import { type PlacementPlan } from '../export-flow.js';
13
+ /** Everything the commit step needs, computed and confirmed up front. */
14
+ export interface SplitPlan {
15
+ source: PatchMetadata;
16
+ movedFiles: string[];
17
+ remainingFiles: string[];
18
+ movedDiff: string;
19
+ remainingDiff: string;
20
+ placement: PlacementPlan;
21
+ /** Effective placement flags (with the after-source default applied). */
22
+ placementOptions: {
23
+ order?: number;
24
+ before?: string;
25
+ after?: string;
26
+ };
27
+ category: PatchCategory;
28
+ name: string;
29
+ description: string;
30
+ /** Patches (by current filename) whose staged-dependency owners re-point to the new patch. */
31
+ ownerRewrites: string[];
32
+ }
33
+ /**
34
+ *
35
+ */
36
+ export declare function assertSourceOwnsFiles(source: PatchMetadata, files: readonly string[]): void;
37
+ /**
38
+ *
39
+ */
40
+ export declare function buildSplitDiff(engineDir: string, files: readonly string[], label: string, sourceFilename: string): Promise<string>;
41
+ /**
42
+ * Finds patches declaring a staged-dependency forward-import whose `owner`
43
+ * is the source patch and whose `creates` path moves to the new patch.
44
+ */
45
+ export declare function findOwnerRewriteHolders(patches: readonly PatchMetadata[], sourceFilename: string, movedSet: ReadonlySet<string>): string[];
46
+ /** Rewrites split-affected owners on one manifest row. */
47
+ export declare function rewriteSplitOwners(patch: PatchMetadata, sourceFilename: string, movedSet: ReadonlySet<string>, newFilename: string): PatchMetadata;
48
+ /**
49
+ * Projects the full split (renumber + shrunken source + synthetic new
50
+ * patch + owner rewrites) through cross-patch lint, reporting only the
51
+ * regressions the split itself would introduce.
52
+ */
53
+ export declare function runProjectedSplitLint(patchesDir: string, plan: SplitPlan): Promise<ConflictReport | null>;
54
+ /** Builds the projected manifest for policy enforcement. */
55
+ export declare function projectSplitManifest(manifest: {
56
+ version: 1;
57
+ patches: PatchMetadata[];
58
+ }, plan: SplitPlan, newMetadata: PatchMetadata): ReturnType<typeof buildProjectedManifest>;
59
+ /**
60
+ *
61
+ */
62
+ export declare function buildNewPatchMetadata(plan: SplitPlan, config: FireForgeConfig): PatchMetadata;
63
+ /**
64
+ *
65
+ */
66
+ export declare function buildSplitSummary(plan: SplitPlan): string[];