@fern-api/replay 0.19.0 → 0.19.2

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
@@ -884,6 +884,31 @@ declare class ReplayService {
884
884
  */
885
885
  getLastResults(): ReadonlyArray<ReplayResult>;
886
886
  constructor(outputDir: string, _config: ReplayConfig);
887
+ /**
888
+ * Resolve the previous-generation SHA to export on
889
+ * `ReplayPreparation.previousGenerationSha`.
890
+ *
891
+ * `current_generation` is the SHA of a LOCALLY-created `[fern-generated]` commit.
892
+ * generator-cli's signed-commit push (`pushSignedCommit`) recreates that commit under a
893
+ * new remote SHA, and a squash-merge collapses it away, so the recorded value can be
894
+ * unreachable in a later clone (org "Natural": autoVersion then diffed against a phantom
895
+ * baseline and silently reported NO_CHANGE — or, pre-a38bbc6, shipped
896
+ * `0.0.0-fern-placeholder`). Consumers (generator-cli's autoVersion) diff against this
897
+ * baseline, so it MUST resolve to a real commit.
898
+ *
899
+ * Prefer the recorded SHA while it still resolves — the healthy case, and the pristine
900
+ * prior `[fern-generated]` tree. Otherwise re-anchor on the most recent reachable
901
+ * generation boundary in first-parent history: the SAME value the detector already
902
+ * derives for its own scan boundary (`findPreviousGenerationFromHistory`, whose
903
+ * `isGenerationBoundary` matches squash-merge subjects like `SDK Generation (#N)`). Fall
904
+ * back to the recorded SHA only when no boundary is reachable at all — no worse than the
905
+ * prior behavior. ADR 0002 (generator-cli) allows replay to expose this reconciled value;
906
+ * the consumer stays independently resilient.
907
+ *
908
+ * MUST be called before the new `[fern-generated]` commit is created, while HEAD is still
909
+ * the pre-generation (customer) state, so the walk anchors on the PREVIOUS generation.
910
+ */
911
+ resolveReachablePreviousGeneration(recordedSha: string | null): Promise<string | null>;
887
912
  /**
888
913
  * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
889
914
  * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
@@ -970,6 +995,21 @@ declare class ReplayService {
970
995
  * never be used during apply anyway).
971
996
  */
972
997
  private readSnapshotFromDisk;
998
+ /**
999
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
1000
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
1001
+ *
1002
+ * The generation tree (BASE) still carries the placeholder on version lines
1003
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
1004
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
1005
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
1006
+ * pinning a stale version. This re-derives both against a version-neutralized
1007
+ * THEIRS so the pipeline-owned version line never enters the patch.
1008
+ *
1009
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
1010
+ * placeholder in the generation tree) — callers keep their existing behavior.
1011
+ */
1012
+ private computeAutoversionNeutralizedRebase;
973
1013
  /**
974
1014
  * Detects autoversion contamination — returns true when a
975
1015
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
package/dist/index.d.ts CHANGED
@@ -884,6 +884,31 @@ declare class ReplayService {
884
884
  */
885
885
  getLastResults(): ReadonlyArray<ReplayResult>;
886
886
  constructor(outputDir: string, _config: ReplayConfig);
887
+ /**
888
+ * Resolve the previous-generation SHA to export on
889
+ * `ReplayPreparation.previousGenerationSha`.
890
+ *
891
+ * `current_generation` is the SHA of a LOCALLY-created `[fern-generated]` commit.
892
+ * generator-cli's signed-commit push (`pushSignedCommit`) recreates that commit under a
893
+ * new remote SHA, and a squash-merge collapses it away, so the recorded value can be
894
+ * unreachable in a later clone (org "Natural": autoVersion then diffed against a phantom
895
+ * baseline and silently reported NO_CHANGE — or, pre-a38bbc6, shipped
896
+ * `0.0.0-fern-placeholder`). Consumers (generator-cli's autoVersion) diff against this
897
+ * baseline, so it MUST resolve to a real commit.
898
+ *
899
+ * Prefer the recorded SHA while it still resolves — the healthy case, and the pristine
900
+ * prior `[fern-generated]` tree. Otherwise re-anchor on the most recent reachable
901
+ * generation boundary in first-parent history: the SAME value the detector already
902
+ * derives for its own scan boundary (`findPreviousGenerationFromHistory`, whose
903
+ * `isGenerationBoundary` matches squash-merge subjects like `SDK Generation (#N)`). Fall
904
+ * back to the recorded SHA only when no boundary is reachable at all — no worse than the
905
+ * prior behavior. ADR 0002 (generator-cli) allows replay to expose this reconciled value;
906
+ * the consumer stays independently resilient.
907
+ *
908
+ * MUST be called before the new `[fern-generated]` commit is created, while HEAD is still
909
+ * the pre-generation (customer) state, so the walk anchors on the PREVIOUS generation.
910
+ */
911
+ resolveReachablePreviousGeneration(recordedSha: string | null): Promise<string | null>;
887
912
  /**
888
913
  * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
889
914
  * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
@@ -970,6 +995,21 @@ declare class ReplayService {
970
995
  * never be used during apply anyway).
971
996
  */
972
997
  private readSnapshotFromDisk;
998
+ /**
999
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
1000
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
1001
+ *
1002
+ * The generation tree (BASE) still carries the placeholder on version lines
1003
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
1004
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
1005
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
1006
+ * pinning a stale version. This re-derives both against a version-neutralized
1007
+ * THEIRS so the pipeline-owned version line never enters the patch.
1008
+ *
1009
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
1010
+ * placeholder in the generation tree) — callers keep their existing behavior.
1011
+ */
1012
+ private computeAutoversionNeutralizedRebase;
973
1013
  /**
974
1014
  * Detects autoversion contamination — returns true when a
975
1015
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any
package/dist/index.js CHANGED
@@ -2027,6 +2027,51 @@ async function buildMergePlan(args) {
2027
2027
  // src/applicator/runMergeAndRecord.ts
2028
2028
  import { mkdir, writeFile } from "fs/promises";
2029
2029
  import { dirname as dirname2 } from "path";
2030
+
2031
+ // src/autoversion.ts
2032
+ var AUTOVERSION_PLACEHOLDERS = [
2033
+ "v0.0.0-fern-placeholder",
2034
+ // Go -- a superstring of the default; check first
2035
+ "0.0.0-fern-placeholder",
2036
+ // TS/Java/C#/Ruby/PHP/Rust/default
2037
+ "0.0.0.dev0"
2038
+ // Python (PEP 440 -- Poetry rejects hyphens)
2039
+ ];
2040
+ var VERSION_TOKEN = /(?:v)?\d+\.\d+\.\d+[0-9A-Za-z.+-]*/g;
2041
+ var VERSION_MASK = "@@FERN_VERSION@@";
2042
+ function maskVersions(line) {
2043
+ return line.replace(VERSION_TOKEN, VERSION_MASK);
2044
+ }
2045
+ function containsPlaceholder(content) {
2046
+ if (!content) return false;
2047
+ return AUTOVERSION_PLACEHOLDERS.some((p) => content.includes(p));
2048
+ }
2049
+ function remapManagedLines(genBase, target, theirs) {
2050
+ if (!containsPlaceholder(genBase)) return theirs;
2051
+ const managedMasks = /* @__PURE__ */ new Set();
2052
+ for (const line of genBase.split("\n")) {
2053
+ if (AUTOVERSION_PLACEHOLDERS.some((p) => line.includes(p))) managedMasks.add(maskVersions(line));
2054
+ }
2055
+ if (managedMasks.size === 0) return theirs;
2056
+ const targetByMask = /* @__PURE__ */ new Map();
2057
+ for (const line of target.split("\n")) {
2058
+ const mask = maskVersions(line);
2059
+ if (managedMasks.has(mask) && !targetByMask.has(mask)) targetByMask.set(mask, line);
2060
+ }
2061
+ return theirs.split("\n").map((line) => {
2062
+ const mask = maskVersions(line);
2063
+ const replacement = managedMasks.has(mask) ? targetByMask.get(mask) : void 0;
2064
+ return replacement !== void 0 && replacement !== line ? replacement : line;
2065
+ }).join("\n");
2066
+ }
2067
+ function neutralizeAutoversionTheirs(genBase, theirs) {
2068
+ return remapManagedLines(genBase, genBase, theirs);
2069
+ }
2070
+ function alignAutoversionToOurs(genBase, ours, theirs) {
2071
+ return remapManagedLines(genBase, ours, theirs);
2072
+ }
2073
+
2074
+ // src/applicator/runMergeAndRecord.ts
2030
2075
  async function runMergeAndRecord(args) {
2031
2076
  const { plan, inputs, patch, accumulator } = args;
2032
2077
  const { resolvedPath, metadata, oursPath, ours } = inputs;
@@ -2057,11 +2102,13 @@ async function runMergeAndRecord(args) {
2057
2102
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
2058
2103
  return { file: resolvedPath, status: "merged", ...unreachableFlag };
2059
2104
  }
2060
- const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
2105
+ const mergeBase = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.mergeBase) : plan.mergeBase;
2106
+ const theirs = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.theirs) : plan.theirs;
2107
+ const merged = threeWayMerge(mergeBase, ours, theirs);
2061
2108
  await mkdir(dirname2(oursPath), { recursive: true });
2062
2109
  await writeFile(oursPath, merged.content);
2063
2110
  if (merged.hasConflicts) {
2064
- accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
2111
+ accumulator.recordConflict(resolvedPath, theirs, patch.base_generation);
2065
2112
  return {
2066
2113
  file: resolvedPath,
2067
2114
  status: "conflict",
@@ -2070,7 +2117,7 @@ async function runMergeAndRecord(args) {
2070
2117
  conflictMetadata: metadata
2071
2118
  };
2072
2119
  }
2073
- accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
2120
+ accumulator.recordMerge(resolvedPath, theirs, patch.base_generation);
2074
2121
  return {
2075
2122
  file: resolvedPath,
2076
2123
  status: "merged",
@@ -3198,7 +3245,9 @@ var NoPatchesFlow = class {
3198
3245
  }
3199
3246
  async prepare(options) {
3200
3247
  const preLock = this.service.lockManager.read();
3201
- const previousGenerationSha = preLock.current_generation ?? null;
3248
+ const previousGenerationSha = await this.service.resolveReachablePreviousGeneration(
3249
+ preLock.current_generation ?? null
3250
+ );
3202
3251
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3203
3252
  const detectorWarnings = [...this.service.detector.warnings];
3204
3253
  const detectorDegraded = [...this.service.detector.degradedReasons];
@@ -3336,7 +3385,9 @@ var NormalRegenerationFlow = class {
3336
3385
  }
3337
3386
  async prepare(options) {
3338
3387
  const preLock = this.service.lockManager.read();
3339
- const previousGenerationSha = preLock.current_generation ?? null;
3388
+ const previousGenerationSha = await this.service.resolveReachablePreviousGeneration(
3389
+ preLock.current_generation ?? null
3390
+ );
3340
3391
  if (options?.dryRun) {
3341
3392
  const existingPatches2 = this.service.lockManager.getPatches();
3342
3393
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
@@ -3574,6 +3625,37 @@ var ReplayService = class {
3574
3625
  this.committer = new ReplayCommitter(git, outputDir);
3575
3626
  this.fileOwnership = new FileOwnership(git);
3576
3627
  }
3628
+ /**
3629
+ * Resolve the previous-generation SHA to export on
3630
+ * `ReplayPreparation.previousGenerationSha`.
3631
+ *
3632
+ * `current_generation` is the SHA of a LOCALLY-created `[fern-generated]` commit.
3633
+ * generator-cli's signed-commit push (`pushSignedCommit`) recreates that commit under a
3634
+ * new remote SHA, and a squash-merge collapses it away, so the recorded value can be
3635
+ * unreachable in a later clone (org "Natural": autoVersion then diffed against a phantom
3636
+ * baseline and silently reported NO_CHANGE — or, pre-a38bbc6, shipped
3637
+ * `0.0.0-fern-placeholder`). Consumers (generator-cli's autoVersion) diff against this
3638
+ * baseline, so it MUST resolve to a real commit.
3639
+ *
3640
+ * Prefer the recorded SHA while it still resolves — the healthy case, and the pristine
3641
+ * prior `[fern-generated]` tree. Otherwise re-anchor on the most recent reachable
3642
+ * generation boundary in first-parent history: the SAME value the detector already
3643
+ * derives for its own scan boundary (`findPreviousGenerationFromHistory`, whose
3644
+ * `isGenerationBoundary` matches squash-merge subjects like `SDK Generation (#N)`). Fall
3645
+ * back to the recorded SHA only when no boundary is reachable at all — no worse than the
3646
+ * prior behavior. ADR 0002 (generator-cli) allows replay to expose this reconciled value;
3647
+ * the consumer stays independently resilient.
3648
+ *
3649
+ * MUST be called before the new `[fern-generated]` commit is created, while HEAD is still
3650
+ * the pre-generation (customer) state, so the walk anchors on the PREVIOUS generation.
3651
+ */
3652
+ async resolveReachablePreviousGeneration(recordedSha) {
3653
+ if (recordedSha != null && await this.git.commitExists(recordedSha)) {
3654
+ return recordedSha;
3655
+ }
3656
+ const derived = await this.detector.findPreviousGenerationFromHistory();
3657
+ return derived ?? recordedSha;
3658
+ }
3577
3659
  /**
3578
3660
  * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
3579
3661
  * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
@@ -3856,7 +3938,9 @@ var ReplayService = class {
3856
3938
  if (baseChanged) repointed++;
3857
3939
  continue;
3858
3940
  }
3859
- const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3941
+ const rawDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3942
+ const neutralizedRebase = rawDiff != null && containsPlaceholder(rawDiff) ? await this.computeAutoversionNeutralizedRebase(patch.files, currentGenSha) : null;
3943
+ const diff = neutralizedRebase ? neutralizedRebase.diff : rawDiff;
3860
3944
  if (diff === null) continue;
3861
3945
  if (!diff.trim()) {
3862
3946
  if (hasProtectedFiles) {
@@ -3912,7 +3996,7 @@ var ReplayService = class {
3912
3996
  patch.base_generation = currentGenSha;
3913
3997
  patch.patch_content = diff;
3914
3998
  patch.content_hash = newContentHash;
3915
- const snapshot = await this.readSnapshotFromDisk(patch.files);
3999
+ const snapshot = neutralizedRebase ? neutralizedRebase.snapshot : await this.readSnapshotFromDisk(patch.files);
3916
4000
  const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3917
4001
  if (snapshotIsNonEmpty) {
3918
4002
  patch.theirs_snapshot = snapshot;
@@ -3952,6 +4036,47 @@ var ReplayService = class {
3952
4036
  }
3953
4037
  return snapshot;
3954
4038
  }
4039
+ /**
4040
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
4041
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
4042
+ *
4043
+ * The generation tree (BASE) still carries the placeholder on version lines
4044
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
4045
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
4046
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
4047
+ * pinning a stale version. This re-derives both against a version-neutralized
4048
+ * THEIRS so the pipeline-owned version line never enters the patch.
4049
+ *
4050
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
4051
+ * placeholder in the generation tree) — callers keep their existing behavior.
4052
+ */
4053
+ async computeAutoversionNeutralizedRebase(files, currentGenSha) {
4054
+ const genByFile = /* @__PURE__ */ new Map();
4055
+ let anyPlaceholder = false;
4056
+ for (const file of files) {
4057
+ const gen = await this.git.showFile(currentGenSha, file).catch(() => null);
4058
+ genByFile.set(file, gen);
4059
+ if (containsPlaceholder(gen)) anyPlaceholder = true;
4060
+ }
4061
+ if (!anyPlaceholder) return null;
4062
+ const snapshot = {};
4063
+ const fragments = [];
4064
+ for (const file of files) {
4065
+ if (isBinaryFile(file)) continue;
4066
+ const disk = await readFileAsync(join7(this.outputDir, file), "utf-8").catch(() => null);
4067
+ if (disk == null) continue;
4068
+ const gen = genByFile.get(file) ?? null;
4069
+ const neutralized = gen != null ? neutralizeAutoversionTheirs(gen, disk) : disk;
4070
+ snapshot[file] = neutralized;
4071
+ if (gen == null) {
4072
+ fragments.push(synthesizeNewFileDiff(file, neutralized));
4073
+ } else if (gen !== neutralized) {
4074
+ const fileDiff = await unifiedDiff(gen, neutralized, file);
4075
+ if (fileDiff.trim()) fragments.push(fileDiff);
4076
+ }
4077
+ }
4078
+ return { diff: fragments.join(""), snapshot };
4079
+ }
3955
4080
  /**
3956
4081
  * Detects autoversion contamination — returns true when a
3957
4082
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any