@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.
- package/CHANGELOG.md +93 -1
- package/README.md +125 -238
- package/dist/bin/fireforge.js +26 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +131 -52
- package/dist/src/commands/bootstrap.js +6 -2
- package/dist/src/commands/build.js +4 -2
- package/dist/src/commands/discard.js +16 -4
- package/dist/src/commands/doctor-furnace.d.ts +8 -0
- package/dist/src/commands/doctor-furnace.js +422 -0
- package/dist/src/commands/doctor.d.ts +115 -0
- package/dist/src/commands/doctor.js +327 -258
- package/dist/src/commands/download.js +16 -1
- package/dist/src/commands/export-all.js +15 -0
- package/dist/src/commands/export-flow.d.ts +91 -0
- package/dist/src/commands/export-flow.js +344 -0
- package/dist/src/commands/export.js +151 -5
- package/dist/src/commands/furnace/apply.d.ts +3 -2
- package/dist/src/commands/furnace/apply.js +169 -36
- package/dist/src/commands/furnace/create.js +162 -52
- package/dist/src/commands/furnace/deploy.js +156 -144
- package/dist/src/commands/furnace/diff.d.ts +8 -4
- package/dist/src/commands/furnace/diff.js +142 -73
- package/dist/src/commands/furnace/index.d.ts +6 -2
- package/dist/src/commands/furnace/index.js +76 -25
- package/dist/src/commands/furnace/init.d.ts +11 -0
- package/dist/src/commands/furnace/init.js +76 -0
- package/dist/src/commands/furnace/list.d.ts +4 -1
- package/dist/src/commands/furnace/list.js +35 -3
- package/dist/src/commands/furnace/override.d.ts +8 -0
- package/dist/src/commands/furnace/override.js +216 -26
- package/dist/src/commands/furnace/preview.js +184 -30
- package/dist/src/commands/furnace/refresh.d.ts +10 -0
- package/dist/src/commands/furnace/refresh.js +268 -0
- package/dist/src/commands/furnace/remove.js +285 -89
- package/dist/src/commands/furnace/rename.d.ts +5 -0
- package/dist/src/commands/furnace/rename.js +308 -0
- package/dist/src/commands/furnace/scan.d.ts +4 -1
- package/dist/src/commands/furnace/scan.js +72 -11
- package/dist/src/commands/furnace/status.js +85 -20
- package/dist/src/commands/furnace/sync.d.ts +12 -0
- package/dist/src/commands/furnace/sync.js +77 -0
- package/dist/src/commands/furnace/validate.d.ts +4 -1
- package/dist/src/commands/furnace/validate.js +99 -3
- package/dist/src/commands/furnace/validation-output.d.ts +24 -1
- package/dist/src/commands/furnace/validation-output.js +93 -1
- package/dist/src/commands/import.js +37 -4
- package/dist/src/commands/lint.js +11 -2
- package/dist/src/commands/manifest.d.ts +39 -0
- package/dist/src/commands/manifest.js +59 -0
- package/dist/src/commands/patch/delete.d.ts +28 -0
- package/dist/src/commands/patch/delete.js +209 -0
- package/dist/src/commands/patch/index.d.ts +17 -0
- package/dist/src/commands/patch/index.js +25 -0
- package/dist/src/commands/patch/reorder.d.ts +30 -0
- package/dist/src/commands/patch/reorder.js +377 -0
- package/dist/src/commands/re-export-files.d.ts +17 -0
- package/dist/src/commands/re-export-files.js +177 -0
- package/dist/src/commands/re-export.js +44 -0
- package/dist/src/commands/rebase/abort.d.ts +1 -1
- package/dist/src/commands/rebase/abort.js +12 -3
- package/dist/src/commands/rebase/confirm.d.ts +3 -3
- package/dist/src/commands/rebase/confirm.js +4 -4
- package/dist/src/commands/rebase/index.js +13 -4
- package/dist/src/commands/reset.js +20 -4
- package/dist/src/commands/run.js +46 -1
- package/dist/src/commands/setup-support.js +5 -5
- package/dist/src/commands/status.js +97 -6
- package/dist/src/commands/test.js +5 -37
- package/dist/src/commands/verify.d.ts +31 -0
- package/dist/src/commands/verify.js +126 -0
- package/dist/src/core/build-prepare.js +40 -16
- package/dist/src/core/destructive.d.ts +96 -0
- package/dist/src/core/destructive.js +137 -0
- package/dist/src/core/diff-hunks.d.ts +73 -0
- package/dist/src/core/diff-hunks.js +268 -0
- package/dist/src/core/firefox.d.ts +1 -1
- package/dist/src/core/firefox.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
- package/dist/src/core/furnace-apply-helpers.js +302 -57
- package/dist/src/core/furnace-apply-output.d.ts +16 -0
- package/dist/src/core/furnace-apply-output.js +57 -0
- package/dist/src/core/furnace-apply.d.ts +21 -3
- package/dist/src/core/furnace-apply.js +260 -29
- package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
- package/dist/src/core/furnace-checksum-utils.js +24 -0
- package/dist/src/core/furnace-config.d.ts +28 -1
- package/dist/src/core/furnace-config.js +180 -17
- package/dist/src/core/furnace-constants.d.ts +22 -0
- package/dist/src/core/furnace-constants.js +36 -0
- package/dist/src/core/furnace-graph-utils.d.ts +11 -0
- package/dist/src/core/furnace-graph-utils.js +94 -0
- package/dist/src/core/furnace-operation.d.ts +108 -0
- package/dist/src/core/furnace-operation.js +220 -0
- package/dist/src/core/furnace-refresh.d.ts +20 -0
- package/dist/src/core/furnace-refresh.js +118 -0
- package/dist/src/core/furnace-registration-ast.d.ts +5 -0
- package/dist/src/core/furnace-registration-ast.js +134 -4
- package/dist/src/core/furnace-registration-remove.d.ts +25 -3
- package/dist/src/core/furnace-registration-remove.js +196 -62
- package/dist/src/core/furnace-registration-validate.d.ts +13 -1
- package/dist/src/core/furnace-registration-validate.js +15 -3
- package/dist/src/core/furnace-registration.d.ts +27 -4
- package/dist/src/core/furnace-registration.js +93 -11
- package/dist/src/core/furnace-rollback.d.ts +11 -0
- package/dist/src/core/furnace-rollback.js +78 -7
- package/dist/src/core/furnace-scanner.d.ts +8 -2
- package/dist/src/core/furnace-scanner.js +152 -55
- package/dist/src/core/furnace-stories.js +7 -5
- package/dist/src/core/furnace-validate-accessibility.js +7 -1
- package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
- package/dist/src/core/furnace-validate-compatibility.js +85 -1
- package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
- package/dist/src/core/furnace-validate-helpers.js +31 -0
- package/dist/src/core/furnace-validate-registration.d.ts +17 -2
- package/dist/src/core/furnace-validate-registration.js +73 -3
- package/dist/src/core/furnace-validate-structure.d.ts +10 -2
- package/dist/src/core/furnace-validate-structure.js +45 -3
- package/dist/src/core/furnace-validate.d.ts +10 -1
- package/dist/src/core/furnace-validate.js +80 -6
- package/dist/src/core/furnace-version-drift.d.ts +55 -0
- package/dist/src/core/furnace-version-drift.js +101 -0
- package/dist/src/core/git-file-ops.d.ts +8 -0
- package/dist/src/core/git-file-ops.js +19 -6
- package/dist/src/core/lint-projection.d.ts +25 -0
- package/dist/src/core/lint-projection.js +44 -0
- package/dist/src/core/mach.d.ts +4 -2
- package/dist/src/core/mach.js +17 -2
- package/dist/src/core/markdown-table.d.ts +104 -0
- package/dist/src/core/markdown-table.js +266 -0
- package/dist/src/core/ownership-table.d.ts +53 -0
- package/dist/src/core/ownership-table.js +144 -0
- package/dist/src/core/patch-apply.d.ts +17 -3
- package/dist/src/core/patch-apply.js +86 -8
- package/dist/src/core/patch-export.d.ts +119 -5
- package/dist/src/core/patch-export.js +183 -25
- package/dist/src/core/patch-lint-cross.d.ts +195 -0
- package/dist/src/core/patch-lint-cross.js +428 -0
- package/dist/src/core/patch-lint-diff.d.ts +33 -0
- package/dist/src/core/patch-lint-diff.js +84 -0
- package/dist/src/core/patch-lint.d.ts +2 -4
- package/dist/src/core/patch-lint.js +12 -50
- package/dist/src/core/patch-lock.js +2 -1
- package/dist/src/core/patch-manifest-io.d.ts +102 -1
- package/dist/src/core/patch-manifest-io.js +270 -2
- package/dist/src/core/patch-manifest-query.d.ts +1 -1
- package/dist/src/core/patch-manifest-query.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-transform.d.ts +12 -0
- package/dist/src/core/patch-transform.js +21 -7
- package/dist/src/core/token-manager.js +67 -69
- package/dist/src/core/wire-destroy.js +6 -3
- package/dist/src/core/wire-init.js +10 -4
- package/dist/src/core/wire-subscript.js +9 -3
- package/dist/src/core/wire-utils.d.ts +52 -5
- package/dist/src/core/wire-utils.js +69 -6
- package/dist/src/errors/base.d.ts +20 -0
- package/dist/src/errors/base.js +24 -0
- package/dist/src/errors/furnace.js +7 -1
- package/dist/src/errors/rebase.js +6 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +125 -4
- package/dist/src/types/commands/patches.d.ts +11 -1
- package/dist/src/types/config.d.ts +1 -1
- package/dist/src/types/furnace.d.ts +55 -1
- package/dist/src/utils/fs.d.ts +12 -0
- package/dist/src/utils/fs.js +30 -1
- package/dist/src/utils/package-root.d.ts +5 -0
- package/dist/src/utils/package-root.js +12 -0
- package/dist/src/utils/process.js +9 -4
- package/dist/src/utils/validation.d.ts +20 -2
- package/dist/src/utils/validation.js +26 -3
- 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
|
|
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,
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
198
|
-
for (let
|
|
199
|
-
const remainingPatch = patches[
|
|
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
|
|
32
|
-
* patch directory lock so concurrent exports cannot allocate the
|
|
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
|
|
132
|
+
* @returns Coverage report with the triggering file list when `covered` is true
|
|
89
133
|
*/
|
|
90
|
-
export declare function isPatchFullyCovered(patchFiles: string[], targetFiles: string[]):
|
|
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
|
|
51
|
-
* patch directory lock so concurrent exports cannot allocate the
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
const
|
|
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
|
|
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,
|
|
80
|
-
for (const oldPatch of
|
|
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 (
|
|
108
|
-
await savePatchesManifest(input.patchesDir,
|
|
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
|
|
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
|
-
|
|
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
|