@hominis/fireforge 0.10.1 → 0.11.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 (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 +5 -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
@@ -7,7 +7,7 @@ export { PatchError } from '../errors/patch.js';
7
7
  export { countPatches, discoverPatches, getAllTargetFilesFromPatch, getTargetFileFromPatch, isNewFilePatch, } from './patch-files.js';
8
8
  export { withPatchDirectoryLock } from './patch-lock.js';
9
9
  export { extractAffectedFiles, extractOrder, isNewFileInPatch, parseHunksForFile, } from './patch-parse.js';
10
- export { applyPatchToContent, extractNewFileContent } from './patch-transform.js';
10
+ export { applyPatchToContent, extractNewFileContent, extractNewFileContentFromDiff, } from './patch-transform.js';
11
11
  /**
12
12
  * Applies all patches in order. Rolls back all successfully applied
13
13
  * patches when one fails so the engine directory stays clean.
@@ -26,16 +26,30 @@ export declare function validatePatches(patchesDir: string, engineDir: string):
26
26
  valid: boolean;
27
27
  errors: string[];
28
28
  }>;
29
+ /**
30
+ * Options for {@link applyPatchesWithContinue}.
31
+ */
32
+ export interface ApplyPatchesOptions {
33
+ /** Continue applying patches even after one fails. */
34
+ continueOnFailure?: boolean;
35
+ /**
36
+ * Stop applying patches after this filename has been processed
37
+ * (successfully or not). Any patches after it in apply order are left
38
+ * untouched. Accepts either the bare filename (with or without .patch)
39
+ * or the numeric ordinal as a string. Unknown identifiers throw.
40
+ */
41
+ untilFilename?: string | undefined;
42
+ }
29
43
  /**
30
44
  * Enhanced patch application with continue mode.
31
45
  * When continueOnFailure is false, rolls back all previously applied patches
32
46
  * on the first failure to keep the engine directory in a clean state.
33
47
  * @param patchesDir - Path to the patches directory
34
48
  * @param engineDir - Path to the engine directory
35
- * @param continueOnFailure - Whether to continue after failures
49
+ * @param optionsOrContinue - Options object, or the legacy continueOnFailure boolean
36
50
  * @returns Import summary with all results
37
51
  */
38
- export declare function applyPatchesWithContinue(patchesDir: string, engineDir: string, continueOnFailure?: boolean): Promise<ImportSummary>;
52
+ export declare function applyPatchesWithContinue(patchesDir: string, engineDir: string, optionsOrContinue?: ApplyPatchesOptions | boolean): Promise<ImportSummary>;
39
53
  /**
40
54
  * Computes the cumulative patched content for a file.
41
55
  * @param patchesDir - Path to the patches directory
@@ -21,7 +21,7 @@ export { PatchError } from '../errors/patch.js';
21
21
  export { countPatches, discoverPatches, getAllTargetFilesFromPatch, getTargetFileFromPatch, isNewFilePatch, } from './patch-files.js';
22
22
  export { withPatchDirectoryLock } from './patch-lock.js';
23
23
  export { extractAffectedFiles, extractOrder, isNewFileInPatch, parseHunksForFile, } from './patch-parse.js';
24
- export { applyPatchToContent, extractNewFileContent } from './patch-transform.js';
24
+ export { applyPatchToContent, extractNewFileContent, extractNewFileContentFromDiff, } from './patch-transform.js';
25
25
  /**
26
26
  * Applies a single patch.
27
27
  * @param patch - Patch info
@@ -164,21 +164,99 @@ function validatePatchTargets(patch, affectedFiles) {
164
164
  }
165
165
  }
166
166
  }
167
+ /**
168
+ * Decides whether a patch filename matches an `--until` identifier.
169
+ *
170
+ * The identifier is one of three shapes and the three shapes must stay
171
+ * disjoint so the operator can reason about which patch they picked:
172
+ *
173
+ * 1. **Exact filename** — `005-foo.patch`. Matches only that filename.
174
+ * 2. **Filename without extension** — `005-foo`. Matches `005-foo.patch`.
175
+ * 3. **Bare numeric ordinal** — `5` or `005`. Matches the patch whose
176
+ * order prefix parses to the same integer (so `5` and `005` both
177
+ * match `005-foo.patch`, and `05` matches too because parseInt
178
+ * normalizes leading zeros).
179
+ *
180
+ * A purely-numeric identifier is treated **only** as an ordinal: it does
181
+ * not also match a filename that happens to literally equal the digits.
182
+ * That would require a patch literally named `5` or `005` — which would
183
+ * collide with the filename prefix anyway — and we prefer the explicit
184
+ * ordinal interpretation. The earlier form's `patchFilename === needle`
185
+ * short-circuit was kept behind the numeric gate so the match stays
186
+ * single-meaning per identifier.
187
+ */
188
+ function matchesUntilFilename(patchFilename, needle) {
189
+ const isNumeric = /^\d+$/.test(needle);
190
+ if (isNumeric) {
191
+ const order = parseInt(needle, 10);
192
+ const prefixMatch = /^(\d+)-/.exec(patchFilename);
193
+ return prefixMatch !== null && parseInt(prefixMatch[1] ?? '0', 10) === order;
194
+ }
195
+ if (patchFilename === needle)
196
+ return true;
197
+ if (patchFilename === `${needle}.patch`)
198
+ return true;
199
+ return false;
200
+ }
167
201
  /**
168
202
  * Enhanced patch application with continue mode.
169
203
  * When continueOnFailure is false, rolls back all previously applied patches
170
204
  * on the first failure to keep the engine directory in a clean state.
171
205
  * @param patchesDir - Path to the patches directory
172
206
  * @param engineDir - Path to the engine directory
173
- * @param continueOnFailure - Whether to continue after failures
207
+ * @param optionsOrContinue - Options object, or the legacy continueOnFailure boolean
174
208
  * @returns Import summary with all results
175
209
  */
176
- export async function applyPatchesWithContinue(patchesDir, engineDir, continueOnFailure = false) {
210
+ export async function applyPatchesWithContinue(patchesDir, engineDir, optionsOrContinue = false) {
211
+ // Accept both the legacy boolean positional and the new options object so
212
+ // existing call sites (tests and rebase) keep working without a rewrite.
213
+ const options = typeof optionsOrContinue === 'boolean'
214
+ ? { continueOnFailure: optionsOrContinue }
215
+ : optionsOrContinue;
216
+ const continueOnFailure = options.continueOnFailure ?? false;
217
+ const untilFilename = options.untilFilename;
177
218
  const patches = await discoverPatches(patchesDir);
219
+ // Resolve the --until stop index up front so callers get an immediate
220
+ // error on an unknown identifier instead of a silent no-op. Detect
221
+ // ambiguity (two patches matching the same needle — should never happen
222
+ // in a well-formed manifest but surfaces queue corruption loudly
223
+ // instead of silently picking the first match).
224
+ let stopIndex = patches.length - 1;
225
+ if (untilFilename !== undefined) {
226
+ const matchingIndexes = [];
227
+ for (let i = 0; i < patches.length; i++) {
228
+ const patch = patches[i];
229
+ if (patch && matchesUntilFilename(patch.filename, untilFilename)) {
230
+ matchingIndexes.push(i);
231
+ }
232
+ }
233
+ if (matchingIndexes.length === 0) {
234
+ throw new PatchError(`--until identifier "${untilFilename}" does not match any patch. ` +
235
+ `Available: ${patches.map((p) => p.filename).join(', ')}`);
236
+ }
237
+ if (matchingIndexes.length > 1) {
238
+ const matches = matchingIndexes
239
+ .map((idx) => patches[idx]?.filename ?? '<unknown>')
240
+ .join(', ');
241
+ throw new PatchError(`--until identifier "${untilFilename}" is ambiguous: matches ${matchingIndexes.length} ` +
242
+ `patches (${matches}). Use the full filename to disambiguate.`);
243
+ }
244
+ stopIndex = matchingIndexes[0] ?? patches.length - 1;
245
+ }
178
246
  const succeeded = [];
179
247
  const failed = [];
180
248
  const skipped = [];
181
- for (const patch of patches) {
249
+ for (let i = 0; i < patches.length; i++) {
250
+ const patch = patches[i];
251
+ if (!patch)
252
+ continue;
253
+ if (i > stopIndex) {
254
+ // Patches beyond the --until cutoff are intentionally skipped. They
255
+ // are not failures — record them in `skipped` so the summary still
256
+ // reflects what the full queue contained.
257
+ skipped.push(patch);
258
+ continue;
259
+ }
182
260
  const result = await applySinglePatch(patch, engineDir);
183
261
  if (result.success) {
184
262
  succeeded.push(result);
@@ -193,10 +271,10 @@ export async function applyPatchesWithContinue(patchesDir, engineDir, continueOn
193
271
  verbose(`Rolling back ${succeeded.length} previously applied patch(es)…`);
194
272
  await rollbackPatches(succeeded, engineDir);
195
273
  }
196
- // Mark remaining patches as skipped
197
- const currentIndex = patches.indexOf(patch);
198
- for (let i = currentIndex + 1; i < patches.length; i++) {
199
- const remainingPatch = patches[i];
274
+ // Mark remaining patches as skipped (including anything that was
275
+ // already past the --until cutoff, which stays skipped).
276
+ for (let j = i + 1; j < patches.length; j++) {
277
+ const remainingPatch = patches[j];
200
278
  if (remainingPatch) {
201
279
  skipped.push(remainingPatch);
202
280
  }
@@ -1,4 +1,4 @@
1
- import type { PatchCategory, PatchInfo, PatchMetadata } from '../types/commands/index.js';
1
+ import type { PatchCategory, PatchesManifest, PatchInfo, PatchMetadata } from '../types/commands/index.js';
2
2
  /**
3
3
  * Gets the next patch number for a new patch.
4
4
  * @param patchesDir - Path to the patches directory
@@ -28,8 +28,12 @@ export interface CommitExportedPatchResult {
28
28
  superseded: PatchInfo[];
29
29
  }
30
30
  /**
31
- * Commits a freshly generated patch file and manifest update under an exclusive
32
- * patch directory lock so concurrent exports cannot allocate the same number.
31
+ * Commits a freshly generated patch file and manifest update under an
32
+ * exclusive patch directory lock so concurrent exports cannot allocate the
33
+ * same number. Shares {@link computeExportPlanUnderLock} with
34
+ * {@link planExport} so the dry-run preview cannot drift from the real
35
+ * write: both paths go through the same planning helper, and any bug fix
36
+ * to filename allocation or supersede detection lands in both automatically.
33
37
  */
34
38
  export declare function commitExportedPatch(input: CommitExportedPatchInput): Promise<CommitExportedPatchResult>;
35
39
  /**
@@ -58,6 +62,32 @@ export declare function findExistingPatchForFile(patchesDir: string, filePath: s
58
62
  * @param newContent - New patch content
59
63
  */
60
64
  export declare function updatePatch(patchPath: string, newContent: string): Promise<void>;
65
+ /**
66
+ * Optional post-commit hook for {@link updatePatchAndMetadata}. Runs inside
67
+ * the patch directory lock after the mutation has succeeded but before the
68
+ * lock is released. Intended for history-log appends so the audit record
69
+ * lands atomically with the mutation. Hook failures are warned but never
70
+ * re-thrown: by the time the hook runs the mutation is already committed,
71
+ * so there is nothing meaningful to roll back.
72
+ */
73
+ export type UpdatePatchCommittedHook = () => Promise<void>;
74
+ /**
75
+ * Updates a patch file body and its manifest row under the same patch
76
+ * directory lock. Intended for commands like `re-export --files` where the
77
+ * file body and `filesAffected` metadata must move together.
78
+ *
79
+ * If the manifest write fails after the patch body has been rewritten, the
80
+ * original patch content is restored best-effort before the error is
81
+ * re-thrown.
82
+ *
83
+ * @param patchesDir - Path to the patches directory
84
+ * @param filename - Target patch filename
85
+ * @param newContent - New patch body
86
+ * @param updates - Metadata fields to merge into the existing row
87
+ * @param onCommitted - Optional hook that runs inside the same lock after
88
+ * the mutation succeeds. See {@link UpdatePatchCommittedHook}.
89
+ */
90
+ export declare function updatePatchAndMetadata(patchesDir: string, filename: string, newContent: string, updates: Partial<PatchMetadata>, onCommitted?: UpdatePatchCommittedHook): Promise<void>;
61
91
  /**
62
92
  * Updates metadata for a patch in the manifest.
63
93
  * @param patchesDir - Path to the patches directory
@@ -80,14 +110,28 @@ export declare function findSupersededPatches(patchesDir: string, newPatchFiles:
80
110
  * @param filename - Patch filename to delete
81
111
  */
82
112
  export declare function deletePatch(patchesDir: string, filename: string): Promise<void>;
113
+ /**
114
+ * Report whether a patch is fully covered by a new export, and which of its
115
+ * files caused the coverage.
116
+ *
117
+ * Widened from a bare boolean to `{covered, byFiles}` so that `export
118
+ * --supersede --dry-run` can tell the operator which files in each existing
119
+ * patch triggered its supersession — the opaque "this export would
120
+ * supersede N patches" message was the primary reason `--supersede` was
121
+ * unsafe before this change.
122
+ */
123
+ export interface PatchCoverage {
124
+ covered: boolean;
125
+ byFiles: string[];
126
+ }
83
127
  /**
84
128
  * Checks whether a patch is fully covered by a new export.
85
129
  * A patch is fully covered when every file it affects is present in the new export.
86
130
  * @param patchFiles - Files affected by the existing patch
87
131
  * @param targetFiles - Files affected by the new export
88
- * @returns True when the existing patch is fully covered
132
+ * @returns Coverage report with the triggering file list when `covered` is true
89
133
  */
90
- export declare function isPatchFullyCovered(patchFiles: string[], targetFiles: string[]): boolean;
134
+ export declare function isPatchFullyCovered(patchFiles: string[], targetFiles: string[]): PatchCoverage;
91
135
  /**
92
136
  * Finds patches whose filesAffected entries are fully covered by the specified files.
93
137
  * Used for complete supersession when exporting full-file patches.
@@ -97,3 +141,73 @@ export declare function isPatchFullyCovered(patchFiles: string[], targetFiles: s
97
141
  * @returns Patches that are fully covered by the new export
98
142
  */
99
143
  export declare function findAllPatchesForFiles(patchesDir: string, targetFiles: string[], excludeFilename?: string): Promise<PatchInfo[]>;
144
+ /**
145
+ * Describes which files in a covered patch triggered its supersession.
146
+ * Returned from {@link planExport} so dry-run previews can render a
147
+ * complete "moved / removed" picture rather than a bare patch count.
148
+ */
149
+ export interface SupersedeCoverageDetail {
150
+ /** Existing patch filename. */
151
+ filename: string;
152
+ /** Files the existing patch claimed that the new export also claims. */
153
+ coveredByFiles: string[];
154
+ }
155
+ /**
156
+ * Resolves coverage details for every existing patch that the new export
157
+ * would fully cover. Mirrors {@link findAllPatchesForFiles} but returns the
158
+ * widened {@link PatchCoverage.byFiles} list per match so callers can render
159
+ * a per-patch breakdown.
160
+ */
161
+ export declare function findAllPatchesForFilesWithDetails(patchesDir: string, targetFiles: string[], excludeFilename?: string): Promise<{
162
+ patch: PatchInfo;
163
+ coverage: PatchCoverage;
164
+ metadata: PatchMetadata;
165
+ }[]>;
166
+ /**
167
+ * Fully computed plan for a pending export. Returned from
168
+ * {@link planExport} so that `--dry-run` previews can render the full
169
+ * outcome of the hypothetical write without touching disk.
170
+ *
171
+ * Dry-run and the real write both go through {@link computeExportPlanUnderLock}
172
+ * so their filename allocation, supersede detection, and projected
173
+ * post-write manifest cannot drift. `planExport` exposes the rich coverage
174
+ * form for preview rendering; {@link commitExportedPatch} consumes the bare
175
+ * `PatchInfo[]` form of the same underlying data.
176
+ */
177
+ export interface ExportPlan {
178
+ /** Allocated patch filename (e.g. `005-ui-sidebar.patch`). */
179
+ patchFilename: string;
180
+ /** Full metadata row that would be written to the manifest. */
181
+ metadata: PatchMetadata;
182
+ /** Existing patches that would be superseded by this export. */
183
+ superseded: SupersedeCoverageDetail[];
184
+ /** Manifest state as it existed when the plan was computed. */
185
+ manifestBefore: PatchesManifest | null;
186
+ /**
187
+ * Manifest state the plan would write. Always includes the new patch
188
+ * metadata and excludes any superseded filenames.
189
+ */
190
+ manifestAfter: PatchesManifest;
191
+ }
192
+ export interface PlanExportInput {
193
+ patchesDir: string;
194
+ category: PatchCategory;
195
+ name: string;
196
+ description: string;
197
+ filesAffected: string[];
198
+ sourceEsrVersion: string;
199
+ }
200
+ /**
201
+ * Read-only planning function — computes everything a real export would
202
+ * do without writing anything to disk. Takes the patch directory lock
203
+ * briefly, runs {@link computeExportPlanUnderLock}, releases the lock,
204
+ * and returns the plan for preview rendering.
205
+ *
206
+ * Shares {@link computeExportPlanUnderLock} with {@link commitExportedPatch}
207
+ * so the dry-run preview cannot drift from the real write. The real write
208
+ * path does NOT reuse a prior plan object (another export may have landed
209
+ * between dry-run and commit, which would stale the filename allocation);
210
+ * it re-runs the same helper under a fresh lock. The guarantee is "same
211
+ * code, possibly different data," not "same plan object."
212
+ */
213
+ export declare function planExport(input: PlanExportInput): Promise<ExportPlan>;
@@ -47,37 +47,35 @@ export async function getNextPatchFilename(patchesDir, category, name) {
47
47
  return `${patchNumber}-${category}-${sanitizedName}.patch`;
48
48
  }
49
49
  /**
50
- * Commits a freshly generated patch file and manifest update under an exclusive
51
- * patch directory lock so concurrent exports cannot allocate the same number.
50
+ * Commits a freshly generated patch file and manifest update under an
51
+ * exclusive patch directory lock so concurrent exports cannot allocate the
52
+ * same number. Shares {@link computeExportPlanUnderLock} with
53
+ * {@link planExport} so the dry-run preview cannot drift from the real
54
+ * write: both paths go through the same planning helper, and any bug fix
55
+ * to filename allocation or supersede detection lands in both automatically.
52
56
  */
53
57
  export async function commitExportedPatch(input) {
54
58
  return withPatchDirectoryLock(input.patchesDir, async () => {
55
- const patchFilename = await getNextPatchFilename(input.patchesDir, input.category, input.name);
56
- const patchPath = join(input.patchesDir, patchFilename);
57
- const metadata = {
58
- filename: patchFilename,
59
- order: parseInt(patchFilename.split('-')[0] ?? '0', 10),
59
+ const plan = await computeExportPlanUnderLock({
60
+ patchesDir: input.patchesDir,
60
61
  category: input.category,
61
62
  name: input.name,
62
63
  description: input.description,
63
- createdAt: new Date().toISOString(),
64
- sourceEsrVersion: input.sourceEsrVersion,
65
64
  filesAffected: input.filesAffected,
66
- };
67
- const superseded = await findAllPatchesForFiles(input.patchesDir, input.filesAffected, patchFilename);
68
- const supersededFilenames = superseded.map((patch) => patch.filename);
69
- const originalManifest = await loadPatchesManifest(input.patchesDir);
65
+ sourceEsrVersion: input.sourceEsrVersion,
66
+ });
67
+ const patchPath = plan.patchPath;
70
68
  const originalPatchContent = (await pathExists(patchPath)) ? await readText(patchPath) : null;
71
69
  const removedPatchContents = new Map();
72
- for (const oldPatch of superseded) {
70
+ for (const oldPatch of plan.supersededPatches) {
73
71
  if (await pathExists(oldPatch.path)) {
74
72
  removedPatchContents.set(oldPatch.path, await readText(oldPatch.path));
75
73
  }
76
74
  }
77
75
  try {
78
76
  await writeText(patchPath, input.diff);
79
- await addPatchToManifest(input.patchesDir, metadata, supersededFilenames);
80
- for (const oldPatch of superseded) {
77
+ await addPatchToManifest(input.patchesDir, plan.metadata, plan.supersededPatches.map((p) => p.filename));
78
+ for (const oldPatch of plan.supersededPatches) {
81
79
  await removeFile(oldPatch.path);
82
80
  }
83
81
  }
@@ -104,8 +102,8 @@ export async function commitExportedPatch(input) {
104
102
  }
105
103
  }
106
104
  try {
107
- if (originalManifest) {
108
- await savePatchesManifest(input.patchesDir, originalManifest);
105
+ if (plan.manifestBefore) {
106
+ await savePatchesManifest(input.patchesDir, plan.manifestBefore);
109
107
  }
110
108
  else {
111
109
  await removeFile(join(input.patchesDir, PATCHES_MANIFEST));
@@ -117,9 +115,9 @@ export async function commitExportedPatch(input) {
117
115
  throw error;
118
116
  }
119
117
  return {
120
- patchFilename,
121
- metadata,
122
- superseded,
118
+ patchFilename: plan.patchFilename,
119
+ metadata: plan.metadata,
120
+ superseded: plan.supersededPatches,
123
121
  };
124
122
  });
125
123
  }
@@ -177,6 +175,67 @@ export async function findExistingPatchForFile(patchesDir, filePath) {
177
175
  export async function updatePatch(patchPath, newContent) {
178
176
  await writeText(patchPath, newContent);
179
177
  }
178
+ /**
179
+ * Updates a patch file body and its manifest row under the same patch
180
+ * directory lock. Intended for commands like `re-export --files` where the
181
+ * file body and `filesAffected` metadata must move together.
182
+ *
183
+ * If the manifest write fails after the patch body has been rewritten, the
184
+ * original patch content is restored best-effort before the error is
185
+ * re-thrown.
186
+ *
187
+ * @param patchesDir - Path to the patches directory
188
+ * @param filename - Target patch filename
189
+ * @param newContent - New patch body
190
+ * @param updates - Metadata fields to merge into the existing row
191
+ * @param onCommitted - Optional hook that runs inside the same lock after
192
+ * the mutation succeeds. See {@link UpdatePatchCommittedHook}.
193
+ */
194
+ export async function updatePatchAndMetadata(patchesDir, filename, newContent, updates, onCommitted) {
195
+ await withPatchDirectoryLock(patchesDir, async () => {
196
+ const manifest = await loadPatchesManifest(patchesDir);
197
+ if (!manifest) {
198
+ throw new Error('Cannot update patch metadata: patches.json is missing.');
199
+ }
200
+ const patchIndex = manifest.patches.findIndex((p) => p.filename === filename);
201
+ if (patchIndex === -1) {
202
+ throw new Error(`Cannot update patch metadata: ${filename} not found in patches.json.`);
203
+ }
204
+ const patchPath = join(patchesDir, filename);
205
+ if (!(await pathExists(patchPath))) {
206
+ throw new Error(`Cannot update patch: patch file is missing on disk: ${filename}`);
207
+ }
208
+ const originalContent = await readText(patchPath);
209
+ const existingPatch = manifest.patches[patchIndex];
210
+ manifest.patches[patchIndex] = { ...existingPatch, ...updates };
211
+ let patchWritten = false;
212
+ try {
213
+ await writeText(patchPath, newContent);
214
+ patchWritten = true;
215
+ await savePatchesManifest(patchesDir, manifest);
216
+ }
217
+ catch (error) {
218
+ if (patchWritten) {
219
+ try {
220
+ await writeText(patchPath, originalContent);
221
+ }
222
+ catch (rollbackError) {
223
+ warn(`Rollback warning: could not restore ${filename} after metadata write failed: ${toError(rollbackError).message}`);
224
+ }
225
+ }
226
+ throw error;
227
+ }
228
+ if (onCommitted) {
229
+ try {
230
+ await onCommitted();
231
+ }
232
+ catch (hookError) {
233
+ warn(`History log append failed after updatePatchAndMetadata committed (${filename}): ` +
234
+ toError(hookError).message);
235
+ }
236
+ }
237
+ });
238
+ }
180
239
  /**
181
240
  * Updates metadata for a patch in the manifest.
182
241
  * @param patchesDir - Path to the patches directory
@@ -275,14 +334,18 @@ export async function deletePatch(patchesDir, filename) {
275
334
  * A patch is fully covered when every file it affects is present in the new export.
276
335
  * @param patchFiles - Files affected by the existing patch
277
336
  * @param targetFiles - Files affected by the new export
278
- * @returns True when the existing patch is fully covered
337
+ * @returns Coverage report with the triggering file list when `covered` is true
279
338
  */
280
339
  export function isPatchFullyCovered(patchFiles, targetFiles) {
281
340
  if (patchFiles.length === 0) {
282
- return false;
341
+ return { covered: false, byFiles: [] };
283
342
  }
284
343
  const targetFileSet = new Set(targetFiles);
285
- return patchFiles.every((file) => targetFileSet.has(file));
344
+ const covered = patchFiles.every((file) => targetFileSet.has(file));
345
+ return {
346
+ covered,
347
+ byFiles: covered ? [...patchFiles] : [],
348
+ };
286
349
  }
287
350
  /**
288
351
  * Finds patches whose filesAffected entries are fully covered by the specified files.
@@ -302,7 +365,7 @@ export async function findAllPatchesForFiles(patchesDir, targetFiles, excludeFil
302
365
  // Skip the new patch itself
303
366
  if (excludeFilename && metadata.filename === excludeFilename)
304
367
  continue;
305
- if (isPatchFullyCovered(metadata.filesAffected, targetFiles)) {
368
+ if (isPatchFullyCovered(metadata.filesAffected, targetFiles).covered) {
306
369
  const patch = patches.find((p) => p.filename === metadata.filename);
307
370
  if (patch) {
308
371
  superseded.push(patch);
@@ -311,4 +374,99 @@ export async function findAllPatchesForFiles(patchesDir, targetFiles, excludeFil
311
374
  }
312
375
  return superseded;
313
376
  }
377
+ /**
378
+ * Resolves coverage details for every existing patch that the new export
379
+ * would fully cover. Mirrors {@link findAllPatchesForFiles} but returns the
380
+ * widened {@link PatchCoverage.byFiles} list per match so callers can render
381
+ * a per-patch breakdown.
382
+ */
383
+ export async function findAllPatchesForFilesWithDetails(patchesDir, targetFiles, excludeFilename) {
384
+ const manifest = await loadPatchesManifest(patchesDir);
385
+ if (!manifest)
386
+ return [];
387
+ const patches = await discoverPatches(patchesDir);
388
+ const results = [];
389
+ for (const metadata of manifest.patches) {
390
+ if (excludeFilename && metadata.filename === excludeFilename)
391
+ continue;
392
+ const coverage = isPatchFullyCovered(metadata.filesAffected, targetFiles);
393
+ if (!coverage.covered)
394
+ continue;
395
+ const patch = patches.find((p) => p.filename === metadata.filename);
396
+ if (!patch)
397
+ continue;
398
+ results.push({ patch, coverage, metadata });
399
+ }
400
+ return results;
401
+ }
402
+ /**
403
+ * Internal planning helper. Does NOT take the patch directory lock — the
404
+ * caller must already hold it — because the two public entry points
405
+ * ({@link planExport} and {@link commitExportedPatch}) each take their own
406
+ * lock for the full operation. Sharing this single pure computation is how
407
+ * dry-run previews and real writes stay in lockstep by construction
408
+ * instead of by parallel implementations that can drift.
409
+ */
410
+ async function computeExportPlanUnderLock(input) {
411
+ const patchFilename = await getNextPatchFilename(input.patchesDir, input.category, input.name);
412
+ const patchPath = join(input.patchesDir, patchFilename);
413
+ const metadata = {
414
+ filename: patchFilename,
415
+ order: parseInt(patchFilename.split('-')[0] ?? '0', 10),
416
+ category: input.category,
417
+ name: input.name,
418
+ description: input.description,
419
+ createdAt: new Date().toISOString(),
420
+ sourceEsrVersion: input.sourceEsrVersion,
421
+ filesAffected: input.filesAffected,
422
+ };
423
+ const supersedeMatches = await findAllPatchesForFilesWithDetails(input.patchesDir, input.filesAffected, patchFilename);
424
+ const supersededDetails = supersedeMatches.map((m) => ({
425
+ filename: m.patch.filename,
426
+ coveredByFiles: m.coverage.byFiles,
427
+ }));
428
+ const supersededPatches = supersedeMatches.map((m) => m.patch);
429
+ const manifestBefore = await loadPatchesManifest(input.patchesDir);
430
+ const supersededSet = new Set(supersededDetails.map((s) => s.filename));
431
+ const afterPatches = (manifestBefore?.patches ?? []).filter((p) => !supersededSet.has(p.filename) && p.filename !== patchFilename);
432
+ afterPatches.push(metadata);
433
+ afterPatches.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
434
+ return {
435
+ patchFilename,
436
+ patchPath,
437
+ metadata,
438
+ supersededDetails,
439
+ supersededPatches,
440
+ manifestBefore: manifestBefore ?? null,
441
+ manifestAfter: {
442
+ version: 1,
443
+ patches: afterPatches,
444
+ },
445
+ };
446
+ }
447
+ /**
448
+ * Read-only planning function — computes everything a real export would
449
+ * do without writing anything to disk. Takes the patch directory lock
450
+ * briefly, runs {@link computeExportPlanUnderLock}, releases the lock,
451
+ * and returns the plan for preview rendering.
452
+ *
453
+ * Shares {@link computeExportPlanUnderLock} with {@link commitExportedPatch}
454
+ * so the dry-run preview cannot drift from the real write. The real write
455
+ * path does NOT reuse a prior plan object (another export may have landed
456
+ * between dry-run and commit, which would stale the filename allocation);
457
+ * it re-runs the same helper under a fresh lock. The guarantee is "same
458
+ * code, possibly different data," not "same plan object."
459
+ */
460
+ export async function planExport(input) {
461
+ return withPatchDirectoryLock(input.patchesDir, async () => {
462
+ const plan = await computeExportPlanUnderLock(input);
463
+ return {
464
+ patchFilename: plan.patchFilename,
465
+ metadata: plan.metadata,
466
+ superseded: plan.supersededDetails,
467
+ manifestBefore: plan.manifestBefore,
468
+ manifestAfter: plan.manifestAfter,
469
+ };
470
+ });
471
+ }
314
472
  //# sourceMappingURL=patch-export.js.map