@fern-api/replay 0.10.4 → 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/dist/index.d.cts CHANGED
@@ -28,6 +28,15 @@ interface StoredPatch {
28
28
  * When "resolving", the resolve command has applied patches to the working tree
29
29
  * and is waiting for the customer to resolve conflicts and run resolve again. */
30
30
  status?: "unresolved" | "resolving";
31
+ /**
32
+ * True when the patch introduces files that the generator has never produced
33
+ * (new-file creations). User-owned patches are immune to absorption checks in
34
+ * `rebasePatches`, `trimAbsorbedFiles`, and `preGenerationRebase`. Set at
35
+ * detection time by observing `--- /dev/null` headers in patch_content, or at
36
+ * rebase time by the reachability-aware helper. Once true, never cleared —
37
+ * the generator will not start producing a file it never produced before.
38
+ */
39
+ user_owned?: boolean;
31
40
  }
32
41
  interface CustomizationsConfig {
33
42
  exclude?: string[];
@@ -83,6 +92,29 @@ interface ReplayConfig {
83
92
  enabled: boolean;
84
93
  }
85
94
 
95
+ /**
96
+ * Credential detection patterns for the replay pipeline.
97
+ *
98
+ * Two-layer defense against credential leaks in .fern/replay.lock:
99
+ * Layer 1 — file-level: SENSITIVE_FILE_PATTERNS blocks entire files (.env, .pem, etc.)
100
+ * Layer 2 — content-level: CREDENTIAL_PATTERNS catches inline secrets in any file
101
+ *
102
+ * These patterns mirror the invariant assertions in __tests__/invariants/i4-credential-containment.ts.
103
+ * If they diverge, the invariant tests independently catch real leaks.
104
+ *
105
+ * @security FER-9807
106
+ */
107
+ interface CredentialPattern {
108
+ name: string;
109
+ re: RegExp;
110
+ }
111
+ declare const CREDENTIAL_PATTERNS: ReadonlyArray<CredentialPattern>;
112
+ declare const SENSITIVE_FILE_PATTERNS: ReadonlyArray<RegExp>;
113
+ /** Check if a file path matches any sensitive file pattern. */
114
+ declare function isSensitiveFile(filePath: string): boolean;
115
+ /** Scan content for credential patterns. Returns matched pattern names. */
116
+ declare function scanForCredentials(content: string): string[];
117
+
86
118
  declare class GitClient {
87
119
  private git;
88
120
  private repoPath;
@@ -95,6 +127,16 @@ declare class GitClient {
95
127
  showFile(treeish: string, filePath: string): Promise<string | null>;
96
128
  getCommitInfo(commitSha: string): Promise<CommitInfo>;
97
129
  getCommitParents(commitSha: string): Promise<string[]>;
130
+ /**
131
+ * Detects renames and copies between two trees.
132
+ *
133
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
134
+ * Copies are treated as rename-equivalent because the generator may produce the
135
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
136
+ * customer edits at the old path, or test helpers that only write new files).
137
+ * Callers that care about "is the source still present" should verify via
138
+ * `showFile(toTree, from)`.
139
+ */
98
140
  detectRenames(fromTree: string, toTree: string): Promise<Array<{
99
141
  from: string;
100
142
  to: string;
@@ -124,6 +166,7 @@ declare class LockfileNotFoundError extends Error {
124
166
  declare class LockfileManager {
125
167
  private outputDir;
126
168
  private lock;
169
+ readonly warnings: string[];
127
170
  constructor(outputDir: string);
128
171
  get lockfilePath(): string;
129
172
  get customizationsPath(): string;
@@ -139,7 +182,7 @@ declare class LockfileManager {
139
182
  save(): void;
140
183
  addGeneration(record: GenerationRecord): void;
141
184
  addPatch(patch: StoredPatch): void;
142
- updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status">>): void;
185
+ updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status" | "user_owned">>): void;
143
186
  removePatch(patchId: string): void;
144
187
  clearPatches(): void;
145
188
  addForgottenHash(hash: string): void;
@@ -169,10 +212,60 @@ declare class ReplayDetector {
169
212
  detectNewPatches(): Promise<DetectionResult>;
170
213
  /**
171
214
  * Compute content hash for deduplication.
172
- * Removes commit SHA line and index lines before hashing,
173
- * so rebased commits with same content produce the same hash.
215
+ *
216
+ * Produces a format-agnostic hash so both `git format-patch` output
217
+ * (email-wrapped) and plain `git diff` output hash to the same value
218
+ * when the underlying diff hunks are identical.
219
+ *
220
+ * Normalization:
221
+ * 1. Strip email wrapper: everything before the first `diff --git` line
222
+ * (From:, Subject:, Date:, diffstat, blank separators).
223
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
224
+ * 3. Strip trailing git version marker (`-- \n<version>`).
225
+ *
226
+ * This ensures content hashes match across the no-patches and normal-
227
+ * regeneration flows, which store formatPatch and git-diff formats
228
+ * respectively (FER-9850, D5-D9).
174
229
  */
175
230
  computeContentHash(patchContent: string): string;
231
+ /**
232
+ * FER-9805 — Drop patches whose files were transiently created and then
233
+ * deleted within the current linear detection window, and are absent from
234
+ * HEAD at the end of the window.
235
+ *
236
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
237
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
238
+ * linear history each commit becomes its own patch, so a file that was
239
+ * briefly added and later removed survives as a create+delete pair whose
240
+ * cumulative diff is empty. We drop such patches before they reach the
241
+ * lockfile.
242
+ *
243
+ * A file is "transient" when:
244
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
245
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
246
+ * 3. it is absent from HEAD's tree.
247
+ *
248
+ * The HEAD guard (#3) prevents false positives when a file is deleted
249
+ * and then recreated with different content — the cumulative diff of
250
+ * such a pair is non-empty and must be preserved.
251
+ *
252
+ * This only drops patches whose `files` are a subset of the transient
253
+ * set. A partial-transient patch (one that touches a transient file
254
+ * alongside a persistent one) is left unmodified; surgical stripping of
255
+ * single files from a multi-file patch would require a follow-up that
256
+ * regenerates the patch content via `git format-patch -- <files>`. No
257
+ * current failing test exercises this, and leaving the patch intact
258
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
259
+ * adversarial test demands per-file stripping.
260
+ */
261
+ private collapseNetZeroFiles;
262
+ /**
263
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
264
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
265
+ * caller then treats every candidate as "not in HEAD" and may collapse
266
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
267
+ */
268
+ private listHeadFiles;
176
269
  /**
177
270
  * Create a single composite patch for a merge commit by diffing the merge
178
271
  * commit against its first parent. This captures the net change of the
@@ -237,6 +330,17 @@ declare class ReplayApplicator {
237
330
  private resolveBaseGeneration;
238
331
  /** Reset inter-patch accumulator for a new cycle. */
239
332
  private resetAccumulator;
333
+ /**
334
+ * @internal Test-only.
335
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
336
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
337
+ * applied patch, the accumulator has an entry for each file the patch
338
+ * touched, and the entry's content matches on-disk.
339
+ */
340
+ getAccumulatorSnapshot(): ReadonlyMap<string, {
341
+ content: string;
342
+ baseGeneration: string;
343
+ }>;
240
344
  /**
241
345
  * Apply all patches, returning results for each.
242
346
  * Skips patches that match exclude patterns in replay.yml
@@ -251,6 +355,14 @@ declare class ReplayApplicator {
251
355
  private isExcluded;
252
356
  private resolveFilePath;
253
357
  private applyPatchToContent;
358
+ /**
359
+ * Detects whether a patch's additions are already present in `content`.
360
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
361
+ * the forward patch is effectively a no-op (its +lines are already there and
362
+ * its -lines are already gone). Used to distinguish "patch already applied"
363
+ * from "patch base mismatch" after a forward-apply fails.
364
+ */
365
+ private isPatchAlreadyApplied;
254
366
  private extractFileDiff;
255
367
  private extractRenameSource;
256
368
  private extractNewFileFromPatch;
@@ -329,6 +441,29 @@ declare class ReplayService {
329
441
  private committer;
330
442
  private lockManager;
331
443
  private outputDir;
444
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
445
+ private _lastApplyResults;
446
+ /**
447
+ * Service-level warnings accumulated during a single runReplay() call.
448
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
449
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
450
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
451
+ * heuristic. Cleared at the top of `runReplay()`.
452
+ */
453
+ private warnings;
454
+ /**
455
+ * @internal Test-only accessor.
456
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
457
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
458
+ */
459
+ get applicatorRef(): ReplayApplicator;
460
+ /**
461
+ * @internal Test-only accessor.
462
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
463
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
464
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
465
+ */
466
+ getLastResults(): ReadonlyArray<ReplayResult>;
332
467
  constructor(outputDir: string, _config: ReplayConfig);
333
468
  runReplay(options?: ReplayOptions): Promise<ReplayReport>;
334
469
  /**
@@ -362,6 +497,37 @@ declare class ReplayService {
362
497
  * Returns the number of patches rebased.
363
498
  */
364
499
  private rebasePatches;
500
+ /**
501
+ * Determine whether `file` is user-owned (never produced by the generator).
502
+ *
503
+ * Resolution order (first match wins):
504
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
505
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
506
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
507
+ * creation. Works for patches that predate the `user_owned` field or
508
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
509
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
510
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
511
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
512
+ * customer-actionable warning (deduplicated via warnedGens) and
513
+ * conservatively treat as user-owned — strictly safer than silent
514
+ * absorption (FER-9809).
515
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
516
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
517
+ *
518
+ * `originalFileName` is the pre-rename path when the generator moved the
519
+ * file between the patch's base and the current generation. Tree lookups
520
+ * at ancestor generations use the original path — that's where the
521
+ * generator produced the content.
522
+ */
523
+ private isFileUserOwned;
524
+ /**
525
+ * Returns true if `file`'s diff section in `patchContent` starts with
526
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
527
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
528
+ * absent or cleared.
529
+ */
530
+ private fileIsCreationInPatchContent;
365
531
  /**
366
532
  * For conflict patches with mixed results (some files merged, some conflicted),
367
533
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -595,4 +761,4 @@ interface StatusGeneration {
595
761
  }
596
762
  declare function status(outputDir: string): StatusResult;
597
763
 
598
- export { type BootstrapOptions, type BootstrapResult, type CommitInfo, type CommitOptions, type ConflictDetail, type ConflictMetadata, type ConflictReason, type ConflictRegion, type CustomizationsConfig, type DetectionResult, type DiffStat, FERN_BOT_EMAIL, FERN_BOT_LOGIN, FERN_BOT_NAME, FernignoreMigrator, type FileResult, type ForgetOptions, type ForgetResult, type GenerationLock, type GenerationRecord, GitClient, LockfileManager, LockfileNotFoundError, type MatchedPatch, type MergeResult, type MigrationAnalysis, type MigrationResult, type MoveDeclaration, ReplayApplicator, ReplayCommitter, type ReplayConfig, ReplayDetector, type ReplayOptions, type ReplayReport, type ReplayResult, ReplayService, type ResetOptions, type ResetResult, type ResolveOptions, type ResolveResult, type StatusGeneration, type StatusPatch, type StatusResult, type StoredPatch, type UnresolvedPatchInfo, bootstrap, forget, isGenerationCommit, isReplayCommit, isRevertCommit, parseRevertedMessage, parseRevertedSha, reset, resolve, status, threeWayMerge };
764
+ export { type BootstrapOptions, type BootstrapResult, CREDENTIAL_PATTERNS, type CommitInfo, type CommitOptions, type ConflictDetail, type ConflictMetadata, type ConflictReason, type ConflictRegion, type CredentialPattern, type CustomizationsConfig, type DetectionResult, type DiffStat, FERN_BOT_EMAIL, FERN_BOT_LOGIN, FERN_BOT_NAME, FernignoreMigrator, type FileResult, type ForgetOptions, type ForgetResult, type GenerationLock, type GenerationRecord, GitClient, LockfileManager, LockfileNotFoundError, type MatchedPatch, type MergeResult, type MigrationAnalysis, type MigrationResult, type MoveDeclaration, ReplayApplicator, ReplayCommitter, type ReplayConfig, ReplayDetector, type ReplayOptions, type ReplayReport, type ReplayResult, ReplayService, type ResetOptions, type ResetResult, type ResolveOptions, type ResolveResult, SENSITIVE_FILE_PATTERNS, type StatusGeneration, type StatusPatch, type StatusResult, type StoredPatch, type UnresolvedPatchInfo, bootstrap, forget, isGenerationCommit, isReplayCommit, isRevertCommit, isSensitiveFile, parseRevertedMessage, parseRevertedSha, reset, resolve, scanForCredentials, status, threeWayMerge };
package/dist/index.d.ts CHANGED
@@ -28,6 +28,15 @@ interface StoredPatch {
28
28
  * When "resolving", the resolve command has applied patches to the working tree
29
29
  * and is waiting for the customer to resolve conflicts and run resolve again. */
30
30
  status?: "unresolved" | "resolving";
31
+ /**
32
+ * True when the patch introduces files that the generator has never produced
33
+ * (new-file creations). User-owned patches are immune to absorption checks in
34
+ * `rebasePatches`, `trimAbsorbedFiles`, and `preGenerationRebase`. Set at
35
+ * detection time by observing `--- /dev/null` headers in patch_content, or at
36
+ * rebase time by the reachability-aware helper. Once true, never cleared —
37
+ * the generator will not start producing a file it never produced before.
38
+ */
39
+ user_owned?: boolean;
31
40
  }
32
41
  interface CustomizationsConfig {
33
42
  exclude?: string[];
@@ -83,6 +92,29 @@ interface ReplayConfig {
83
92
  enabled: boolean;
84
93
  }
85
94
 
95
+ /**
96
+ * Credential detection patterns for the replay pipeline.
97
+ *
98
+ * Two-layer defense against credential leaks in .fern/replay.lock:
99
+ * Layer 1 — file-level: SENSITIVE_FILE_PATTERNS blocks entire files (.env, .pem, etc.)
100
+ * Layer 2 — content-level: CREDENTIAL_PATTERNS catches inline secrets in any file
101
+ *
102
+ * These patterns mirror the invariant assertions in __tests__/invariants/i4-credential-containment.ts.
103
+ * If they diverge, the invariant tests independently catch real leaks.
104
+ *
105
+ * @security FER-9807
106
+ */
107
+ interface CredentialPattern {
108
+ name: string;
109
+ re: RegExp;
110
+ }
111
+ declare const CREDENTIAL_PATTERNS: ReadonlyArray<CredentialPattern>;
112
+ declare const SENSITIVE_FILE_PATTERNS: ReadonlyArray<RegExp>;
113
+ /** Check if a file path matches any sensitive file pattern. */
114
+ declare function isSensitiveFile(filePath: string): boolean;
115
+ /** Scan content for credential patterns. Returns matched pattern names. */
116
+ declare function scanForCredentials(content: string): string[];
117
+
86
118
  declare class GitClient {
87
119
  private git;
88
120
  private repoPath;
@@ -95,6 +127,16 @@ declare class GitClient {
95
127
  showFile(treeish: string, filePath: string): Promise<string | null>;
96
128
  getCommitInfo(commitSha: string): Promise<CommitInfo>;
97
129
  getCommitParents(commitSha: string): Promise<string[]>;
130
+ /**
131
+ * Detects renames and copies between two trees.
132
+ *
133
+ * Both `R` (rename) and `C` (copy) entries are returned as `{from, to}` pairs.
134
+ * Copies are treated as rename-equivalent because the generator may produce the
135
+ * new path while the old path remains in the tree (e.g., imperfect cleanup,
136
+ * customer edits at the old path, or test helpers that only write new files).
137
+ * Callers that care about "is the source still present" should verify via
138
+ * `showFile(toTree, from)`.
139
+ */
98
140
  detectRenames(fromTree: string, toTree: string): Promise<Array<{
99
141
  from: string;
100
142
  to: string;
@@ -124,6 +166,7 @@ declare class LockfileNotFoundError extends Error {
124
166
  declare class LockfileManager {
125
167
  private outputDir;
126
168
  private lock;
169
+ readonly warnings: string[];
127
170
  constructor(outputDir: string);
128
171
  get lockfilePath(): string;
129
172
  get customizationsPath(): string;
@@ -139,7 +182,7 @@ declare class LockfileManager {
139
182
  save(): void;
140
183
  addGeneration(record: GenerationRecord): void;
141
184
  addPatch(patch: StoredPatch): void;
142
- updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status">>): void;
185
+ updatePatch(patchId: string, updates: Partial<Pick<StoredPatch, "base_generation" | "patch_content" | "content_hash" | "files" | "status" | "user_owned">>): void;
143
186
  removePatch(patchId: string): void;
144
187
  clearPatches(): void;
145
188
  addForgottenHash(hash: string): void;
@@ -169,10 +212,60 @@ declare class ReplayDetector {
169
212
  detectNewPatches(): Promise<DetectionResult>;
170
213
  /**
171
214
  * Compute content hash for deduplication.
172
- * Removes commit SHA line and index lines before hashing,
173
- * so rebased commits with same content produce the same hash.
215
+ *
216
+ * Produces a format-agnostic hash so both `git format-patch` output
217
+ * (email-wrapped) and plain `git diff` output hash to the same value
218
+ * when the underlying diff hunks are identical.
219
+ *
220
+ * Normalization:
221
+ * 1. Strip email wrapper: everything before the first `diff --git` line
222
+ * (From:, Subject:, Date:, diffstat, blank separators).
223
+ * 2. Strip `index` lines (blob SHA pairs change across rebases).
224
+ * 3. Strip trailing git version marker (`-- \n<version>`).
225
+ *
226
+ * This ensures content hashes match across the no-patches and normal-
227
+ * regeneration flows, which store formatPatch and git-diff formats
228
+ * respectively (FER-9850, D5-D9).
174
229
  */
175
230
  computeContentHash(patchContent: string): string;
231
+ /**
232
+ * FER-9805 — Drop patches whose files were transiently created and then
233
+ * deleted within the current linear detection window, and are absent from
234
+ * HEAD at the end of the window.
235
+ *
236
+ * Rationale: on a merge commit, createMergeCompositePatch already diffs
237
+ * firstParent..mergeCommit so net-zero files never enter the composite. On
238
+ * linear history each commit becomes its own patch, so a file that was
239
+ * briefly added and later removed survives as a create+delete pair whose
240
+ * cumulative diff is empty. We drop such patches before they reach the
241
+ * lockfile.
242
+ *
243
+ * A file is "transient" when:
244
+ * 1. some patch in the window sets `new file mode` for it (a creation), AND
245
+ * 2. some patch in the window sets `deleted file mode` for it (a deletion), AND
246
+ * 3. it is absent from HEAD's tree.
247
+ *
248
+ * The HEAD guard (#3) prevents false positives when a file is deleted
249
+ * and then recreated with different content — the cumulative diff of
250
+ * such a pair is non-empty and must be preserved.
251
+ *
252
+ * This only drops patches whose `files` are a subset of the transient
253
+ * set. A partial-transient patch (one that touches a transient file
254
+ * alongside a persistent one) is left unmodified; surgical stripping of
255
+ * single files from a multi-file patch would require a follow-up that
256
+ * regenerates the patch content via `git format-patch -- <files>`. No
257
+ * current failing test exercises this, and leaving the patch intact
258
+ * preserves today's behaviour. TODO(FER-9805): revisit if a future
259
+ * adversarial test demands per-file stripping.
260
+ */
261
+ private collapseNetZeroFiles;
262
+ /**
263
+ * List every tracked path at HEAD (one `git ls-tree -r` invocation).
264
+ * Returns an empty Set if HEAD is unborn or the invocation fails — the
265
+ * caller then treats every candidate as "not in HEAD" and may collapse
266
+ * more aggressively. On typical SDK repos this is a single sub-10ms call.
267
+ */
268
+ private listHeadFiles;
176
269
  /**
177
270
  * Create a single composite patch for a merge commit by diffing the merge
178
271
  * commit against its first parent. This captures the net change of the
@@ -237,6 +330,17 @@ declare class ReplayApplicator {
237
330
  private resolveBaseGeneration;
238
331
  /** Reset inter-patch accumulator for a new cycle. */
239
332
  private resetAccumulator;
333
+ /**
334
+ * @internal Test-only.
335
+ * Returns a defensive copy of the inter-patch accumulator. Used by the
336
+ * adversarial test suite (FER-9791) to assert invariant I2: after every
337
+ * applied patch, the accumulator has an entry for each file the patch
338
+ * touched, and the entry's content matches on-disk.
339
+ */
340
+ getAccumulatorSnapshot(): ReadonlyMap<string, {
341
+ content: string;
342
+ baseGeneration: string;
343
+ }>;
240
344
  /**
241
345
  * Apply all patches, returning results for each.
242
346
  * Skips patches that match exclude patterns in replay.yml
@@ -251,6 +355,14 @@ declare class ReplayApplicator {
251
355
  private isExcluded;
252
356
  private resolveFilePath;
253
357
  private applyPatchToContent;
358
+ /**
359
+ * Detects whether a patch's additions are already present in `content`.
360
+ * Uses `git apply --reverse --check` — if the reverse patch applies cleanly,
361
+ * the forward patch is effectively a no-op (its +lines are already there and
362
+ * its -lines are already gone). Used to distinguish "patch already applied"
363
+ * from "patch base mismatch" after a forward-apply fails.
364
+ */
365
+ private isPatchAlreadyApplied;
254
366
  private extractFileDiff;
255
367
  private extractRenameSource;
256
368
  private extractNewFileFromPatch;
@@ -329,6 +441,29 @@ declare class ReplayService {
329
441
  private committer;
330
442
  private lockManager;
331
443
  private outputDir;
444
+ /** @internal Test-only. Captures the last ReplayResult[] returned from applicator.applyPatches(). */
445
+ private _lastApplyResults;
446
+ /**
447
+ * Service-level warnings accumulated during a single runReplay() call.
448
+ * Merged with `detector.warnings` into `ReplayReport.warnings` by each flow handler.
449
+ * Populated by `isFileUserOwned` when a patch's base generation tree is unreachable
450
+ * (shallow clone / aggressive `git gc --prune`) and we fall back to a conservative
451
+ * heuristic. Cleared at the top of `runReplay()`.
452
+ */
453
+ private warnings;
454
+ /**
455
+ * @internal Test-only accessor.
456
+ * Returns the ReplayApplicator instance used for the last run. Tests use this
457
+ * to inspect the inter-patch accumulator after runReplay() completes (FER-9791, I2).
458
+ */
459
+ get applicatorRef(): ReplayApplicator;
460
+ /**
461
+ * @internal Test-only accessor.
462
+ * Returns the ReplayResult[] from the most recent applyPatches() call. Tests use
463
+ * this to filter applied-vs-conflicted-vs-absorbed patches for invariant assertions
464
+ * (FER-9791). Returns an empty array if no patches have been applied yet.
465
+ */
466
+ getLastResults(): ReadonlyArray<ReplayResult>;
332
467
  constructor(outputDir: string, _config: ReplayConfig);
333
468
  runReplay(options?: ReplayOptions): Promise<ReplayReport>;
334
469
  /**
@@ -362,6 +497,37 @@ declare class ReplayService {
362
497
  * Returns the number of patches rebased.
363
498
  */
364
499
  private rebasePatches;
500
+ /**
501
+ * Determine whether `file` is user-owned (never produced by the generator).
502
+ *
503
+ * Resolution order (first match wins):
504
+ * 1. `patch.user_owned === true` — authoritative, persisted across cycles.
505
+ * 2. `.fernignore` itself, or file matches a .fernignore pattern.
506
+ * 3. Legacy-safe signal: `patch_content` shows this file as a `--- /dev/null`
507
+ * creation. Works for patches that predate the `user_owned` field or
508
+ * whose flag was lost. Immune to rename tricks — relies on raw patch text.
509
+ * 4. Tree-based check against `patch.base_generation` when it is reachable
510
+ * AND distinct from `currentGenSha`. If the base tree is unreachable
511
+ * (shallow clone / aggressive `git gc --prune`), emit a one-time
512
+ * customer-actionable warning (deduplicated via warnedGens) and
513
+ * conservatively treat as user-owned — strictly safer than silent
514
+ * absorption (FER-9809).
515
+ * 5. Final fallback — check `currentGenSha` when there is no usable base
516
+ * (legacy one-gen lockfile). Preserves pre-fix behavior for that edge.
517
+ *
518
+ * `originalFileName` is the pre-rename path when the generator moved the
519
+ * file between the patch's base and the current generation. Tree lookups
520
+ * at ancestor generations use the original path — that's where the
521
+ * generator produced the content.
522
+ */
523
+ private isFileUserOwned;
524
+ /**
525
+ * Returns true if `file`'s diff section in `patchContent` starts with
526
+ * `--- /dev/null`, meaning this file was introduced by the patch (user
527
+ * creation). Used as a legacy-safe fallback when `patch.user_owned` is
528
+ * absent or cleared.
529
+ */
530
+ private fileIsCreationInPatchContent;
365
531
  /**
366
532
  * For conflict patches with mixed results (some files merged, some conflicted),
367
533
  * check if the cleanly merged files were absorbed by the generator (empty diff).
@@ -595,4 +761,4 @@ interface StatusGeneration {
595
761
  }
596
762
  declare function status(outputDir: string): StatusResult;
597
763
 
598
- export { type BootstrapOptions, type BootstrapResult, type CommitInfo, type CommitOptions, type ConflictDetail, type ConflictMetadata, type ConflictReason, type ConflictRegion, type CustomizationsConfig, type DetectionResult, type DiffStat, FERN_BOT_EMAIL, FERN_BOT_LOGIN, FERN_BOT_NAME, FernignoreMigrator, type FileResult, type ForgetOptions, type ForgetResult, type GenerationLock, type GenerationRecord, GitClient, LockfileManager, LockfileNotFoundError, type MatchedPatch, type MergeResult, type MigrationAnalysis, type MigrationResult, type MoveDeclaration, ReplayApplicator, ReplayCommitter, type ReplayConfig, ReplayDetector, type ReplayOptions, type ReplayReport, type ReplayResult, ReplayService, type ResetOptions, type ResetResult, type ResolveOptions, type ResolveResult, type StatusGeneration, type StatusPatch, type StatusResult, type StoredPatch, type UnresolvedPatchInfo, bootstrap, forget, isGenerationCommit, isReplayCommit, isRevertCommit, parseRevertedMessage, parseRevertedSha, reset, resolve, status, threeWayMerge };
764
+ export { type BootstrapOptions, type BootstrapResult, CREDENTIAL_PATTERNS, type CommitInfo, type CommitOptions, type ConflictDetail, type ConflictMetadata, type ConflictReason, type ConflictRegion, type CredentialPattern, type CustomizationsConfig, type DetectionResult, type DiffStat, FERN_BOT_EMAIL, FERN_BOT_LOGIN, FERN_BOT_NAME, FernignoreMigrator, type FileResult, type ForgetOptions, type ForgetResult, type GenerationLock, type GenerationRecord, GitClient, LockfileManager, LockfileNotFoundError, type MatchedPatch, type MergeResult, type MigrationAnalysis, type MigrationResult, type MoveDeclaration, ReplayApplicator, ReplayCommitter, type ReplayConfig, ReplayDetector, type ReplayOptions, type ReplayReport, type ReplayResult, ReplayService, type ResetOptions, type ResetResult, type ResolveOptions, type ResolveResult, SENSITIVE_FILE_PATTERNS, type StatusGeneration, type StatusPatch, type StatusResult, type StoredPatch, type UnresolvedPatchInfo, bootstrap, forget, isGenerationCommit, isReplayCommit, isRevertCommit, isSensitiveFile, parseRevertedMessage, parseRevertedSha, reset, resolve, scanForCredentials, status, threeWayMerge };