@hominis/fireforge 0.30.1 → 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 +25 -0
  2. package/README.md +22 -0
  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 +110 -81
  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 +0 -13
  94. package/dist/src/core/lint-cache.js +5 -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
@@ -0,0 +1,178 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Planning helpers for `fireforge patch split`: ownership guards, diff
4
+ * construction from the worktree, staged-dependency owner-rewrite
5
+ * discovery, cross-patch lint projection, and policy-manifest projection.
6
+ * Split out of `split.ts` to keep both files within the per-file line
7
+ * budget; consumed only by the split command.
8
+ */
9
+ import { join } from 'node:path';
10
+ import { getDiffForFilesAgainstHead } from '../../core/git-diff.js';
11
+ import { computeProjectedLintRegressions } from '../../core/lint-projection.js';
12
+ import { extractAffectedFiles } from '../../core/patch-apply.js';
13
+ import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../../core/patch-lint.js';
14
+ import { rewriteStagedDependencyOwners } from '../../core/patch-manifest.js';
15
+ import { applyRenameMapToManifest, buildProjectedManifest } from '../../core/patch-policy.js';
16
+ import { buildPatchSourceMetadata } from '../../core/patch-source-metadata.js';
17
+ import { extractNewFileContentFromDiff } from '../../core/patch-transform.js';
18
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
19
+ import { pathExists } from '../../utils/fs.js';
20
+ import { warn } from '../../utils/logger.js';
21
+ /**
22
+ *
23
+ */
24
+ export function assertSourceOwnsFiles(source, files) {
25
+ const owned = new Set(source.filesAffected);
26
+ const missing = files.filter((file) => !owned.has(file));
27
+ if (missing.length > 0) {
28
+ throw new InvalidArgumentError(`${source.filename} does not currently own ${missing.length} requested file(s): ${missing.join(', ')}. ` +
29
+ 'Run "fireforge status --ownership" to inspect current ownership.', '--files');
30
+ }
31
+ }
32
+ /**
33
+ *
34
+ */
35
+ export async function buildSplitDiff(engineDir, files, label, sourceFilename) {
36
+ for (const file of files) {
37
+ if (!(await pathExists(join(engineDir, file)))) {
38
+ throw new GeneralError(`Cannot split ${sourceFilename}: ${label} file is missing from the engine worktree: ${file}. ` +
39
+ 'Run "fireforge import" (or restore the file) so the worktree reflects the patch content first.');
40
+ }
41
+ }
42
+ const diff = await getDiffForFilesAgainstHead(engineDir, [...files]);
43
+ if (!diff.trim()) {
44
+ throw new GeneralError(`Cannot split ${sourceFilename}: the ${label} file set produces an empty diff against HEAD. ` +
45
+ 'The worktree must currently carry the patch content (run "fireforge import" first).');
46
+ }
47
+ const actual = new Set(extractAffectedFiles(diff));
48
+ const noHunks = files.filter((file) => !actual.has(file));
49
+ if (noHunks.length > 0) {
50
+ throw new GeneralError(`Cannot split ${sourceFilename}: ${noHunks.length} ${label} file(s) produced no diff hunks ` +
51
+ `(${noHunks.join(', ')}). The worktree does not carry their patch content.`);
52
+ }
53
+ return diff;
54
+ }
55
+ /**
56
+ * Finds patches declaring a staged-dependency forward-import whose `owner`
57
+ * is the source patch and whose `creates` path moves to the new patch.
58
+ */
59
+ export function findOwnerRewriteHolders(patches, sourceFilename, movedSet) {
60
+ return patches
61
+ .filter((patch) => (patch.stagedDependencies?.forwardImports ?? []).some((fi) => fi.owner === sourceFilename && movedSet.has(fi.creates)))
62
+ .map((patch) => patch.filename);
63
+ }
64
+ /** Rewrites split-affected owners on one manifest row. */
65
+ export function rewriteSplitOwners(patch, sourceFilename, movedSet, newFilename) {
66
+ const forwardImports = patch.stagedDependencies?.forwardImports;
67
+ if (!forwardImports?.some((fi) => fi.owner === sourceFilename && movedSet.has(fi.creates))) {
68
+ return patch;
69
+ }
70
+ return {
71
+ ...patch,
72
+ stagedDependencies: {
73
+ ...patch.stagedDependencies,
74
+ forwardImports: forwardImports.map((fi) => fi.owner === sourceFilename && movedSet.has(fi.creates) ? { ...fi, owner: newFilename } : fi),
75
+ },
76
+ };
77
+ }
78
+ function buildEntryProjection(diff) {
79
+ const newFiles = new Map();
80
+ for (const path of detectNewFilesInDiff(diff)) {
81
+ newFiles.set(path, extractNewFileContentFromDiff(diff, path));
82
+ }
83
+ return { diff, newFiles, modifiedFileAdditions: buildModifiedFileAdditionsFromDiff(diff) };
84
+ }
85
+ /**
86
+ * Projects the full split (renumber + shrunken source + synthetic new
87
+ * patch + owner rewrites) through cross-patch lint, reporting only the
88
+ * regressions the split itself would introduce.
89
+ */
90
+ export async function runProjectedSplitLint(patchesDir, plan) {
91
+ const movedSet = new Set(plan.movedFiles);
92
+ const ownerLookup = (old) => plan.placement.renameMap.get(old)?.newFilename;
93
+ const baseCtx = await buildPatchQueueContext(patchesDir);
94
+ const projectedEntries = baseCtx.entries.map((entry) => {
95
+ let metadata = entry.metadata;
96
+ if (metadata) {
97
+ metadata = rewriteStagedDependencyOwners(metadata, ownerLookup);
98
+ metadata = rewriteSplitOwners(metadata, plan.source.filename, movedSet, plan.placement.newFilename);
99
+ }
100
+ const rename = plan.placement.renameMap.get(entry.filename);
101
+ const base = rename
102
+ ? { ...entry, metadata, filename: rename.newFilename, order: rename.newOrder }
103
+ : { ...entry, metadata };
104
+ if (entry.filename !== plan.source.filename)
105
+ return base;
106
+ return { ...base, ...buildEntryProjection(plan.remainingDiff) };
107
+ });
108
+ projectedEntries.push({
109
+ filename: plan.placement.newFilename,
110
+ order: plan.placement.insertionOrder,
111
+ metadata: null,
112
+ ...buildEntryProjection(plan.movedDiff),
113
+ });
114
+ projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
115
+ const baselineIssues = lintPatchQueue(baseCtx).filter((i) => i.severity === 'error');
116
+ const projectedIssues = lintPatchQueue({ entries: projectedEntries }).filter((i) => i.severity === 'error');
117
+ const regressions = computeProjectedLintRegressions(baselineIssues, projectedIssues);
118
+ if (baselineIssues.length > 0 && regressions.length === 0) {
119
+ warn(`Note: projected queue still has ${baselineIssues.length} pre-existing cross-patch ` +
120
+ 'error(s) unrelated to this split. Run "fireforge verify" to list them.');
121
+ }
122
+ if (regressions.length === 0)
123
+ return null;
124
+ return {
125
+ reason: `split would introduce ${regressions.length} cross-patch lint error(s)`,
126
+ details: regressions.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
127
+ };
128
+ }
129
+ /** Builds the projected manifest for policy enforcement. */
130
+ export function projectSplitManifest(manifest, plan, newMetadata) {
131
+ const movedSet = new Set(plan.movedFiles);
132
+ const renamed = applyRenameMapToManifest(manifest, plan.placement.renameMap);
133
+ const effectiveSourceFilename = plan.placement.renameMap.get(plan.source.filename)?.newFilename ?? plan.source.filename;
134
+ const patched = renamed.patches.map((patch) => {
135
+ const withOwners = rewriteSplitOwners(patch, effectiveSourceFilename, movedSet, plan.placement.newFilename);
136
+ if (patch.filename !== effectiveSourceFilename)
137
+ return withOwners;
138
+ return { ...withOwners, filesAffected: plan.remainingFiles };
139
+ });
140
+ return buildProjectedManifest(renamed, [...patched, newMetadata]);
141
+ }
142
+ /**
143
+ *
144
+ */
145
+ export function buildNewPatchMetadata(plan, config) {
146
+ return {
147
+ filename: plan.placement.newFilename,
148
+ order: plan.placement.insertionOrder,
149
+ category: plan.category,
150
+ name: plan.name,
151
+ description: plan.description,
152
+ createdAt: new Date().toISOString(),
153
+ ...buildPatchSourceMetadata(config.firefox),
154
+ filesAffected: plan.movedFiles,
155
+ };
156
+ }
157
+ /**
158
+ *
159
+ */
160
+ export function buildSplitSummary(plan) {
161
+ const summary = [
162
+ `split ${plan.source.filename}`,
163
+ `moved files (${plan.movedFiles.length}): ${plan.movedFiles.join(', ')}`,
164
+ `source keeps (${plan.remainingFiles.length}): ${plan.remainingFiles.join(', ')}`,
165
+ `new patch: ${plan.placement.newFilename} (order ${plan.placement.insertionOrder})`,
166
+ ];
167
+ if (plan.placement.renameMap.size > 0) {
168
+ summary.push(`${plan.placement.renameMap.size} existing patch(es) renumbered to make room:`);
169
+ for (const [oldName, entry] of [...plan.placement.renameMap.entries()].sort((a, b) => a[1].newOrder - b[1].newOrder)) {
170
+ summary.push(` ${oldName} → ${entry.newFilename}`);
171
+ }
172
+ }
173
+ if (plan.ownerRewrites.length > 0) {
174
+ summary.push(`staged-dependency owners re-pointed to the new patch in: ${plan.ownerRewrites.join(', ')}`);
175
+ }
176
+ return summary;
177
+ }
178
+ //# sourceMappingURL=split-plan.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `fireforge patch split <source> --files <p...> --name <n>` — moves files
3
+ * out of an existing patch into a brand-new patch as one transaction
4
+ * (field report A4).
5
+ *
6
+ * Before this command, splitting a patch whose files have inbound
7
+ * forward-imports required a precise manual order: re-point each
8
+ * staged-dependency owner at a not-yet-created patch, shrink the source
9
+ * via `re-export --files --allow-shrink`, then `export --order` — any
10
+ * other order refused or needed `--force-unsafe`. Split performs the
11
+ * shrink, the new-patch creation, and the dependent owner rewrites under
12
+ * one patch-directory lock with rollback, validating only the final
13
+ * projection.
14
+ *
15
+ * Preconditions match `re-export`: the engine worktree must currently
16
+ * reflect both patches' content, because both bodies are regenerated from
17
+ * the worktree.
18
+ */
19
+ import { Command } from 'commander';
20
+ import type { CommandContext } from '../../types/cli.js';
21
+ import type { PatchSplitOptions } from '../../types/commands/index.js';
22
+ /**
23
+ * Runs the `patch split` command: plans the split, lints the projection,
24
+ * confirms, and commits transactionally.
25
+ */
26
+ export declare function patchSplitCommand(projectRoot: string, sourceId: string, options: PatchSplitOptions): Promise<void>;
27
+ /**
28
+ * Registers the `patch split` subcommand on the `patch` parent.
29
+ */
30
+ export declare function registerPatchSplit(parent: Command, context: CommandContext): void;
@@ -0,0 +1,283 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch split <source> --files <p...> --name <n>` — moves files
4
+ * out of an existing patch into a brand-new patch as one transaction
5
+ * (field report A4).
6
+ *
7
+ * Before this command, splitting a patch whose files have inbound
8
+ * forward-imports required a precise manual order: re-point each
9
+ * staged-dependency owner at a not-yet-created patch, shrink the source
10
+ * via `re-export --files --allow-shrink`, then `export --order` — any
11
+ * other order refused or needed `--force-unsafe`. Split performs the
12
+ * shrink, the new-patch creation, and the dependent owner rewrites under
13
+ * one patch-directory lock with rollback, validating only the final
14
+ * projection.
15
+ *
16
+ * Preconditions match `re-export`: the engine worktree must currently
17
+ * reflect both patches' content, because both bodies are regenerated from
18
+ * the worktree.
19
+ */
20
+ import { join } from 'node:path';
21
+ import { getProjectPaths, loadConfig } from '../../core/config.js';
22
+ import { appendHistory, confirmDestructive } from '../../core/destructive.js';
23
+ import { normalizePatchArtifact } from '../../core/patch-artifact-normalize.js';
24
+ import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
25
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
26
+ import { loadPatchesManifest, renumberPatchesInManifest, resolvePatchIdentifier, savePatchesManifest, validatePatchesManifest, } from '../../core/patch-manifest.js';
27
+ import { enforcePatchPolicy } from '../../core/patch-policy.js';
28
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
29
+ import { toError } from '../../utils/errors.js';
30
+ import { readText, removeFile, writeText } from '../../utils/fs.js';
31
+ import { info, intro, outro, success, warn } from '../../utils/logger.js';
32
+ import { pickDefined } from '../../utils/options.js';
33
+ import { placementPlansEqual, resolvePlacementPlan } from '../export-flow.js';
34
+ import { runPatchLint } from '../export-shared.js';
35
+ import { assertSourceOwnsFiles, buildNewPatchMetadata, buildSplitDiff, buildSplitSummary, findOwnerRewriteHolders, projectSplitManifest, rewriteSplitOwners, runProjectedSplitLint, } from './split-plan.js';
36
+ /**
37
+ * Commits a confirmed split under the patch directory lock: renumber →
38
+ * write new patch body → write shrunken source body → single manifest
39
+ * rewrite (new row + shrunken source row + owner rewrites). On any
40
+ * failure the steps are rolled back in reverse order.
41
+ */
42
+ async function commitPatchSplit(patchesDir, plan, newMetadata, options) {
43
+ await withPatchDirectoryLock(patchesDir, async () => {
44
+ const manifest = await loadPatchesManifest(patchesDir);
45
+ if (!manifest)
46
+ throw new GeneralError('Manifest disappeared while waiting for lock.');
47
+ const current = manifest.patches.find((p) => p.filename === plan.source.filename);
48
+ if (!current || current.filesAffected.join('\n') !== plan.source.filesAffected.join('\n')) {
49
+ throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
50
+ }
51
+ const currentPlacement = await resolvePlacementPlan(patchesDir, plan.placementOptions, plan.category, plan.name);
52
+ if (!placementPlansEqual(currentPlacement, plan.placement)) {
53
+ throw new InvalidArgumentError('Patch queue changed while waiting for split confirmation. Re-run the command.', 'patch split');
54
+ }
55
+ const movedSet = new Set(plan.movedFiles);
56
+ const effectiveSourceFilename = plan.placement.renameMap.get(plan.source.filename)?.newFilename ?? plan.source.filename;
57
+ const newPatchPath = join(patchesDir, plan.placement.newFilename);
58
+ const sourcePathBefore = join(patchesDir, plan.source.filename);
59
+ const sourcePathAfter = join(patchesDir, effectiveSourceFilename);
60
+ const originalSourceBody = await readText(sourcePathBefore);
61
+ let renumberApplied = false;
62
+ let newPatchWritten = false;
63
+ let sourceRewritten = false;
64
+ try {
65
+ if (plan.placement.renameMap.size > 0) {
66
+ await renumberPatchesInManifest(patchesDir, plan.placement.renameMap);
67
+ renumberApplied = true;
68
+ }
69
+ await writeText(newPatchPath, normalizePatchArtifact(plan.movedDiff));
70
+ newPatchWritten = true;
71
+ await writeText(sourcePathAfter, normalizePatchArtifact(plan.remainingDiff));
72
+ sourceRewritten = true;
73
+ const fresh = await loadPatchesManifest(patchesDir);
74
+ if (!fresh)
75
+ throw new GeneralError('Manifest disappeared during split commit.');
76
+ const updatedPatches = fresh.patches.map((patch) => {
77
+ const withOwners = rewriteSplitOwners(patch, effectiveSourceFilename, movedSet, plan.placement.newFilename);
78
+ if (patch.filename !== effectiveSourceFilename)
79
+ return withOwners;
80
+ return { ...withOwners, filesAffected: plan.remainingFiles };
81
+ });
82
+ updatedPatches.push(newMetadata);
83
+ updatedPatches.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
84
+ const updated = validatePatchesManifest({ ...fresh, patches: updatedPatches });
85
+ await savePatchesManifest(patchesDir, updated);
86
+ try {
87
+ await appendHistory(patchesDir, {
88
+ operation: 'patch-split',
89
+ args: {
90
+ source: effectiveSourceFilename,
91
+ newFilename: plan.placement.newFilename,
92
+ order: plan.placement.insertionOrder,
93
+ files: plan.movedFiles,
94
+ ownerRewrites: plan.ownerRewrites,
95
+ renames: [...plan.placement.renameMap.entries()].map(([from, entry]) => ({
96
+ from,
97
+ to: entry.newFilename,
98
+ })),
99
+ },
100
+ ...(options.yes === true ? { yes: true } : {}),
101
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
102
+ result: 'ok',
103
+ });
104
+ }
105
+ catch (historyError) {
106
+ warn(`History log append failed after patch split committed: ${toError(historyError).message}`);
107
+ }
108
+ }
109
+ catch (error) {
110
+ // Reverse-order rollback; each step warns on its own failure so the
111
+ // original error stays visible.
112
+ if (sourceRewritten) {
113
+ try {
114
+ await writeText(sourcePathAfter, originalSourceBody);
115
+ }
116
+ catch (rollbackError) {
117
+ warn(`Rollback warning: could not restore source body: ${toError(rollbackError).message}`);
118
+ }
119
+ }
120
+ if (newPatchWritten) {
121
+ try {
122
+ await removeFile(newPatchPath);
123
+ }
124
+ catch (rollbackError) {
125
+ warn(`Rollback warning: could not remove new patch file: ${toError(rollbackError).message}`);
126
+ }
127
+ }
128
+ if (renumberApplied) {
129
+ const inverseMap = new Map([...plan.placement.renameMap.entries()].map(([oldFilename, entry]) => [
130
+ entry.newFilename,
131
+ {
132
+ newOrder: parseInt(oldFilename.split('-')[0] ?? '0', 10),
133
+ newFilename: oldFilename,
134
+ },
135
+ ]));
136
+ try {
137
+ await renumberPatchesInManifest(patchesDir, inverseMap);
138
+ }
139
+ catch (rollbackError) {
140
+ warn(`Rollback warning: could not invert renumber: ${toError(rollbackError).message}`);
141
+ }
142
+ }
143
+ try {
144
+ await savePatchesManifest(patchesDir, manifest);
145
+ }
146
+ catch (rollbackError) {
147
+ warn(`Rollback warning: could not restore manifest: ${toError(rollbackError).message}`);
148
+ }
149
+ throw error;
150
+ }
151
+ });
152
+ }
153
+ /**
154
+ * Runs the `patch split` command: plans the split, lints the projection,
155
+ * confirms, and commits transactionally.
156
+ */
157
+ export async function patchSplitCommand(projectRoot, sourceId, options) {
158
+ intro(options.dryRun ? 'FireForge patch split (dry run)' : 'FireForge patch split');
159
+ const paths = getProjectPaths(projectRoot);
160
+ const config = await loadConfig(projectRoot);
161
+ const manifest = await loadPatchesManifest(paths.patches);
162
+ if (!manifest || manifest.patches.length === 0) {
163
+ throw new GeneralError('No patches in manifest.');
164
+ }
165
+ const source = resolvePatchIdentifier(sourceId, manifest.patches);
166
+ if (!source) {
167
+ throw new InvalidArgumentError(formatPatchNotFoundError(sourceId, manifest.patches), 'patch split');
168
+ }
169
+ const movedFiles = [...new Set(options.files.map((f) => f.trim()).filter(Boolean))].sort();
170
+ if (movedFiles.length === 0) {
171
+ throw new InvalidArgumentError('patch split requires at least one --files path.', '--files');
172
+ }
173
+ assertSourceOwnsFiles(source, movedFiles);
174
+ const movedSet = new Set(movedFiles);
175
+ const remainingFiles = source.filesAffected.filter((f) => !movedSet.has(f));
176
+ if (remainingFiles.length === 0) {
177
+ throw new InvalidArgumentError(`Splitting every file out of ${source.filename} would leave it empty. ` +
178
+ 'Use "fireforge patch rename" / "fireforge patch reorder" to repurpose or move the whole patch instead.', '--files');
179
+ }
180
+ const movedDiff = await buildSplitDiff(paths.engine, movedFiles, 'moved', source.filename);
181
+ const remainingDiff = await buildSplitDiff(paths.engine, remainingFiles, 'remaining', source.filename);
182
+ const category = options.category ?? source.category;
183
+ const placementOptions = pickDefined({
184
+ order: options.order,
185
+ before: options.before,
186
+ after: options.after ??
187
+ (options.order === undefined && options.before === undefined ? source.filename : undefined),
188
+ });
189
+ const placement = await resolvePlacementPlan(paths.patches, placementOptions, category, options.name);
190
+ const plan = {
191
+ source,
192
+ movedFiles,
193
+ remainingFiles,
194
+ movedDiff,
195
+ remainingDiff,
196
+ placement,
197
+ placementOptions,
198
+ category,
199
+ name: options.name,
200
+ description: options.description ?? '',
201
+ ownerRewrites: findOwnerRewriteHolders(manifest.patches, source.filename, movedSet),
202
+ };
203
+ // Per-patch lint both projected bodies, threading the source patch's
204
+ // tier/lintIgnore so an intentional-advisory patch can still split.
205
+ const ignoreChecks = source.lintIgnore ? new Set(source.lintIgnore) : undefined;
206
+ await runPatchLint(paths.engine, remainingFiles, remainingDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
207
+ await runPatchLint(paths.engine, movedFiles, movedDiff, config, options.skipLint, undefined, ignoreChecks, source.tier);
208
+ const conflicts = await runProjectedSplitLint(paths.patches, plan);
209
+ const newMetadata = buildNewPatchMetadata(plan, config);
210
+ enforcePatchPolicy({
211
+ config,
212
+ manifest: projectSplitManifest(manifest, plan, newMetadata),
213
+ command: 'patch split',
214
+ forceUnsafe: options.forceUnsafe === true,
215
+ });
216
+ const decision = await confirmDestructive({
217
+ operation: 'patch-split',
218
+ title: `Split ${plan.movedFiles.length} file(s) out of ${source.filename} into ${placement.newFilename}`,
219
+ summary: buildSplitSummary(plan),
220
+ yes: options.yes === true,
221
+ dryRun: options.dryRun === true,
222
+ unsafeOverride: options.forceUnsafe === true,
223
+ conflicts,
224
+ });
225
+ if (decision === 'dry-run') {
226
+ outro('Dry run complete — no changes made');
227
+ return;
228
+ }
229
+ if (decision === 'cancelled') {
230
+ outro('Split cancelled');
231
+ return;
232
+ }
233
+ await commitPatchSplit(paths.patches, plan, newMetadata, options);
234
+ success(`Split ${source.filename}: ${placement.newFilename} now owns ${plan.movedFiles.length} file(s)`);
235
+ if (plan.ownerRewrites.length > 0) {
236
+ info(`Re-pointed staged-dependency owners in: ${plan.ownerRewrites.join(', ')}`);
237
+ }
238
+ outro('Split complete');
239
+ }
240
+ /**
241
+ * Registers the `patch split` subcommand on the `patch` parent.
242
+ */
243
+ export function registerPatchSplit(parent, context) {
244
+ const { getProjectRoot, withErrorHandling } = context;
245
+ parent
246
+ .command('split <source>')
247
+ .description('Move files out of a patch into a new patch as one transaction (shrink + create + staged-dependency owner rewrites)')
248
+ .requiredOption('--files <path...>', 'Engine-relative files to move from the source patch to the new patch')
249
+ .requiredOption('--name <name>', 'Name for the new patch')
250
+ .option('--category <category>', "Category for the new patch (default: the source patch's)")
251
+ .option('--description <desc>', 'Description for the new patch')
252
+ .option('--order <n>', 'Exact sparse order for the new patch', (raw) => {
253
+ const n = Number.parseInt(raw, 10);
254
+ if (!Number.isInteger(n) || n <= 0) {
255
+ throw new InvalidArgumentError(`--order must be a positive integer, got "${raw}".`, '--order');
256
+ }
257
+ return n;
258
+ })
259
+ .option('--before <patch>', 'Place the new patch before this patch')
260
+ .option('--after <patch>', 'Place the new patch after this patch (default: the source patch)')
261
+ .option('--dry-run', 'Show what would happen without writing')
262
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
263
+ .option('--force-unsafe', 'Bypass projected-lint refusals')
264
+ .option('--skip-lint', 'Skip per-patch lint of the projected bodies')
265
+ .action(withErrorHandling(async (sourceId, options) => {
266
+ await patchSplitCommand(getProjectRoot(), sourceId, {
267
+ files: options.files,
268
+ name: options.name,
269
+ ...pickDefined({
270
+ category: options.category,
271
+ description: options.description,
272
+ order: options.order,
273
+ before: options.before,
274
+ after: options.after,
275
+ dryRun: options.dryRun,
276
+ yes: options.yes,
277
+ forceUnsafe: options.forceUnsafe,
278
+ skipLint: options.skipLint,
279
+ }),
280
+ });
281
+ }));
282
+ }
283
+ //# sourceMappingURL=split.js.map
@@ -4,12 +4,7 @@
4
4
  */
5
5
  import { Command } from 'commander';
6
6
  import type { CommandContext } from '../../types/cli.js';
7
- import type { PatchStagedDependencyOptions, PatchStagedForwardImport } from '../../types/commands/index.js';
8
- type StagedDependencyMode = 'add' | 'remove' | 'clear';
9
- /**
10
- * Renders a one-line summary of a staged-dependency metadata change.
11
- */
12
- export declare function describeStagedDependencyChange(before: readonly PatchStagedForwardImport[], after: readonly PatchStagedForwardImport[], mode: StagedDependencyMode, dependency: PatchStagedForwardImport | undefined): string;
7
+ import type { PatchStagedDependencyOptions } from '../../types/commands/index.js';
13
8
  /**
14
9
  * Runs the metadata-only staged-dependency mutation command.
15
10
  *
@@ -25,4 +20,3 @@ export declare function patchStagedDependencyCommand(projectRoot: string, identi
25
20
  * @param context - Shared CLI registration context
26
21
  */
27
22
  export declare function registerPatchStagedDependency(parent: Command, context: CommandContext): void;
28
- export {};
@@ -3,15 +3,12 @@
3
3
  * `fireforge patch staged-dependency <name>` — edits
4
4
  * PatchMetadata.stagedDependencies without rewriting the .patch body.
5
5
  */
6
- import { getProjectPaths } from '../../core/config.js';
7
6
  import { appendHistory } from '../../core/destructive.js';
8
7
  import { mutatePatchMetadata } from '../../core/patch-export.js';
9
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
10
- import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
11
8
  import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
12
9
  import { toError } from '../../utils/errors.js';
13
- import { pathExists } from '../../utils/fs.js';
14
10
  import { info, intro, outro, warn } from '../../utils/logger.js';
11
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
15
12
  function modeFromOptions(options) {
16
13
  const adding = options.add === true;
17
14
  const removing = options.remove === true;
@@ -69,7 +66,7 @@ function applyMode(existing, mode, dependency) {
69
66
  /**
70
67
  * Renders a one-line summary of a staged-dependency metadata change.
71
68
  */
72
- export function describeStagedDependencyChange(before, after, mode, dependency) {
69
+ function describeStagedDependencyChange(before, after, mode, dependency) {
73
70
  if (mode === 'clear') {
74
71
  return before.length === 0
75
72
  ? 'stagedDependencies was already empty — no change'
@@ -98,18 +95,8 @@ export async function patchStagedDependencyCommand(projectRoot, identifier, opti
98
95
  intro(isDryRun ? 'FireForge patch staged-dependency (dry run)' : 'FireForge patch staged-dependency');
99
96
  const mode = modeFromOptions(options);
100
97
  const dependency = mode === 'clear' ? undefined : requireForwardImportOptions(options, mode);
101
- const paths = getProjectPaths(projectRoot);
102
- if (!(await pathExists(paths.patches))) {
103
- throw new GeneralError('Patches directory not found.');
104
- }
105
- const manifest = await loadPatchesManifest(paths.patches);
106
- if (!manifest || manifest.patches.length === 0) {
107
- throw new GeneralError('No patches in manifest.');
108
- }
109
- const target = resolvePatchIdentifier(identifier, manifest.patches);
110
- if (!target) {
111
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
112
- }
98
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
99
+ const target = requirePatchTarget(identifier, manifest.patches);
113
100
  const existing = target.stagedDependencies?.forwardImports ?? [];
114
101
  const projected = applyMode(existing, mode, dependency);
115
102
  const summary = describeStagedDependencyChange(existing, projected, mode, dependency);
@@ -15,16 +15,13 @@
15
15
  * `--clear` must be supplied per invocation.
16
16
  */
17
17
  import { Option } from 'commander';
18
- import { getProjectPaths } from '../../core/config.js';
19
18
  import { appendHistory } from '../../core/destructive.js';
20
19
  import { updatePatchMetadata } from '../../core/patch-export.js';
21
- import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
22
- import { loadPatchesManifest, resolvePatchIdentifier } from '../../core/patch-manifest.js';
23
- import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
20
+ import { InvalidArgumentError } from '../../errors/base.js';
24
21
  import { toError } from '../../utils/errors.js';
25
- import { pathExists } from '../../utils/fs.js';
26
22
  import { info, intro, outro, warn } from '../../utils/logger.js';
27
23
  import { pickDefined } from '../../utils/options.js';
24
+ import { requirePatchQueue, requirePatchTarget } from './patch-context.js';
28
25
  /**
29
26
  * Runs the `patch tier` command: updates `PatchMetadata.tier` on the
30
27
  * named patch (or clears the field) and writes the manifest.
@@ -47,18 +44,8 @@ export async function patchTierCommand(projectRoot, identifier, options = {}) {
47
44
  if (!setting && !clearing) {
48
45
  throw new InvalidArgumentError('Specify --tier <tier> to set the override, or --clear to remove it.', 'patch tier');
49
46
  }
50
- const paths = getProjectPaths(projectRoot);
51
- if (!(await pathExists(paths.patches))) {
52
- throw new GeneralError('Patches directory not found.');
53
- }
54
- const manifest = await loadPatchesManifest(paths.patches);
55
- if (!manifest || manifest.patches.length === 0) {
56
- throw new GeneralError('No patches in manifest.');
57
- }
58
- const target = resolvePatchIdentifier(identifier, manifest.patches);
59
- if (!target) {
60
- throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
61
- }
47
+ const { paths, manifest } = await requirePatchQueue(projectRoot);
48
+ const target = requirePatchTarget(identifier, manifest.patches);
62
49
  const before = target.tier;
63
50
  const after = setting ? options.tier : undefined;
64
51
  if (before === after) {
@@ -47,9 +47,16 @@ async function scanPatchFiles(args) {
47
47
  for (const f of [...modifiedFiles, ...untrackedFiles])
48
48
  discoveredFiles.add(f);
49
49
  }
50
+ // Git pathspecs recurse, so a claimed file in a shallow directory would
51
+ // sweep entire subtrees into the candidate set — with several patches
52
+ // sharing a parent directory, every unmanaged file in the tree gets
53
+ // offered to whichever patch is scanned first. Constrain the broad scan
54
+ // to the patch's exact directory footprint; deeper paths need an
55
+ // explicit --scan-file / --scan-files assignment.
56
+ const parentDirSet = new Set(parentDirs);
50
57
  const currentSet = new Set(currentFilesAffected);
51
58
  const added = [...discoveredFiles]
52
- .filter((f) => !currentSet.has(f) && !claimedByOthers.has(f))
59
+ .filter((f) => parentDirSet.has(dirname(f)) && !currentSet.has(f) && !claimedByOthers.has(f))
53
60
  .sort();
54
61
  const removed = await findRemovedFiles(currentFilesAffected, engineDir);
55
62
  return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed);
@@ -1,11 +1,7 @@
1
1
  /**
2
2
  * Rebase summary and status label formatting.
3
3
  */
4
- import type { RebasePatchEntry, RebaseSession } from '../../core/rebase-session.js';
5
- /**
6
- * Formats a status label for a rebase patch entry.
7
- */
8
- export declare function statusLabel(status: RebasePatchEntry['status'], fuzzFactor?: number): string;
4
+ import type { RebaseSession } from '../../core/rebase-session.js';
9
5
  /**
10
6
  * Prints the rebase summary table.
11
7
  */
@@ -6,7 +6,7 @@ import { info } from '../../utils/logger.js';
6
6
  /**
7
7
  * Formats a status label for a rebase patch entry.
8
8
  */
9
- export function statusLabel(status, fuzzFactor) {
9
+ function statusLabel(status, fuzzFactor) {
10
10
  switch (status) {
11
11
  case 'applied-clean':
12
12
  return 'applied cleanly';