@fern-api/replay 0.15.2 → 0.16.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/cli.cjs CHANGED
@@ -13596,9 +13596,22 @@ function isGenerationCommit(commit) {
13596
13596
  const hasGenerationMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.startsWith("[fern-autoversion]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || // Squash merge of a Fern-generated PR uses the PR title as commit message.
13597
13597
  // The default PR title is "SDK Generation" (from GithubStep's commitMessage default).
13598
13598
  // GitHub appends "(#N)" for the PR number, e.g. "SDK Generation (#70)".
13599
- commit.message.startsWith("SDK Generation");
13599
+ commit.message.startsWith("SDK Generation") || // Merge commit (GitHub's "Create a merge commit" option) of a fern-bot
13600
+ // regen PR. First-parent walking lands on this commit, not on the
13601
+ // generation commit on the side branch, so the classifier must treat
13602
+ // it as a generation boundary. GitHub's default subject is
13603
+ // "Merge pull request #N from <owner>/<branch>"; fern-bot branches are
13604
+ // named "<owner>/fern-bot/<...>".
13605
+ /^Merge pull request #\d+ from [^/]+\/fern-bot\//.test(commit.message);
13600
13606
  return isBotAuthor || hasGenerationMarker;
13601
13607
  }
13608
+ function isGenerationBoundary(commit) {
13609
+ const isFernSupport = FERN_SUPPORT_NAMES.includes(commit.authorName);
13610
+ const isBotAuthor = !isFernSupport && (commit.authorLogin === FERN_BOT_LOGIN || commit.authorEmail === FERN_BOT_EMAIL || commit.authorName === FERN_BOT_NAME);
13611
+ const isAutoversion = commit.message.startsWith("[fern-autoversion]");
13612
+ const hasBoundaryMarker = commit.message.startsWith("[fern-generated]") || commit.message.startsWith("[fern-replay]") || commit.message.includes("Generated by Fern") || commit.message.includes("\u{1F916} Generated with Fern") || commit.message.startsWith("SDK Generation");
13613
+ return isBotAuthor && !isAutoversion || hasBoundaryMarker;
13614
+ }
13602
13615
  function isReplayCommit(commit) {
13603
13616
  return commit.message.startsWith("[fern-replay]");
13604
13617
  }
@@ -13672,8 +13685,48 @@ var ReplayDetector = class {
13672
13685
  this.lockManager = lockManager;
13673
13686
  this.sdkOutputDir = sdkOutputDir;
13674
13687
  }
13688
+ /**
13689
+ * Derive the previous-generation SHA from git history instead of trusting
13690
+ * `lock.current_generation`.
13691
+ *
13692
+ * Walks `git log HEAD --first-parent` and returns the SHA of the most recent
13693
+ * commit that `isGenerationBoundary` classifies as a customization-baseline
13694
+ * boundary, or null if no such commit is reachable from HEAD on the mainline.
13695
+ *
13696
+ * `isGenerationBoundary` is intentionally narrower than `isGenerationCommit`:
13697
+ * it excludes `[fern-autoversion]` (lightweight bumps that don't reset the
13698
+ * baseline) and the GitHub merge-commit subject for fern-bot regen PRs
13699
+ * (which sits on first-parent but where the customer fix-up commits live
13700
+ * on second-parent — stopping at the merge would hide them).
13701
+ *
13702
+ * See docs/adr/0001-derived-scan-boundary.md.
13703
+ */
13704
+ async findPreviousGenerationFromHistory() {
13705
+ let log;
13706
+ try {
13707
+ log = await this.git.exec([
13708
+ "log",
13709
+ "--first-parent",
13710
+ "--format=%H%x00%an%x00%ae%x00%s",
13711
+ "HEAD"
13712
+ ]);
13713
+ } catch {
13714
+ return null;
13715
+ }
13716
+ for (const commit of this.parseGitLog(log)) {
13717
+ if (isGenerationBoundary(commit)) {
13718
+ return commit.sha;
13719
+ }
13720
+ }
13721
+ return null;
13722
+ }
13675
13723
  async detectNewPatches() {
13676
13724
  const lock = this.lockManager.read();
13725
+ const derivedSha = await this.findPreviousGenerationFromHistory();
13726
+ if (derivedSha !== null) {
13727
+ const derivedRecord = await this.synthesizeGenerationRecord(derivedSha);
13728
+ return this.detectPatchesInRange(derivedSha, derivedRecord, lock);
13729
+ }
13677
13730
  const lastGen = this.getLastGeneration(lock);
13678
13731
  if (!lastGen) {
13679
13732
  return { patches: [], revertedPatchIds: [] };
@@ -13681,7 +13734,7 @@ var ReplayDetector = class {
13681
13734
  const exists2 = await this.git.commitExists(lastGen.commit_sha);
13682
13735
  if (!exists2) {
13683
13736
  this.warnings.push(
13684
- `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to alternate detection.`
13737
+ `Generation commit ${lastGen.commit_sha.slice(0, 7)} not found in git history. Falling back to tree-diff detection (likely a shallow clone).`
13685
13738
  );
13686
13739
  return this.detectPatchesViaTreeDiff(
13687
13740
  lastGen,
@@ -13689,44 +13742,8 @@ var ReplayDetector = class {
13689
13742
  true
13690
13743
  );
13691
13744
  }
13692
- const isAncestor = await this.git.isAncestor(lastGen.commit_sha, "HEAD");
13693
- if (!isAncestor) {
13694
- return this.detectPatchesViaMergeBase(lastGen, lock);
13695
- }
13696
13745
  return this.detectPatchesInRange(lastGen.commit_sha, lastGen, lock);
13697
13746
  }
13698
- /**
13699
- * Non-linear-history detection via `merge-base(prevGen, HEAD)`.
13700
- *
13701
- * After a squash-merge of a Fern-bot PR, `lastGen.commit_sha` (the previous
13702
- * `[fern-generated]`) is orphaned but still in git's object database. The
13703
- * merge-base is the commit on main from which the bot's branch was created —
13704
- * a reachable ancestor of HEAD. Walking that range and classifying each
13705
- * commit via `isGenerationCommit` mirrors the linear path and eliminates
13706
- * the bug class where pipeline-driven changes (autoversion, replay,
13707
- * generation) get bundled as customer customizations.
13708
- *
13709
- * Falls through to the legacy composite path when no common ancestor exists
13710
- * (truly disjoint history — orphan branches with no shared root).
13711
- */
13712
- async detectPatchesViaMergeBase(lastGen, lock) {
13713
- let mergeBase = "";
13714
- try {
13715
- mergeBase = (await this.git.exec(["merge-base", lastGen.commit_sha, "HEAD"])).trim();
13716
- } catch {
13717
- }
13718
- if (!mergeBase) {
13719
- this.warnings.push(
13720
- `No common ancestor between previous generation ${lastGen.commit_sha.slice(0, 7)} and HEAD. Falling back to composite tree-diff detection.`
13721
- );
13722
- return this.detectPatchesViaTreeDiff(
13723
- lastGen,
13724
- /* commitKnownMissing */
13725
- false
13726
- );
13727
- }
13728
- return this.detectPatchesInRange(mergeBase, lastGen, lock);
13729
- }
13730
13747
  /**
13731
13748
  * Walk `${rangeStart}..HEAD`, classify each commit, emit one
13732
13749
  * `StoredPatch` per customer commit. Body shared by the linear path
@@ -14162,6 +14179,26 @@ var ReplayDetector = class {
14162
14179
  getLastGeneration(lock) {
14163
14180
  return lock.generations.find((g) => g.commit_sha === lock.current_generation);
14164
14181
  }
14182
+ /**
14183
+ * Build a transient `GenerationRecord` from a SHA derived by walking git
14184
+ * history. Only `commit_sha` (and `tree_hash` when needed downstream) is
14185
+ * load-bearing here; the rest are filled with defensible placeholders.
14186
+ * This record is consumed by `detectPatchesInRange` and is never persisted.
14187
+ */
14188
+ async synthesizeGenerationRecord(sha) {
14189
+ let tree_hash = "";
14190
+ try {
14191
+ tree_hash = await this.git.getTreeHash(sha);
14192
+ } catch {
14193
+ }
14194
+ return {
14195
+ commit_sha: sha,
14196
+ tree_hash,
14197
+ timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
14198
+ cli_version: "",
14199
+ generator_versions: {}
14200
+ };
14201
+ }
14165
14202
  };
14166
14203
 
14167
14204
  // src/ReplayService.ts
@@ -16882,8 +16919,7 @@ Patches absorbed by generator (${buckets.absorbed.length}):`;
16882
16919
  tree_hash: treeHash,
16883
16920
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
16884
16921
  cli_version: options?.cliVersion ?? "unknown",
16885
- generator_versions: options?.generatorVersions ?? {},
16886
- base_branch_head: options?.baseBranchHead
16922
+ generator_versions: options?.generatorVersions ?? {}
16887
16923
  };
16888
16924
  }
16889
16925
  async stageAll() {
@@ -17262,7 +17298,6 @@ var FirstGenerationFlow = class {
17262
17298
  flow: "first-generation",
17263
17299
  previousGenerationSha: null,
17264
17300
  currentGenerationSha: headSha,
17265
- baseBranchHead: options.baseBranchHead ?? null,
17266
17301
  _prepared: {
17267
17302
  kind: "terminal",
17268
17303
  flow: "first-generation",
@@ -17272,8 +17307,7 @@ var FirstGenerationFlow = class {
17272
17307
  }
17273
17308
  const commitOpts = options ? {
17274
17309
  cliVersion: options.cliVersion ?? "unknown",
17275
- generatorVersions: options.generatorVersions ?? {},
17276
- baseBranchHead: options.baseBranchHead
17310
+ generatorVersions: options.generatorVersions ?? {}
17277
17311
  } : void 0;
17278
17312
  await this.service.committer.commitGeneration("Initial SDK generation", commitOpts);
17279
17313
  const genRecord = await this.service.committer.createGenerationRecord(commitOpts);
@@ -17282,7 +17316,6 @@ var FirstGenerationFlow = class {
17282
17316
  flow: "first-generation",
17283
17317
  previousGenerationSha: null,
17284
17318
  currentGenerationSha: genRecord.commit_sha,
17285
- baseBranchHead: options?.baseBranchHead ?? null,
17286
17319
  _prepared: {
17287
17320
  kind: "active",
17288
17321
  flow: "first-generation",
@@ -17319,8 +17352,7 @@ var SkipApplicationFlow = class {
17319
17352
  async prepare(options) {
17320
17353
  const commitOpts = options ? {
17321
17354
  cliVersion: options.cliVersion ?? "unknown",
17322
- generatorVersions: options.generatorVersions ?? {},
17323
- baseBranchHead: options.baseBranchHead
17355
+ generatorVersions: options.generatorVersions ?? {}
17324
17356
  } : void 0;
17325
17357
  await this.service.committer.commitGeneration("Update SDK (replay skipped)", commitOpts);
17326
17358
  await this.service.cleanupStaleConflictMarkers();
@@ -17342,7 +17374,6 @@ var SkipApplicationFlow = class {
17342
17374
  flow: "skip-application",
17343
17375
  previousGenerationSha,
17344
17376
  currentGenerationSha: genRecord.commit_sha,
17345
- baseBranchHead: options?.baseBranchHead ?? null,
17346
17377
  _prepared: {
17347
17378
  kind: "active",
17348
17379
  flow: "skip-application",
@@ -17403,7 +17434,6 @@ var NoPatchesFlow = class {
17403
17434
  flow: "no-patches",
17404
17435
  previousGenerationSha,
17405
17436
  currentGenerationSha: headSha,
17406
- baseBranchHead: options.baseBranchHead ?? null,
17407
17437
  _prepared: {
17408
17438
  kind: "terminal",
17409
17439
  flow: "no-patches",
@@ -17435,8 +17465,7 @@ var NoPatchesFlow = class {
17435
17465
  }
17436
17466
  const commitOpts = options ? {
17437
17467
  cliVersion: options.cliVersion ?? "unknown",
17438
- generatorVersions: options.generatorVersions ?? {},
17439
- baseBranchHead: options.baseBranchHead
17468
+ generatorVersions: options.generatorVersions ?? {}
17440
17469
  } : void 0;
17441
17470
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
17442
17471
  await this.service.cleanupStaleConflictMarkers();
@@ -17446,7 +17475,6 @@ var NoPatchesFlow = class {
17446
17475
  flow: "no-patches",
17447
17476
  previousGenerationSha,
17448
17477
  currentGenerationSha: genRecord.commit_sha,
17449
- baseBranchHead: options?.baseBranchHead ?? null,
17450
17478
  _prepared: {
17451
17479
  kind: "active",
17452
17480
  flow: "no-patches",
@@ -17536,7 +17564,6 @@ var NormalRegenerationFlow = class {
17536
17564
  flow: "normal-regeneration",
17537
17565
  previousGenerationSha,
17538
17566
  currentGenerationSha: headSha,
17539
- baseBranchHead: options.baseBranchHead ?? null,
17540
17567
  _prepared: {
17541
17568
  kind: "terminal",
17542
17569
  flow: "normal-regeneration",
@@ -17554,14 +17581,16 @@ var NormalRegenerationFlow = class {
17554
17581
  existingPatches = this.service.lockManager.getPatches();
17555
17582
  const seenHashes = /* @__PURE__ */ new Map();
17556
17583
  for (const p of existingPatches) {
17557
- const priorPatchId = seenHashes.get(p.content_hash);
17558
- if (priorPatchId !== void 0) {
17559
- this.service.lockManager.removePatch(p.id);
17560
- this.service.warnings.push(
17561
- `Consolidated patch ${p.id} (commit ${(p.original_commit || "<missing>").slice(0, 7)}) into prior patch ${priorPatchId}: byte-identical patch_content. Per-commit attribution preserved in git log; lockfile collapsed to avoid duplicate-apply corruption on next regen.`
17562
- );
17563
- } else {
17564
- seenHashes.set(p.content_hash, p.id);
17584
+ if (!p.original_commit || p.original_commit === "") {
17585
+ const priorPatchId = seenHashes.get(p.content_hash);
17586
+ if (priorPatchId !== void 0) {
17587
+ this.service.lockManager.removePatch(p.id);
17588
+ this.service.warnings.push(
17589
+ `Consolidated patch ${p.id} into prior patch ${priorPatchId}: byte-identical patch_content. Lockfile collapsed to avoid duplicate-apply corruption on next regen.`
17590
+ );
17591
+ } else {
17592
+ seenHashes.set(p.content_hash, p.id);
17593
+ }
17565
17594
  }
17566
17595
  }
17567
17596
  existingPatches = this.service.lockManager.getPatches();
@@ -17590,8 +17619,7 @@ var NormalRegenerationFlow = class {
17590
17619
  const allPatches = [...existingPatches, ...newPatches];
17591
17620
  const commitOpts = options ? {
17592
17621
  cliVersion: options.cliVersion ?? "unknown",
17593
- generatorVersions: options.generatorVersions ?? {},
17594
- baseBranchHead: options.baseBranchHead
17622
+ generatorVersions: options.generatorVersions ?? {}
17595
17623
  } : void 0;
17596
17624
  await this.service.committer.commitGeneration("Update SDK", commitOpts);
17597
17625
  await this.service.cleanupStaleConflictMarkers();
@@ -17601,7 +17629,6 @@ var NormalRegenerationFlow = class {
17601
17629
  flow: "normal-regeneration",
17602
17630
  previousGenerationSha,
17603
17631
  currentGenerationSha: genRecord.commit_sha,
17604
- baseBranchHead: options?.baseBranchHead ?? null,
17605
17632
  _prepared: {
17606
17633
  kind: "active",
17607
17634
  flow: "normal-regeneration",
@@ -17794,68 +17821,6 @@ var ReplayService = class {
17794
17821
  const prep = await this.prepareReplay(options);
17795
17822
  return this.applyPreparedReplay(prep, options);
17796
17823
  }
17797
- /**
17798
- * Sync the lockfile after a divergent PR was squash-merged.
17799
- * Call this BEFORE runReplay() when the CLI detects a merged divergent PR.
17800
- *
17801
- * After updating the generation record, re-detects customer patches via
17802
- * tree diff between the generation tag (pure generation tree) and HEAD
17803
- * (which includes customer customizations after squash merge). This ensures
17804
- * patches survive the squash merge → regenerate cycle even when the lockfile
17805
- * was restored from the base branch during conflict PR creation.
17806
- */
17807
- async syncFromDivergentMerge(generationCommitSha, options) {
17808
- const treeHash = await this.git.getTreeHash(generationCommitSha);
17809
- const timestamp = (await this.git.exec(["log", "-1", "--format=%aI", generationCommitSha])).trim();
17810
- const record = {
17811
- commit_sha: generationCommitSha,
17812
- tree_hash: treeHash,
17813
- timestamp,
17814
- cli_version: options?.cliVersion ?? "unknown",
17815
- generator_versions: options?.generatorVersions ?? {},
17816
- base_branch_head: options?.baseBranchHead
17817
- };
17818
- let resolvedPatches;
17819
- if (!this.lockManager.exists()) {
17820
- this.lockManager.initializeInMemory(record);
17821
- } else {
17822
- this.lockManager.read();
17823
- const unresolvedPatches = [
17824
- ...this.lockManager.getUnresolvedPatches(),
17825
- ...this.lockManager.getResolvingPatches()
17826
- ];
17827
- resolvedPatches = this.lockManager.getPatches().filter((p) => p.status == null);
17828
- this.lockManager.addGeneration(record);
17829
- this.lockManager.clearPatches();
17830
- for (const patch of unresolvedPatches) {
17831
- this.lockManager.addPatch(patch);
17832
- }
17833
- }
17834
- try {
17835
- const { patches: redetectedPatches } = await this.detector.detectNewPatches();
17836
- if (redetectedPatches.length > 0) {
17837
- const redetectedFiles = new Set(redetectedPatches.flatMap((p) => p.files));
17838
- const currentPatches = this.lockManager.getPatches();
17839
- for (const patch of currentPatches) {
17840
- if (patch.status != null && patch.files.some((f) => redetectedFiles.has(f))) {
17841
- this.lockManager.removePatch(patch.id);
17842
- }
17843
- }
17844
- for (const patch of redetectedPatches) {
17845
- this.lockManager.addPatch(patch);
17846
- }
17847
- }
17848
- } catch (error) {
17849
- for (const patch of resolvedPatches ?? []) {
17850
- this.lockManager.addPatch(patch);
17851
- }
17852
- this.detector.warnings.push(
17853
- `Patch re-detection failed after divergent merge sync. ${(resolvedPatches ?? []).length} previously resolved patch(es) preserved. Error: ${error instanceof Error ? error.message : String(error)}`
17854
- );
17855
- }
17856
- this.lockManager.save();
17857
- this.detector.warnings.push(...this.lockManager.warnings);
17858
- }
17859
17824
  determineFlow() {
17860
17825
  try {
17861
17826
  const lock = this.lockManager.read();