@fern-api/replay 0.14.0 → 0.15.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/dist/index.d.cts CHANGED
@@ -37,6 +37,31 @@ interface StoredPatch {
37
37
  * the generator will not start producing a file it never produced before.
38
38
  */
39
39
  user_owned?: boolean;
40
+ /**
41
+ * THEIRS snapshot — the post-customer-edit content of each file the patch
42
+ * touches. When present, replay's slow-path 3-way merge uses this directly
43
+ * as THEIRS, bypassing `applyPatchToContent(BASE, patch_content)`
44
+ * reconstruction.
45
+ *
46
+ * Why this exists: `patch_content` is a unified diff anchored to specific
47
+ * line numbers in BASE. When the generator structurally changes a file
48
+ * across regens (different types, dropped/added imports, etc.), those
49
+ * line anchors no longer survive — reconstruction fails or produces
50
+ * garbage, and the customer's customization gets lost. The snapshot
51
+ * stores the customer's intent at content level, not at line-anchor
52
+ * level. diff3 then operates on actual content (BASE vs OURS vs THEIRS)
53
+ * and naturally handles structural changes via merge semantics rather
54
+ * than line bookkeeping.
55
+ *
56
+ * Keys are post-rename paths (matching the `files` array). Files the
57
+ * patch deletes are omitted from the map; the `+++ /dev/null` marker
58
+ * in `patch_content` remains the canonical delete signal.
59
+ *
60
+ * Optional for backward compat. Existing lockfiles without this field
61
+ * auto-migrate on the first regen post-upgrade via
62
+ * `preGenerationRebase`'s migration pass.
63
+ */
64
+ theirs_snapshot?: Record<string, string>;
40
65
  }
41
66
  interface CustomizationsConfig {
42
67
  exclude?: string[];
@@ -199,14 +224,14 @@ declare class LockfileManager {
199
224
  save(): void;
200
225
  addGeneration(record: GenerationRecord): void;
201
226
  addPatch(patch: StoredPatch): void;
202
- updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status" | "user_owned">>): void;
227
+ updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status" | "user_owned" | "theirs_snapshot">>): void;
203
228
  removePatch(patchId: string): void;
204
229
  clearPatches(): void;
205
230
  addForgottenHash(hash: string): void;
206
231
  getUnresolvedPatches(): StoredPatch[];
207
232
  getResolvingPatches(): StoredPatch[];
208
233
  markPatchUnresolved(patchId: string): void;
209
- markPatchResolved(patchId: string, updates: Pick<StoredPatch, "patch_content" | "content_hash" | "base_generation" | "files">): void;
234
+ markPatchResolved(patchId: string, updates: Pick<StoredPatch, "patch_content" | "content_hash" | "base_generation" | "files" | "theirs_snapshot">): void;
210
235
  getPatches(): StoredPatch[];
211
236
  setReplaySkippedAt(timestamp: string): void;
212
237
  clearReplaySkippedAt(): void;
@@ -376,6 +401,26 @@ declare class ReplayApplicator {
376
401
  * cross-patch context mismatch
377
402
  */
378
403
  private currentApplyMode;
404
+ /**
405
+ * Set of files that appear in 2+ patches in the current `applyPatches`
406
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
407
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
408
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
409
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
410
+ *
411
+ * Snapshot-as-primary is correct when the patch owns its file (no other
412
+ * patch in this run touches it): the snapshot is what the customer
413
+ * intends for that file post-regen, regardless of generator structural
414
+ * changes that would invalidate the diff's line anchors.
415
+ *
416
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
417
+ * touching it (each one's snapshot includes prior patches' contributions).
418
+ * Using a later patch's cumulative snapshot as its individual THEIRS
419
+ * would propagate prior patches' edits into the merge — surfacing nested
420
+ * conflicts at regions the patch alone never touched. Reconstruction
421
+ * via `applyPatchToContent` is required there.
422
+ */
423
+ private sharedFiles;
379
424
  constructor(git: GitClient, lockManager: LockfileManager, outputDir: string);
380
425
  /**
381
426
  * Resolve the GenerationRecord for a patch's base_generation.
@@ -680,6 +725,59 @@ declare class ReplayService {
680
725
  * Returns the number of patches rebased.
681
726
  */
682
727
  private rebasePatches;
728
+ /**
729
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
730
+ * from the working tree on disk. Used by `rebasePatches` after a clean
731
+ * apply — disk = correctly-merged customer state at the new generation.
732
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
733
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
734
+ * never be used during apply anyway).
735
+ */
736
+ private readSnapshotFromDisk;
737
+ /**
738
+ * FER-10203: returns true when a `[fern-autoversion]` commit between
739
+ * `fromSha` and `toSha` touched any of the patch's `files`. Re-classifies
740
+ * each grep match via `isGenerationCommit` so a customer commit that
741
+ * merely quotes the marker in its body doesn't false-positive.
742
+ */
743
+ private hasAutoversionContamination;
744
+ /**
745
+ * FER-10203: rebuild patch_content from `theirs_snapshot` (captured at
746
+ * detection time, predates pipeline content) rather than a HEAD-relative
747
+ * diff. Returns `null` when snapshot is missing or doesn't cover every
748
+ * file in `patch.files` — caller leaves the patch unchanged.
749
+ */
750
+ private computeSnapshotBasedPatchDiff;
751
+ /**
752
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
753
+ * Used when the diff that produced `patch_content` was relative to that
754
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
755
+ * Skips binary files.
756
+ */
757
+ private readSnapshotFromTree;
758
+ /**
759
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
760
+ * patches that predate the snapshot encoding. Computes the snapshot by
761
+ * applying the patch's `patch_content` to its `base_generation` tree —
762
+ * yielding the patch's INDIVIDUAL contribution.
763
+ *
764
+ * **Skips patches sharing files with other patches.** HEAD's content
765
+ * for a file shared by N patches is cumulative across all of them, so
766
+ * a HEAD-fallback would falsely encode other patches' contributions
767
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
768
+ * if the snapshot fallback later fires. Per-patch snapshots only
769
+ * make sense when the patch owns its file. Multi-patch-same-file
770
+ * legacy lockfiles keep using line-anchored reconstruction (the
771
+ * existing path); they're no worse than pre-upgrade.
772
+ *
773
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
774
+ * reason — HEAD content can be cumulative. Patches whose
775
+ * reconstruction fails just don't get a snapshot; legacy paths
776
+ * (PR #73's by-association gate, accumulator fallback) handle them.
777
+ *
778
+ * Skips patches that already have a snapshot.
779
+ */
780
+ private migratePatchesToSnapshot;
683
781
  /**
684
782
  * Determine whether `file` is user-owned (never produced by the generator).
685
783
  *
package/dist/index.d.ts CHANGED
@@ -37,6 +37,31 @@ interface StoredPatch {
37
37
  * the generator will not start producing a file it never produced before.
38
38
  */
39
39
  user_owned?: boolean;
40
+ /**
41
+ * THEIRS snapshot — the post-customer-edit content of each file the patch
42
+ * touches. When present, replay's slow-path 3-way merge uses this directly
43
+ * as THEIRS, bypassing `applyPatchToContent(BASE, patch_content)`
44
+ * reconstruction.
45
+ *
46
+ * Why this exists: `patch_content` is a unified diff anchored to specific
47
+ * line numbers in BASE. When the generator structurally changes a file
48
+ * across regens (different types, dropped/added imports, etc.), those
49
+ * line anchors no longer survive — reconstruction fails or produces
50
+ * garbage, and the customer's customization gets lost. The snapshot
51
+ * stores the customer's intent at content level, not at line-anchor
52
+ * level. diff3 then operates on actual content (BASE vs OURS vs THEIRS)
53
+ * and naturally handles structural changes via merge semantics rather
54
+ * than line bookkeeping.
55
+ *
56
+ * Keys are post-rename paths (matching the `files` array). Files the
57
+ * patch deletes are omitted from the map; the `+++ /dev/null` marker
58
+ * in `patch_content` remains the canonical delete signal.
59
+ *
60
+ * Optional for backward compat. Existing lockfiles without this field
61
+ * auto-migrate on the first regen post-upgrade via
62
+ * `preGenerationRebase`'s migration pass.
63
+ */
64
+ theirs_snapshot?: Record<string, string>;
40
65
  }
41
66
  interface CustomizationsConfig {
42
67
  exclude?: string[];
@@ -199,14 +224,14 @@ declare class LockfileManager {
199
224
  save(): void;
200
225
  addGeneration(record: GenerationRecord): void;
201
226
  addPatch(patch: StoredPatch): void;
202
- updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status" | "user_owned">>): void;
227
+ updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status" | "user_owned" | "theirs_snapshot">>): void;
203
228
  removePatch(patchId: string): void;
204
229
  clearPatches(): void;
205
230
  addForgottenHash(hash: string): void;
206
231
  getUnresolvedPatches(): StoredPatch[];
207
232
  getResolvingPatches(): StoredPatch[];
208
233
  markPatchUnresolved(patchId: string): void;
209
- markPatchResolved(patchId: string, updates: Pick<StoredPatch, "patch_content" | "content_hash" | "base_generation" | "files">): void;
234
+ markPatchResolved(patchId: string, updates: Pick<StoredPatch, "patch_content" | "content_hash" | "base_generation" | "files" | "theirs_snapshot">): void;
210
235
  getPatches(): StoredPatch[];
211
236
  setReplaySkippedAt(timestamp: string): void;
212
237
  clearReplaySkippedAt(): void;
@@ -376,6 +401,26 @@ declare class ReplayApplicator {
376
401
  * cross-patch context mismatch
377
402
  */
378
403
  private currentApplyMode;
404
+ /**
405
+ * Set of files that appear in 2+ patches in the current `applyPatches`
406
+ * invocation. Computed at the top of `applyPatches`. Read by `mergeFile`
407
+ * and `populateAccumulatorForPatch` to decide whether to use the patch's
408
+ * `theirs_snapshot` directly as THEIRS (snapshot-as-primary) or fall
409
+ * back to line-anchored reconstruction (FER-9525 cross-patch isolation).
410
+ *
411
+ * Snapshot-as-primary is correct when the patch owns its file (no other
412
+ * patch in this run touches it): the snapshot is what the customer
413
+ * intends for that file post-regen, regardless of generator structural
414
+ * changes that would invalidate the diff's line anchors.
415
+ *
416
+ * When a file IS shared, snapshots are CUMULATIVE across the patches
417
+ * touching it (each one's snapshot includes prior patches' contributions).
418
+ * Using a later patch's cumulative snapshot as its individual THEIRS
419
+ * would propagate prior patches' edits into the merge — surfacing nested
420
+ * conflicts at regions the patch alone never touched. Reconstruction
421
+ * via `applyPatchToContent` is required there.
422
+ */
423
+ private sharedFiles;
379
424
  constructor(git: GitClient, lockManager: LockfileManager, outputDir: string);
380
425
  /**
381
426
  * Resolve the GenerationRecord for a patch's base_generation.
@@ -680,6 +725,59 @@ declare class ReplayService {
680
725
  * Returns the number of patches rebased.
681
726
  */
682
727
  private rebasePatches;
728
+ /**
729
+ * Read THEIRS snapshot (post-customer-edit content) for a list of files
730
+ * from the working tree on disk. Used by `rebasePatches` after a clean
731
+ * apply — disk = correctly-merged customer state at the new generation.
732
+ * Skips binary files (matches `mergeFile`/`populateAccumulatorForPatch`
733
+ * policy — binary content as `utf-8` is lossy and snapshots of it would
734
+ * never be used during apply anyway).
735
+ */
736
+ private readSnapshotFromDisk;
737
+ /**
738
+ * FER-10203: returns true when a `[fern-autoversion]` commit between
739
+ * `fromSha` and `toSha` touched any of the patch's `files`. Re-classifies
740
+ * each grep match via `isGenerationCommit` so a customer commit that
741
+ * merely quotes the marker in its body doesn't false-positive.
742
+ */
743
+ private hasAutoversionContamination;
744
+ /**
745
+ * FER-10203: rebuild patch_content from `theirs_snapshot` (captured at
746
+ * detection time, predates pipeline content) rather than a HEAD-relative
747
+ * diff. Returns `null` when snapshot is missing or doesn't cover every
748
+ * file in `patch.files` — caller leaves the patch unchanged.
749
+ */
750
+ private computeSnapshotBasedPatchDiff;
751
+ /**
752
+ * Read THEIRS snapshot from a git tree-ish (commit, branch, or tree).
753
+ * Used when the diff that produced `patch_content` was relative to that
754
+ * tree (e.g., `git diff currentGen HEAD --` → snapshot from HEAD).
755
+ * Skips binary files.
756
+ */
757
+ private readSnapshotFromTree;
758
+ /**
759
+ * One-shot auto-migration: backfill `theirs_snapshot` on existing
760
+ * patches that predate the snapshot encoding. Computes the snapshot by
761
+ * applying the patch's `patch_content` to its `base_generation` tree —
762
+ * yielding the patch's INDIVIDUAL contribution.
763
+ *
764
+ * **Skips patches sharing files with other patches.** HEAD's content
765
+ * for a file shared by N patches is cumulative across all of them, so
766
+ * a HEAD-fallback would falsely encode other patches' contributions
767
+ * into one patch's snapshot — breaking FER-9525 cross-patch isolation
768
+ * if the snapshot fallback later fires. Per-patch snapshots only
769
+ * make sense when the patch owns its file. Multi-patch-same-file
770
+ * legacy lockfiles keep using line-anchored reconstruction (the
771
+ * existing path); they're no worse than pre-upgrade.
772
+ *
773
+ * **Does not fall back to HEAD when BASE-reconstruction fails.** Same
774
+ * reason — HEAD content can be cumulative. Patches whose
775
+ * reconstruction fails just don't get a snapshot; legacy paths
776
+ * (PR #73's by-association gate, accumulator fallback) handle them.
777
+ *
778
+ * Skips patches that already have a snapshot.
779
+ */
780
+ private migratePatchesToSnapshot;
683
781
  /**
684
782
  * Determine whether `file` is user-owned (never produced by the generator).
685
783
  *