@hominis/fireforge 0.10.1 → 0.11.1

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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +6 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -0,0 +1,209 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch delete <name>` — removes a patch from the queue.
4
+ *
5
+ * Destructive: refuses when a later patch imports a module owned by the
6
+ * target (that would leave a dangling forward import), prompts for
7
+ * confirmation interactively, requires `--yes` for non-TTY, supports
8
+ * `--dry-run`, and appends to `patches/.fireforge-history.jsonl` on success.
9
+ */
10
+ import { basename } from 'node:path';
11
+ import { getProjectPaths } from '../../core/config.js';
12
+ import { appendHistory, confirmDestructive } from '../../core/destructive.js';
13
+ import { buildPatchQueueContext, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, isForwardImportableFile, } from '../../core/patch-lint.js';
14
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
15
+ import { loadPatchesManifest, removePatchFileAndManifest } from '../../core/patch-manifest.js';
16
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
17
+ import { toError } from '../../utils/errors.js';
18
+ import { pathExists } from '../../utils/fs.js';
19
+ import { info, intro, outro, warn } from '../../utils/logger.js';
20
+ import { pickDefined } from '../../utils/options.js';
21
+ /**
22
+ * Resolves `<name>` (ordinal number or filename) to a manifest entry.
23
+ * Mirrors re-export's `resolvePatchIdentifier` so the two resolvers behave
24
+ * consistently — future work can lift this into a shared helper once a
25
+ * third consumer appears.
26
+ */
27
+ function resolvePatchIdentifier(identifier, patches) {
28
+ if (/^\d+$/.test(identifier)) {
29
+ const order = parseInt(identifier, 10);
30
+ return patches.find((p) => p.order === order) ?? null;
31
+ }
32
+ const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
33
+ return patches.find((p) => p.filename === normalized) ?? null;
34
+ }
35
+ /**
36
+ * Runs the `patch delete` command: removes a patch file and its manifest
37
+ * row atomically, refusing when a later patch imports a leaf owned by the
38
+ * target.
39
+ *
40
+ * @param projectRoot - Project root directory
41
+ * @param identifier - Patch filename or ordinal number to delete
42
+ * @param options - Command options
43
+ */
44
+ export async function patchDeleteCommand(projectRoot, identifier, options = {}) {
45
+ intro(options.dryRun ? 'FireForge patch delete (dry run)' : 'FireForge patch delete');
46
+ const paths = getProjectPaths(projectRoot);
47
+ if (!(await pathExists(paths.patches))) {
48
+ throw new GeneralError('Patches directory not found. No patches to delete.');
49
+ }
50
+ const manifest = await loadPatchesManifest(paths.patches);
51
+ if (!manifest || manifest.patches.length === 0) {
52
+ throw new GeneralError('No patches in manifest.');
53
+ }
54
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
55
+ if (!target) {
56
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
57
+ }
58
+ // Build the full queue context once so we can scan each patch's newFiles
59
+ // without re-parsing for the dependency check below.
60
+ const baseCtx = await buildPatchQueueContext(paths.patches);
61
+ // Hard refusal: run the forward-import rule against the projected state.
62
+ // Any issue that names the target patch in its message still applies; any
63
+ // new forward-import that appears *only because the target is gone* means
64
+ // another patch was depending on the target's newly-created files.
65
+ // Simpler check: run the rule on the *original* context and look for
66
+ // imports that resolve into the target's new files from earlier patches.
67
+ // Even simpler: an import owned by a later patch pointing at any of the
68
+ // target's newly-created files is a dependency on the target. We build
69
+ // that check directly from baseCtx.
70
+ const targetEntry = baseCtx.entries.find((e) => e.filename === target.filename);
71
+ const targetNewFileLeaves = new Set();
72
+ if (targetEntry) {
73
+ for (const fullPath of targetEntry.newFiles.keys()) {
74
+ targetNewFileLeaves.add(basename(fullPath));
75
+ }
76
+ }
77
+ // Scan every later patch's new files AND its added lines on pre-existing
78
+ // files for import specifiers that resolve to a leaf owned by the target.
79
+ // Uses the shared specifier extractor so dynamic import() and
80
+ // ChromeUtils.defineESModuleGetters are picked up — the forward-import
81
+ // lint rule already covers those forms and delete safety must match the
82
+ // same set or it silently drops dependencies.
83
+ //
84
+ // We cover both source-site maps: `newFiles` (files the later patch
85
+ // creates) and `modifiedFileAdditions` (added lines against files that
86
+ // already exist). Scanning only newFiles was the second-degree miss
87
+ // that motivated this change — a later patch could add
88
+ // `import "./TargetHelper.sys.mjs"` to an existing file and the delete
89
+ // guard would never see the dependency.
90
+ const dependents = [];
91
+ const scanSite = (entryFilename, sitePath, content) => {
92
+ if (!isForwardImportableFile(sitePath))
93
+ return false;
94
+ const ignoredLines = findForwardImportIgnoreLines(content);
95
+ const specifiers = extractImportSpecifiersWithLines(content);
96
+ for (const { specifier, line } of specifiers) {
97
+ if (ignoredLines.has(line))
98
+ continue;
99
+ const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
100
+ const leaf = basename(cleaned);
101
+ if (!leaf || !isForwardImportableFile(leaf))
102
+ continue;
103
+ if (targetNewFileLeaves.has(leaf)) {
104
+ dependents.push(`${entryFilename} (${sitePath}) imports "${specifier}" which would be deleted`);
105
+ return true;
106
+ }
107
+ }
108
+ return false;
109
+ };
110
+ for (const entry of baseCtx.entries) {
111
+ if (entry.filename === target.filename)
112
+ continue;
113
+ if (entry.order < target.order)
114
+ continue;
115
+ let matched = false;
116
+ for (const [newFile, content] of entry.newFiles) {
117
+ if (scanSite(entry.filename, newFile, content)) {
118
+ matched = true;
119
+ break;
120
+ }
121
+ }
122
+ if (matched)
123
+ continue;
124
+ for (const [modifiedPath, addedContent] of entry.modifiedFileAdditions) {
125
+ if (scanSite(entry.filename, modifiedPath, addedContent))
126
+ break;
127
+ }
128
+ }
129
+ const conflicts = dependents.length > 0
130
+ ? {
131
+ reason: `${dependents.length} later patch(es) depend on files created by ${target.filename}`,
132
+ details: dependents,
133
+ }
134
+ : null;
135
+ const summary = [
136
+ `delete ${target.filename} (category: ${target.category}, order: ${target.order})`,
137
+ `description: ${target.description || '(none)'}`,
138
+ `files currently claimed by this patch (${target.filesAffected.length}):`,
139
+ ];
140
+ for (const file of target.filesAffected) {
141
+ summary.push(` ${file} → will become unmanaged`);
142
+ }
143
+ const decision = await confirmDestructive({
144
+ operation: 'patch-delete',
145
+ title: `Delete ${target.filename}`,
146
+ summary,
147
+ yes: options.yes === true,
148
+ dryRun: options.dryRun === true,
149
+ unsafeOverride: options.forceUnsafe === true,
150
+ conflicts,
151
+ });
152
+ if (decision === 'dry-run') {
153
+ outro('Dry run complete — no changes made');
154
+ return;
155
+ }
156
+ if (decision === 'cancelled') {
157
+ outro('Delete cancelled');
158
+ return;
159
+ }
160
+ // Proceed: remove under the patch directory lock so concurrent exports
161
+ // cannot race us into the same manifest row. The history append lives
162
+ // inside the same lock so two concurrent deletes cannot interleave
163
+ // history records beyond what POSIX O_APPEND atomicity guarantees for a
164
+ // single record, and so a crash between mutation and history write
165
+ // cannot leave a committed mutation with no audit trail alongside a
166
+ // concurrent mutation's record appearing first. A history append
167
+ // failure is warned but not re-thrown: by that point the mutation
168
+ // has committed and reporting failure to the caller would mislead.
169
+ await withPatchDirectoryLock(paths.patches, async () => {
170
+ await removePatchFileAndManifest(paths.patches, target.filename);
171
+ try {
172
+ await appendHistory(paths.patches, {
173
+ operation: 'patch-delete',
174
+ args: {
175
+ filename: target.filename,
176
+ order: target.order,
177
+ filesAffected: target.filesAffected,
178
+ },
179
+ ...(options.yes === true ? { yes: true } : {}),
180
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
181
+ result: 'ok',
182
+ });
183
+ }
184
+ catch (historyError) {
185
+ warn(`History log append failed after patch delete committed (${target.filename}): ${toError(historyError).message}`);
186
+ }
187
+ });
188
+ info(`Deleted ${target.filename}.`);
189
+ outro('Delete complete');
190
+ }
191
+ /**
192
+ * Registers the `patch delete` subcommand on the `patch` parent.
193
+ *
194
+ * @param parent - Parent Commander command
195
+ * @param context - Shared CLI registration context
196
+ */
197
+ export function registerPatchDelete(parent, context) {
198
+ const { getProjectRoot, withErrorHandling } = context;
199
+ parent
200
+ .command('delete <name>')
201
+ .description('Delete a patch from the queue (destructive)')
202
+ .option('--dry-run', 'Show what would happen without writing')
203
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
204
+ .option('--force-unsafe', 'Bypass the refusal when a later patch depends on this patch (last resort)')
205
+ .action(withErrorHandling(async (name, options) => {
206
+ await patchDeleteCommand(getProjectRoot(), name, pickDefined(options));
207
+ }));
208
+ }
209
+ //# sourceMappingURL=delete.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `fireforge patch <verb>` parent command. Groups single-patch
3
+ * mutations (`delete`, `reorder`) so they do not clutter the top-level
4
+ * command list. Queue-level verbs like `lint`, `export`, `verify`, and
5
+ * `status` stay flat.
6
+ */
7
+ import { Command } from 'commander';
8
+ import type { CommandContext } from '../../types/cli.js';
9
+ export { patchDeleteCommand } from './delete.js';
10
+ export { patchReorderCommand } from './reorder.js';
11
+ /**
12
+ * Registers the `patch` subcommand parent and its verbs on the CLI.
13
+ *
14
+ * @param program - Commander root program
15
+ * @param context - Shared CLI registration context
16
+ */
17
+ export declare function registerPatch(program: Command, context: CommandContext): void;
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch <verb>` parent command. Groups single-patch
4
+ * mutations (`delete`, `reorder`) so they do not clutter the top-level
5
+ * command list. Queue-level verbs like `lint`, `export`, `verify`, and
6
+ * `status` stay flat.
7
+ */
8
+ import { registerPatchDelete } from './delete.js';
9
+ import { registerPatchReorder } from './reorder.js';
10
+ export { patchDeleteCommand } from './delete.js';
11
+ export { patchReorderCommand } from './reorder.js';
12
+ /**
13
+ * Registers the `patch` subcommand parent and its verbs on the CLI.
14
+ *
15
+ * @param program - Commander root program
16
+ * @param context - Shared CLI registration context
17
+ */
18
+ export function registerPatch(program, context) {
19
+ const patch = program
20
+ .command('patch')
21
+ .description('Manage individual patches in the queue (delete, reorder)');
22
+ registerPatchDelete(patch, context);
23
+ registerPatchReorder(patch, context);
24
+ }
25
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `fireforge patch reorder <name> --to <N> | --before <name> | --after <name>`
3
+ *
4
+ * Renames the target .patch file and rewrites manifest rows so the target
5
+ * moves to the requested ordinal slot. Any subsequent patches are
6
+ * renumbered to make room. Pre-flights the projected order through
7
+ * `lintPatchQueue` so reorders that would introduce a forward-import fail
8
+ * before any bytes move.
9
+ */
10
+ import { Command } from 'commander';
11
+ import type { CommandContext } from '../../types/cli.js';
12
+ import type { PatchReorderOptions } from '../../types/commands/index.js';
13
+ /**
14
+ * Runs the `patch reorder` command: computes a rename map moving the
15
+ * target patch to the requested slot, projects the new order through
16
+ * cross-patch lint, confirms, and then renames under the patch directory
17
+ * lock.
18
+ *
19
+ * @param projectRoot - Project root directory
20
+ * @param identifier - Patch filename or ordinal number to move
21
+ * @param options - Command options (mutually exclusive --to/--before/--after)
22
+ */
23
+ export declare function patchReorderCommand(projectRoot: string, identifier: string, options?: PatchReorderOptions): Promise<void>;
24
+ /**
25
+ * Registers the `patch reorder` subcommand on the `patch` parent.
26
+ *
27
+ * @param parent - Parent Commander command
28
+ * @param context - Shared CLI registration context
29
+ */
30
+ export declare function registerPatchReorder(parent: Command, context: CommandContext): void;
@@ -0,0 +1,377 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch reorder <name> --to <N> | --before <name> | --after <name>`
4
+ *
5
+ * Renames the target .patch file and rewrites manifest rows so the target
6
+ * moves to the requested ordinal slot. Any subsequent patches are
7
+ * renumbered to make room. Pre-flights the projected order through
8
+ * `lintPatchQueue` so reorders that would introduce a forward-import fail
9
+ * before any bytes move.
10
+ */
11
+ import { Option } from 'commander';
12
+ import { getProjectPaths } from '../../core/config.js';
13
+ import { appendHistory, confirmDestructive, } from '../../core/destructive.js';
14
+ import { buildPatchQueueContext, lintPatchQueue, } from '../../core/patch-lint.js';
15
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
16
+ import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
17
+ import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
18
+ import { toError } from '../../utils/errors.js';
19
+ import { pathExists } from '../../utils/fs.js';
20
+ import { info, intro, outro, warn } from '../../utils/logger.js';
21
+ import { pickDefined } from '../../utils/options.js';
22
+ import { parsePositiveIntegerFlag } from '../../utils/validation.js';
23
+ function resolvePatchIdentifier(identifier, patches) {
24
+ if (/^\d+$/.test(identifier)) {
25
+ const order = parseInt(identifier, 10);
26
+ return patches.find((p) => p.order === order) ?? null;
27
+ }
28
+ const normalized = identifier.endsWith('.patch') ? identifier : `${identifier}.patch`;
29
+ return patches.find((p) => p.filename === normalized) ?? null;
30
+ }
31
+ function padOrder(value, width) {
32
+ return String(value).padStart(width, '0');
33
+ }
34
+ function rebuildFilenameForOrder(existing, newOrder) {
35
+ const currentPrefixMatch = /^(\d+)-/.exec(existing.filename);
36
+ const currentPrefix = currentPrefixMatch?.[1] ?? '001';
37
+ const width = Math.max(3, currentPrefix.length, String(newOrder).length);
38
+ const rest = existing.filename.replace(/^\d+-/, '');
39
+ return `${padOrder(newOrder, width)}-${rest}`;
40
+ }
41
+ /**
42
+ * Computes a rename map that moves `target` to `destinationOrder` with a
43
+ * minimal shift: only the contiguous run of patches whose current order
44
+ * blocks the destination slot is renumbered, so intentional gaps left by
45
+ * prior `patch delete` calls survive the reorder.
46
+ *
47
+ * Algorithm: remove target from the sorted list, then cascade: if any
48
+ * patch sits at the destination order, bump it to order+1; if that
49
+ * collides with the next patch, bump that one too; continue until a free
50
+ * slot is reached. The direction is symmetric — moving a patch earlier
51
+ * and moving it later both reduce to "find a free slot at destination by
52
+ * shifting the contiguous conflicting run upward".
53
+ */
54
+ function computeRenameMap(manifestPatches, target, destinationOrder) {
55
+ const renames = new Map();
56
+ if (destinationOrder === target.order)
57
+ return renames;
58
+ const sorted = [...manifestPatches].sort((a, b) => a.order - b.order);
59
+ const withoutTarget = sorted.filter((p) => p.filename !== target.filename);
60
+ // Clamp destination into the meaningful range. `--to 0` snaps to
61
+ // minOrder, `--to 99` to maxOrder+1 (append past the tail).
62
+ const minOrder = Math.min(...sorted.map((p) => p.order));
63
+ const maxOrderAfterRemoval = withoutTarget.length > 0 ? Math.max(...withoutTarget.map((p) => p.order)) : minOrder;
64
+ const clampedDest = Math.max(minOrder, Math.min(destinationOrder, maxOrderAfterRemoval + 1));
65
+ if (clampedDest === target.order)
66
+ return renames;
67
+ // Build a mutable order-for-each map so cascading bumps compose. Keys are
68
+ // filenames; values start as current order and get rewritten as bumps
69
+ // propagate. Only patches whose value changes end up in the rename map.
70
+ const currentOrder = new Map();
71
+ for (const patch of withoutTarget)
72
+ currentOrder.set(patch.filename, patch.order);
73
+ // Cascade from the destination: while any surviving patch occupies the
74
+ // slot we want, bump it to slot+1 and advance. Patches are processed in
75
+ // ascending order so each bump's collision (if any) is with the immediate
76
+ // successor in the sort — no back-tracking needed.
77
+ let slot = clampedDest;
78
+ for (const patch of withoutTarget) {
79
+ const order = currentOrder.get(patch.filename);
80
+ if (order === undefined)
81
+ continue;
82
+ if (order < slot)
83
+ continue;
84
+ if (order > slot)
85
+ break;
86
+ // Collision at `slot`: bump this patch to slot+1 and continue scanning,
87
+ // because slot+1 may also be occupied by the next patch in sequence.
88
+ currentOrder.set(patch.filename, slot + 1);
89
+ slot = slot + 1;
90
+ }
91
+ for (const patch of withoutTarget) {
92
+ const newOrder = currentOrder.get(patch.filename);
93
+ if (newOrder === undefined || newOrder === patch.order)
94
+ continue;
95
+ renames.set(patch.filename, {
96
+ newOrder,
97
+ newFilename: rebuildFilenameForOrder(patch, newOrder),
98
+ });
99
+ }
100
+ renames.set(target.filename, {
101
+ newOrder: clampedDest,
102
+ newFilename: rebuildFilenameForOrder(target, clampedDest),
103
+ });
104
+ return renames;
105
+ }
106
+ function getSortedRenameEntries(renameMap) {
107
+ return Array.from(renameMap.entries()).sort((a, b) => a[1].newOrder - b[1].newOrder);
108
+ }
109
+ function renameMapsEqual(left, right) {
110
+ const leftEntries = getSortedRenameEntries(left);
111
+ const rightEntries = getSortedRenameEntries(right);
112
+ if (leftEntries.length !== rightEntries.length) {
113
+ return false;
114
+ }
115
+ return leftEntries.every(([leftFilename, leftEntry], index) => {
116
+ const rightTuple = rightEntries[index];
117
+ if (!rightTuple)
118
+ return false;
119
+ const [rightFilename, rightEntry] = rightTuple;
120
+ return (leftFilename === rightFilename &&
121
+ leftEntry.newFilename === rightEntry.newFilename &&
122
+ leftEntry.newOrder === rightEntry.newOrder);
123
+ });
124
+ }
125
+ /**
126
+ * Applies a rename map to a {@link PatchQueueContext} so cross-patch lint
127
+ * can run against the projected state without touching disk.
128
+ */
129
+ function projectReorder(base, renameMap) {
130
+ const projectedEntries = base.entries.map((entry) => {
131
+ const rename = renameMap.get(entry.filename);
132
+ if (!rename)
133
+ return entry;
134
+ return {
135
+ ...entry,
136
+ filename: rename.newFilename,
137
+ order: rename.newOrder,
138
+ };
139
+ });
140
+ projectedEntries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
141
+ return { entries: projectedEntries };
142
+ }
143
+ /**
144
+ * Resolves `--to <N>`, `--before <anchor>`, and `--after <anchor>` into a
145
+ * concrete destination order and, for the anchor variants, the anchor
146
+ * filename the reorder should position against. Extracted from
147
+ * {@link patchReorderCommand} so the command body stays inside the
148
+ * project's per-function line budget.
149
+ */
150
+ function resolveDestination(target, manifestPatches, options) {
151
+ if (options.to !== undefined) {
152
+ // Defense-in-depth: the argParser should have rejected non-positive
153
+ // integers, but the function is reachable from tests that may pass
154
+ // `{ to: NaN }` directly.
155
+ if (!Number.isInteger(options.to) || options.to <= 0) {
156
+ throw new InvalidArgumentError(`--to must be a positive integer, got ${String(options.to)}.`, '--to');
157
+ }
158
+ return { destinationOrder: options.to, anchorFilename: undefined };
159
+ }
160
+ if (options.before !== undefined) {
161
+ const anchor = resolvePatchIdentifier(options.before, manifestPatches);
162
+ if (!anchor) {
163
+ throw new InvalidArgumentError(`--before anchor "${options.before}" not found.`, '--before');
164
+ }
165
+ // Reject self-reference. `--before <target>` resolves to the target's
166
+ // current order, so computeRenameMap would take its no-op branch — but
167
+ // that masks a user-facing typo or scripted mistake instead of
168
+ // surfacing it. The symmetric `--after` case is worse (mutates the
169
+ // queue), so both reject for consistency.
170
+ if (anchor.filename === target.filename) {
171
+ throw new InvalidArgumentError(`Cannot reorder patch "${target.filename}" relative to itself.`, '--before');
172
+ }
173
+ return { destinationOrder: anchor.order, anchorFilename: anchor.filename };
174
+ }
175
+ const afterId = options.after;
176
+ if (afterId === undefined) {
177
+ throw new InvalidArgumentError('Reached --after resolver with no value set.', '--after');
178
+ }
179
+ const anchor = resolvePatchIdentifier(afterId, manifestPatches);
180
+ if (!anchor) {
181
+ throw new InvalidArgumentError(`--after anchor "${afterId}" not found.`, '--after');
182
+ }
183
+ // See the --before branch above: self-reference is a logical
184
+ // contradiction. In the --after case, the previous `anchor.order + 1`
185
+ // bypassed computeRenameMap's no-op short-circuit and silently
186
+ // renumbered the target and every patch after it.
187
+ if (anchor.filename === target.filename) {
188
+ throw new InvalidArgumentError(`Cannot reorder patch "${target.filename}" relative to itself.`, '--after');
189
+ }
190
+ return { destinationOrder: anchor.order + 1, anchorFilename: anchor.filename };
191
+ }
192
+ async function commitReorderPlan(patchesDir, target, renameMap, anchorFilename, options, buildHistoryEntry) {
193
+ await withPatchDirectoryLock(patchesDir, async () => {
194
+ const currentManifest = await loadPatchesManifest(patchesDir);
195
+ if (!currentManifest || currentManifest.patches.length === 0) {
196
+ throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder.');
197
+ }
198
+ const currentTarget = currentManifest.patches.find((p) => p.filename === target.filename);
199
+ if (!currentTarget) {
200
+ throw new GeneralError(`Patch queue changed while waiting for confirmation. ${target.filename} no longer exists; re-run reorder.`);
201
+ }
202
+ let currentDestinationOrder;
203
+ if (options.to !== undefined) {
204
+ currentDestinationOrder = options.to;
205
+ }
206
+ else if (options.before !== undefined) {
207
+ const currentAnchor = currentManifest.patches.find((p) => p.filename === anchorFilename);
208
+ if (!currentAnchor) {
209
+ throw new GeneralError('Patch queue changed while waiting for confirmation. The reorder anchor moved or disappeared; re-run reorder.');
210
+ }
211
+ currentDestinationOrder = currentAnchor.order;
212
+ }
213
+ else {
214
+ const currentAnchor = currentManifest.patches.find((p) => p.filename === anchorFilename);
215
+ if (!currentAnchor) {
216
+ throw new GeneralError('Patch queue changed while waiting for confirmation. The reorder anchor moved or disappeared; re-run reorder.');
217
+ }
218
+ currentDestinationOrder = currentAnchor.order + 1;
219
+ }
220
+ const currentRenameMap = computeRenameMap(currentManifest.patches, currentTarget, currentDestinationOrder);
221
+ if (!renameMapsEqual(renameMap, currentRenameMap)) {
222
+ throw new GeneralError('Patch queue changed while waiting for confirmation. Re-run reorder to recompute the rename plan.');
223
+ }
224
+ const currentProjected = projectReorder(await buildPatchQueueContext(patchesDir), currentRenameMap);
225
+ const currentConflicts = lintPatchQueue(currentProjected).filter((i) => i.severity === 'error');
226
+ if (currentConflicts.length > 0 && options.forceUnsafe !== true) {
227
+ throw new InvalidArgumentError(`Refusing to run patch reorder: reorder would introduce ${currentConflicts.length} cross-patch lint error(s). Pass --force-unsafe to override.`, '--force-unsafe');
228
+ }
229
+ await renumberPatchesInManifest(patchesDir, currentRenameMap);
230
+ // Append the history record inside the lock so two concurrent
231
+ // reorders cannot interleave mutation and history writes, and so a
232
+ // crash between the rename and the history write cannot orphan a
233
+ // committed reorder with no audit trail. If the append itself
234
+ // fails (disk full, permissions), we warn but do not re-throw:
235
+ // the mutation has already succeeded and is not reversible, so
236
+ // surfacing the history failure as a command failure would
237
+ // mislead the caller.
238
+ try {
239
+ await appendHistory(patchesDir, buildHistoryEntry(currentRenameMap));
240
+ }
241
+ catch (historyError) {
242
+ warn(`History log append failed after patch reorder committed: ${toError(historyError).message}`);
243
+ }
244
+ });
245
+ }
246
+ /**
247
+ * Runs the `patch reorder` command: computes a rename map moving the
248
+ * target patch to the requested slot, projects the new order through
249
+ * cross-patch lint, confirms, and then renames under the patch directory
250
+ * lock.
251
+ *
252
+ * @param projectRoot - Project root directory
253
+ * @param identifier - Patch filename or ordinal number to move
254
+ * @param options - Command options (mutually exclusive --to/--before/--after)
255
+ */
256
+ export async function patchReorderCommand(projectRoot, identifier, options = {}) {
257
+ intro(options.dryRun ? 'FireForge patch reorder (dry run)' : 'FireForge patch reorder');
258
+ const specifiedTargets = [
259
+ options.to !== undefined,
260
+ options.before !== undefined,
261
+ options.after !== undefined,
262
+ ].filter(Boolean).length;
263
+ if (specifiedTargets === 0) {
264
+ throw new InvalidArgumentError('Specify --to <N>, --before <name>, or --after <name>.', 'patch reorder');
265
+ }
266
+ if (specifiedTargets > 1) {
267
+ throw new InvalidArgumentError('--to, --before, and --after are mutually exclusive.', 'patch reorder');
268
+ }
269
+ const paths = getProjectPaths(projectRoot);
270
+ if (!(await pathExists(paths.patches))) {
271
+ throw new GeneralError('Patches directory not found.');
272
+ }
273
+ const manifest = await loadPatchesManifest(paths.patches);
274
+ if (!manifest || manifest.patches.length === 0) {
275
+ throw new GeneralError('No patches in manifest.');
276
+ }
277
+ const target = resolvePatchIdentifier(identifier, manifest.patches);
278
+ if (!target) {
279
+ throw new InvalidArgumentError(`Patch "${identifier}" not found. Available: ${manifest.patches.map((p) => p.filename).join(', ')}`, identifier);
280
+ }
281
+ const { destinationOrder, anchorFilename } = resolveDestination(target, manifest.patches, options);
282
+ const renameMap = computeRenameMap(manifest.patches, target, destinationOrder);
283
+ if (renameMap.size === 0) {
284
+ info('Target is already at the requested position. Nothing to do.');
285
+ outro('Reorder complete (no-op)');
286
+ return;
287
+ }
288
+ // Project the reorder through cross-patch lint. Forward-import violations
289
+ // that are introduced *by* the reorder become hard refusals.
290
+ const baseCtx = await buildPatchQueueContext(paths.patches);
291
+ const projected = projectReorder(baseCtx, renameMap);
292
+ const projectedIssues = lintPatchQueue(projected);
293
+ const errorIssues = projectedIssues.filter((i) => i.severity === 'error');
294
+ const conflicts = errorIssues.length > 0
295
+ ? {
296
+ reason: `reorder would introduce ${errorIssues.length} cross-patch lint error(s)`,
297
+ details: errorIssues.map((i) => `[${i.check}] ${i.file}: ${i.message}`),
298
+ }
299
+ : null;
300
+ const renameEntries = getSortedRenameEntries(renameMap);
301
+ const targetRename = renameMap.get(target.filename);
302
+ if (!targetRename) {
303
+ throw new GeneralError('Reorder plan did not include the target patch.');
304
+ }
305
+ const actualDestinationOrder = targetRename.newOrder;
306
+ const summary = [
307
+ `move ${target.filename} → order ${actualDestinationOrder}`,
308
+ `${renameMap.size} patch(es) would be renamed:`,
309
+ ];
310
+ for (const [oldFilename, entry] of renameEntries) {
311
+ summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
312
+ }
313
+ const decision = await confirmDestructive({
314
+ operation: 'patch-reorder',
315
+ title: `Reorder ${target.filename} to position ${actualDestinationOrder}`,
316
+ summary,
317
+ yes: options.yes === true,
318
+ dryRun: options.dryRun === true,
319
+ unsafeOverride: options.forceUnsafe === true,
320
+ conflicts,
321
+ });
322
+ if (decision === 'dry-run') {
323
+ outro('Dry run complete — no changes made');
324
+ return;
325
+ }
326
+ if (decision === 'cancelled') {
327
+ outro('Reorder cancelled');
328
+ return;
329
+ }
330
+ // The history entry is built inside commitReorderPlan (still under the
331
+ // lock) from the *final* rename map, not the pre-confirmation one, so
332
+ // the destinationOrder and renames mirror what actually landed on disk.
333
+ const buildHistoryEntry = (finalRenameMap) => {
334
+ const finalEntries = getSortedRenameEntries(finalRenameMap);
335
+ const finalTarget = finalRenameMap.get(target.filename);
336
+ return {
337
+ operation: 'patch-reorder',
338
+ args: {
339
+ target: target.filename,
340
+ destinationOrder: finalTarget?.newOrder ?? actualDestinationOrder,
341
+ renames: finalEntries.map(([from, entry]) => ({
342
+ from,
343
+ to: entry.newFilename,
344
+ order: entry.newOrder,
345
+ })),
346
+ },
347
+ ...(options.yes === true ? { yes: true } : {}),
348
+ ...(options.forceUnsafe === true ? { unsafeOverride: true } : {}),
349
+ result: 'ok',
350
+ };
351
+ };
352
+ await commitReorderPlan(paths.patches, target, renameMap, anchorFilename, options, buildHistoryEntry);
353
+ info(`Reordered ${renameMap.size} patch(es).`);
354
+ outro('Reorder complete');
355
+ }
356
+ /**
357
+ * Registers the `patch reorder` subcommand on the `patch` parent.
358
+ *
359
+ * @param parent - Parent Commander command
360
+ * @param context - Shared CLI registration context
361
+ */
362
+ export function registerPatchReorder(parent, context) {
363
+ const { getProjectRoot, withErrorHandling } = context;
364
+ parent
365
+ .command('reorder <name>')
366
+ .description('Move a patch to a different position in the queue (destructive)')
367
+ .addOption(new Option('--to <order>', 'Destination ordinal').argParser((v) => parsePositiveIntegerFlag('--to', v)))
368
+ .option('--before <anchor>', 'Place the patch immediately before <anchor>')
369
+ .option('--after <anchor>', 'Place the patch immediately after <anchor>')
370
+ .option('--dry-run', 'Show what would happen without writing')
371
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
372
+ .option('--force-unsafe', 'Bypass the refusal when the projected order introduces a lint error')
373
+ .action(withErrorHandling(async (name, options) => {
374
+ await patchReorderCommand(getProjectRoot(), name, pickDefined(options));
375
+ }));
376
+ }
377
+ //# sourceMappingURL=reorder.js.map