@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.cjs CHANGED
@@ -2082,6 +2082,51 @@ async function buildMergePlan(args) {
2082
2082
  // src/applicator/runMergeAndRecord.ts
2083
2083
  var import_promises2 = require("fs/promises");
2084
2084
  var import_node_path4 = require("path");
2085
+
2086
+ // src/autoversion.ts
2087
+ var AUTOVERSION_PLACEHOLDERS = [
2088
+ "v0.0.0-fern-placeholder",
2089
+ // Go -- a superstring of the default; check first
2090
+ "0.0.0-fern-placeholder",
2091
+ // TS/Java/C#/Ruby/PHP/Rust/default
2092
+ "0.0.0.dev0"
2093
+ // Python (PEP 440 -- Poetry rejects hyphens)
2094
+ ];
2095
+ var VERSION_TOKEN = /(?:v)?\d+\.\d+\.\d+[0-9A-Za-z.+-]*/g;
2096
+ var VERSION_MASK = "@@FERN_VERSION@@";
2097
+ function maskVersions(line) {
2098
+ return line.replace(VERSION_TOKEN, VERSION_MASK);
2099
+ }
2100
+ function containsPlaceholder(content) {
2101
+ if (!content) return false;
2102
+ return AUTOVERSION_PLACEHOLDERS.some((p) => content.includes(p));
2103
+ }
2104
+ function remapManagedLines(genBase, target, theirs) {
2105
+ if (!containsPlaceholder(genBase)) return theirs;
2106
+ const managedMasks = /* @__PURE__ */ new Set();
2107
+ for (const line of genBase.split("\n")) {
2108
+ if (AUTOVERSION_PLACEHOLDERS.some((p) => line.includes(p))) managedMasks.add(maskVersions(line));
2109
+ }
2110
+ if (managedMasks.size === 0) return theirs;
2111
+ const targetByMask = /* @__PURE__ */ new Map();
2112
+ for (const line of target.split("\n")) {
2113
+ const mask = maskVersions(line);
2114
+ if (managedMasks.has(mask) && !targetByMask.has(mask)) targetByMask.set(mask, line);
2115
+ }
2116
+ return theirs.split("\n").map((line) => {
2117
+ const mask = maskVersions(line);
2118
+ const replacement = managedMasks.has(mask) ? targetByMask.get(mask) : void 0;
2119
+ return replacement !== void 0 && replacement !== line ? replacement : line;
2120
+ }).join("\n");
2121
+ }
2122
+ function neutralizeAutoversionTheirs(genBase, theirs) {
2123
+ return remapManagedLines(genBase, genBase, theirs);
2124
+ }
2125
+ function alignAutoversionToOurs(genBase, ours, theirs) {
2126
+ return remapManagedLines(genBase, ours, theirs);
2127
+ }
2128
+
2129
+ // src/applicator/runMergeAndRecord.ts
2085
2130
  async function runMergeAndRecord(args) {
2086
2131
  const { plan, inputs, patch, accumulator } = args;
2087
2132
  const { resolvedPath, metadata, oursPath, ours } = inputs;
@@ -2112,11 +2157,13 @@ async function runMergeAndRecord(args) {
2112
2157
  accumulator.recordMerge(resolvedPath, merged2.content, patch.base_generation);
2113
2158
  return { file: resolvedPath, status: "merged", ...unreachableFlag };
2114
2159
  }
2115
- const merged = threeWayMerge(plan.mergeBase, ours, plan.theirs);
2160
+ const mergeBase = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.mergeBase) : plan.mergeBase;
2161
+ const theirs = inputs.base != null ? alignAutoversionToOurs(inputs.base, ours, plan.theirs) : plan.theirs;
2162
+ const merged = threeWayMerge(mergeBase, ours, theirs);
2116
2163
  await (0, import_promises2.mkdir)((0, import_node_path4.dirname)(oursPath), { recursive: true });
2117
2164
  await (0, import_promises2.writeFile)(oursPath, merged.content);
2118
2165
  if (merged.hasConflicts) {
2119
- accumulator.recordConflict(resolvedPath, plan.theirs, patch.base_generation);
2166
+ accumulator.recordConflict(resolvedPath, theirs, patch.base_generation);
2120
2167
  return {
2121
2168
  file: resolvedPath,
2122
2169
  status: "conflict",
@@ -2125,7 +2172,7 @@ async function runMergeAndRecord(args) {
2125
2172
  conflictMetadata: metadata
2126
2173
  };
2127
2174
  }
2128
- accumulator.recordMerge(resolvedPath, plan.theirs, patch.base_generation);
2175
+ accumulator.recordMerge(resolvedPath, theirs, patch.base_generation);
2129
2176
  return {
2130
2177
  file: resolvedPath,
2131
2178
  status: "merged",
@@ -3253,7 +3300,9 @@ var NoPatchesFlow = class {
3253
3300
  }
3254
3301
  async prepare(options) {
3255
3302
  const preLock = this.service.lockManager.read();
3256
- const previousGenerationSha = preLock.current_generation ?? null;
3303
+ const previousGenerationSha = await this.service.resolveReachablePreviousGeneration(
3304
+ preLock.current_generation ?? null
3305
+ );
3257
3306
  let { patches: newPatches, revertedPatchIds } = await this.service.detector.detectNewPatches();
3258
3307
  const detectorWarnings = [...this.service.detector.warnings];
3259
3308
  const detectorDegraded = [...this.service.detector.degradedReasons];
@@ -3391,7 +3440,9 @@ var NormalRegenerationFlow = class {
3391
3440
  }
3392
3441
  async prepare(options) {
3393
3442
  const preLock = this.service.lockManager.read();
3394
- const previousGenerationSha = preLock.current_generation ?? null;
3443
+ const previousGenerationSha = await this.service.resolveReachablePreviousGeneration(
3444
+ preLock.current_generation ?? null
3445
+ );
3395
3446
  if (options?.dryRun) {
3396
3447
  const existingPatches2 = this.service.lockManager.getPatches();
3397
3448
  const { patches: newPatches2, revertedPatchIds: dryRunReverted } = await this.service.detector.detectNewPatches();
@@ -3629,6 +3680,37 @@ var ReplayService = class {
3629
3680
  this.committer = new ReplayCommitter(git, outputDir);
3630
3681
  this.fileOwnership = new FileOwnership(git);
3631
3682
  }
3683
+ /**
3684
+ * Resolve the previous-generation SHA to export on
3685
+ * `ReplayPreparation.previousGenerationSha`.
3686
+ *
3687
+ * `current_generation` is the SHA of a LOCALLY-created `[fern-generated]` commit.
3688
+ * generator-cli's signed-commit push (`pushSignedCommit`) recreates that commit under a
3689
+ * new remote SHA, and a squash-merge collapses it away, so the recorded value can be
3690
+ * unreachable in a later clone (org "Natural": autoVersion then diffed against a phantom
3691
+ * baseline and silently reported NO_CHANGE — or, pre-a38bbc6, shipped
3692
+ * `0.0.0-fern-placeholder`). Consumers (generator-cli's autoVersion) diff against this
3693
+ * baseline, so it MUST resolve to a real commit.
3694
+ *
3695
+ * Prefer the recorded SHA while it still resolves — the healthy case, and the pristine
3696
+ * prior `[fern-generated]` tree. Otherwise re-anchor on the most recent reachable
3697
+ * generation boundary in first-parent history: the SAME value the detector already
3698
+ * derives for its own scan boundary (`findPreviousGenerationFromHistory`, whose
3699
+ * `isGenerationBoundary` matches squash-merge subjects like `SDK Generation (#N)`). Fall
3700
+ * back to the recorded SHA only when no boundary is reachable at all — no worse than the
3701
+ * prior behavior. ADR 0002 (generator-cli) allows replay to expose this reconciled value;
3702
+ * the consumer stays independently resilient.
3703
+ *
3704
+ * MUST be called before the new `[fern-generated]` commit is created, while HEAD is still
3705
+ * the pre-generation (customer) state, so the walk anchors on the PREVIOUS generation.
3706
+ */
3707
+ async resolveReachablePreviousGeneration(recordedSha) {
3708
+ if (recordedSha != null && await this.git.commitExists(recordedSha)) {
3709
+ return recordedSha;
3710
+ }
3711
+ const derived = await this.detector.findPreviousGenerationFromHistory();
3712
+ return derived ?? recordedSha;
3713
+ }
3632
3714
  /**
3633
3715
  * Phase 1 of the two-phase replay flow. Runs preGenerationRebase (when applicable),
3634
3716
  * detects new patches, and creates the `[fern-generated]` commit. Leaves HEAD at
@@ -3911,7 +3993,9 @@ var ReplayService = class {
3911
3993
  if (baseChanged) repointed++;
3912
3994
  continue;
3913
3995
  }
3914
- const diff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3996
+ const rawDiff = await this.git.exec(["diff", currentGenSha, "--", ...patch.files]).catch(() => null);
3997
+ const neutralizedRebase = rawDiff != null && containsPlaceholder(rawDiff) ? await this.computeAutoversionNeutralizedRebase(patch.files, currentGenSha) : null;
3998
+ const diff = neutralizedRebase ? neutralizedRebase.diff : rawDiff;
3915
3999
  if (diff === null) continue;
3916
4000
  if (!diff.trim()) {
3917
4001
  if (hasProtectedFiles) {
@@ -3967,7 +4051,7 @@ var ReplayService = class {
3967
4051
  patch.base_generation = currentGenSha;
3968
4052
  patch.patch_content = diff;
3969
4053
  patch.content_hash = newContentHash;
3970
- const snapshot = await this.readSnapshotFromDisk(patch.files);
4054
+ const snapshot = neutralizedRebase ? neutralizedRebase.snapshot : await this.readSnapshotFromDisk(patch.files);
3971
4055
  const snapshotIsNonEmpty = Object.keys(snapshot).length > 0;
3972
4056
  if (snapshotIsNonEmpty) {
3973
4057
  patch.theirs_snapshot = snapshot;
@@ -4007,6 +4091,47 @@ var ReplayService = class {
4007
4091
  }
4008
4092
  return snapshot;
4009
4093
  }
4094
+ /**
4095
+ * Autoversion-aware replacement for the naive `git diff currentGen -- files`
4096
+ * + on-disk snapshot used when rebasing a cleanly-applied patch.
4097
+ *
4098
+ * The generation tree (BASE) still carries the placeholder on version lines
4099
+ * (autoversion runs after `[fern-generated]`), but the working tree holds
4100
+ * autoversion's resolved semver. A raw diff/snapshot would fold the
4101
+ * placeholder->semver swap into the customer's patch_content/theirs_snapshot,
4102
+ * pinning a stale version. This re-derives both against a version-neutralized
4103
+ * THEIRS so the pipeline-owned version line never enters the patch.
4104
+ *
4105
+ * Returns null when no file is under autoversion (fixed `--version`, i.e. no
4106
+ * placeholder in the generation tree) — callers keep their existing behavior.
4107
+ */
4108
+ async computeAutoversionNeutralizedRebase(files, currentGenSha) {
4109
+ const genByFile = /* @__PURE__ */ new Map();
4110
+ let anyPlaceholder = false;
4111
+ for (const file of files) {
4112
+ const gen = await this.git.showFile(currentGenSha, file).catch(() => null);
4113
+ genByFile.set(file, gen);
4114
+ if (containsPlaceholder(gen)) anyPlaceholder = true;
4115
+ }
4116
+ if (!anyPlaceholder) return null;
4117
+ const snapshot = {};
4118
+ const fragments = [];
4119
+ for (const file of files) {
4120
+ if (isBinaryFile(file)) continue;
4121
+ const disk = await (0, import_promises6.readFile)((0, import_node_path9.join)(this.outputDir, file), "utf-8").catch(() => null);
4122
+ if (disk == null) continue;
4123
+ const gen = genByFile.get(file) ?? null;
4124
+ const neutralized = gen != null ? neutralizeAutoversionTheirs(gen, disk) : disk;
4125
+ snapshot[file] = neutralized;
4126
+ if (gen == null) {
4127
+ fragments.push(synthesizeNewFileDiff(file, neutralized));
4128
+ } else if (gen !== neutralized) {
4129
+ const fileDiff = await unifiedDiff(gen, neutralized, file);
4130
+ if (fileDiff.trim()) fragments.push(fileDiff);
4131
+ }
4132
+ }
4133
+ return { diff: fragments.join(""), snapshot };
4134
+ }
4010
4135
  /**
4011
4136
  * Detects autoversion contamination — returns true when a
4012
4137
  * `[fern-autoversion]` commit between `fromSha` and `toSha` touched any