codetour-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,400 @@
1
+ """
2
+ Step-by-step migration mode: A → B → C (sequential).
3
+
4
+ Migrates CodeTour steps by replaying commits sequentially, applying
5
+ each commit's changes one at a time. More accurate than direct mode
6
+ for complex refactorings, but slower for long commit histories.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional, List, Tuple
11
+ from pathlib import Path
12
+
13
+ import git
14
+ import structlog
15
+
16
+ from codetour_cli.migration.direct import DirectMigrator, MigrationResult
17
+ from codetour_cli.migration.git_diff import GitDiffParser
18
+ from codetour_cli.tour.schema import TourStep
19
+
20
+ logger = structlog.get_logger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class StepwiseResult:
25
+ """
26
+ Result of step-by-step migration with full trace.
27
+
28
+ Contains the final migration result plus intermediate steps
29
+ and comparison with direct mode.
30
+ """
31
+
32
+ # Final result
33
+ final_result: MigrationResult
34
+
35
+ # Intermediate migrations (one per commit)
36
+ intermediate_results: List[MigrationResult] = field(default_factory=list)
37
+
38
+ # Direct mode comparison (for data collection)
39
+ direct_result: Optional[MigrationResult] = None
40
+ methods_disagree: bool = False
41
+
42
+ # Commit path taken
43
+ commit_path: List[str] = field(default_factory=list)
44
+
45
+ @property
46
+ def num_steps(self) -> int:
47
+ """Number of intermediate steps taken."""
48
+ return len(self.intermediate_results)
49
+
50
+ @property
51
+ def confidence_history(self) -> List[float]:
52
+ """Confidence at each step."""
53
+ return [r.confidence for r in self.intermediate_results]
54
+
55
+ @property
56
+ def min_confidence(self) -> float:
57
+ """Minimum confidence across all steps."""
58
+ if not self.intermediate_results:
59
+ return 1.0
60
+ return min(self.confidence_history)
61
+
62
+
63
+ class StepwiseMigrator:
64
+ """
65
+ Step-by-step migration engine: A → B → C.
66
+
67
+ Migrates tour steps by replaying commits sequentially, tracking
68
+ line movements through each intermediate state.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ repo: git.Repo,
74
+ source_commit, # str or git.Commit
75
+ target_commit, # str or git.Commit
76
+ diff_algorithm: str = "histogram",
77
+ compute_direct_comparison: bool = True
78
+ ):
79
+ """
80
+ Initialize the step-by-step migrator.
81
+
82
+ Args:
83
+ repo: GitPython repository object
84
+ source_commit: Source commit SHA or Commit object
85
+ target_commit: Target commit SHA or Commit object
86
+ diff_algorithm: Git diff algorithm ('myers', 'patience', 'histogram')
87
+ compute_direct_comparison: Also compute direct mode for comparison
88
+ """
89
+ self.repo = repo
90
+
91
+ # Convert Commit objects to strings
92
+ self.source_commit = (source_commit.hexsha if hasattr(source_commit, 'hexsha')
93
+ else source_commit)
94
+ self.target_commit = (target_commit.hexsha if hasattr(target_commit, 'hexsha')
95
+ else target_commit)
96
+
97
+ self.diff_algorithm = diff_algorithm
98
+ self.compute_direct = compute_direct_comparison
99
+
100
+ self.log = logger.bind(
101
+ source=self.source_commit[:8],
102
+ target=self.target_commit[:8],
103
+ mode="stepwise"
104
+ )
105
+
106
+ def _get_commit_path(self) -> List[str]:
107
+ """
108
+ Get the sequential path of commits from source to target.
109
+
110
+ Returns:
111
+ List of commit SHAs from source to target (inclusive)
112
+ """
113
+ try:
114
+ # Use git rev-list to get commit path
115
+ # This gives us A, B1, B2, ..., C in order
116
+ commits = list(self.repo.iter_commits(
117
+ f"{self.source_commit}..{self.target_commit}"
118
+ ))
119
+
120
+ # Reverse to get chronological order (oldest to newest)
121
+ commits.reverse()
122
+
123
+ # Convert to SHAs and include source
124
+ commit_path = [self.source_commit] + [c.hexsha for c in commits]
125
+
126
+ self.log.info(
127
+ "computed_commit_path",
128
+ num_commits=len(commit_path),
129
+ path_length=len(commit_path) - 1 # Number of steps
130
+ )
131
+
132
+ return commit_path
133
+
134
+ except Exception as e:
135
+ self.log.error(
136
+ "failed_to_get_commit_path",
137
+ error=str(e)
138
+ )
139
+ # Fall back to direct path
140
+ return [self.source_commit, self.target_commit]
141
+
142
+ def migrate_step(
143
+ self,
144
+ file_path: str,
145
+ line_number: int,
146
+ step_id: Optional[int] = None,
147
+ step: Optional[TourStep] = None
148
+ ) -> StepwiseResult:
149
+ """
150
+ Migrate a single tour step through sequential commits.
151
+
152
+ Args:
153
+ file_path: File path in source commit
154
+ line_number: Line number in source commit (1-indexed)
155
+ step_id: Optional step ID for logging
156
+ step: Optional TourStep for pattern validation
157
+
158
+ Returns:
159
+ StepwiseResult with final location and full trace
160
+ """
161
+ log = self.log.bind(
162
+ file=file_path,
163
+ line=line_number,
164
+ step_id=step_id
165
+ )
166
+
167
+ log.info("starting_stepwise_migration")
168
+
169
+ # Get commit path
170
+ commit_path = self._get_commit_path()
171
+
172
+ if len(commit_path) < 2:
173
+ log.warning("commit_path_too_short", path_length=len(commit_path))
174
+ # Fall back to single direct migration
175
+ migrator = DirectMigrator(
176
+ self.repo,
177
+ self.source_commit,
178
+ self.target_commit,
179
+ self.diff_algorithm
180
+ )
181
+ final_result = migrator.migrate_step(file_path, line_number, step_id, step)
182
+ return StepwiseResult(
183
+ final_result=final_result,
184
+ commit_path=commit_path
185
+ )
186
+
187
+ # Track current location as we migrate through commits
188
+ current_file = file_path
189
+ current_line = line_number
190
+ intermediate_results = []
191
+ ever_renamed = False # Track if file was renamed at any point
192
+
193
+ # Migrate through each consecutive pair of commits
194
+ for i in range(len(commit_path) - 1):
195
+ commit_a = commit_path[i]
196
+ commit_b = commit_path[i + 1]
197
+
198
+ log.debug(
199
+ "migrating_step",
200
+ step=i + 1,
201
+ total_steps=len(commit_path) - 1,
202
+ from_commit=commit_a[:8],
203
+ to_commit=commit_b[:8],
204
+ current_location=f"{current_file}:{current_line}"
205
+ )
206
+
207
+ # Migrate this single step
208
+ migrator = DirectMigrator(
209
+ self.repo,
210
+ commit_a,
211
+ commit_b,
212
+ self.diff_algorithm
213
+ )
214
+
215
+ result = migrator.migrate_step(
216
+ current_file,
217
+ current_line,
218
+ step_id=step_id,
219
+ step=step
220
+ )
221
+
222
+ intermediate_results.append(result)
223
+
224
+ # Track if file was renamed at any point
225
+ if result.file_renamed:
226
+ ever_renamed = True
227
+
228
+ # Check if migration failed (file deleted, etc.)
229
+ if not result.success:
230
+ log.warning(
231
+ "migration_failed_at_intermediate_step",
232
+ step=i + 1,
233
+ reason=result.method,
234
+ commit=commit_b[:8]
235
+ )
236
+ # Can't continue - return current state
237
+ return StepwiseResult(
238
+ final_result=result,
239
+ intermediate_results=intermediate_results,
240
+ commit_path=commit_path[:i + 2] # Up to failure point
241
+ )
242
+
243
+ # Update current location for next iteration
244
+ current_file = result.new_file
245
+ current_line = result.new_line
246
+
247
+ log.debug(
248
+ "step_migrated",
249
+ step=i + 1,
250
+ new_location=f"{current_file}:{current_line}",
251
+ confidence=result.confidence,
252
+ method=result.method
253
+ )
254
+
255
+ # Final result is the last intermediate result
256
+ final_result = intermediate_results[-1]
257
+
258
+ # Compute aggregate confidence (minimum across all steps)
259
+ min_confidence = min(r.confidence for r in intermediate_results)
260
+ avg_confidence = sum(r.confidence for r in intermediate_results) / len(intermediate_results)
261
+
262
+ # Adjust final confidence based on journey
263
+ # Use average but flag if any step had low confidence
264
+ final_result.confidence = avg_confidence
265
+ if min_confidence < 0.7:
266
+ final_result.needs_review = True
267
+ final_result.explanation += f" (min confidence {min_confidence:.2f})"
268
+
269
+ # Set file_renamed flag if file was renamed at any point in the journey
270
+ if ever_renamed:
271
+ final_result.file_renamed = True
272
+
273
+ log.info(
274
+ "stepwise_migration_complete",
275
+ final_location=f"{final_result.new_file}:{final_result.new_line}",
276
+ num_steps=len(intermediate_results),
277
+ min_confidence=min_confidence,
278
+ avg_confidence=avg_confidence
279
+ )
280
+
281
+ # Optionally compute direct mode for comparison
282
+ direct_result = None
283
+ methods_disagree = False
284
+
285
+ if self.compute_direct:
286
+ direct_migrator = DirectMigrator(
287
+ self.repo,
288
+ self.source_commit,
289
+ self.target_commit,
290
+ self.diff_algorithm
291
+ )
292
+ direct_result = direct_migrator.migrate_step(
293
+ file_path,
294
+ line_number,
295
+ step_id,
296
+ step
297
+ )
298
+
299
+ # Check if methods disagree
300
+ if (final_result.new_file != direct_result.new_file or
301
+ final_result.new_line != direct_result.new_line):
302
+ methods_disagree = True
303
+
304
+ log.warning(
305
+ "methods_disagree",
306
+ stepwise_result=f"{final_result.new_file}:{final_result.new_line}",
307
+ direct_result=f"{direct_result.new_file}:{direct_result.new_line}",
308
+ stepwise_confidence=final_result.confidence,
309
+ direct_confidence=direct_result.confidence
310
+ )
311
+
312
+ # Lower confidence when methods disagree (per ADR-0001)
313
+ final_result.confidence *= 0.9
314
+ final_result.explanation += " (methods disagree)"
315
+ final_result.needs_review = True
316
+
317
+ return StepwiseResult(
318
+ final_result=final_result,
319
+ intermediate_results=intermediate_results,
320
+ direct_result=direct_result,
321
+ methods_disagree=methods_disagree,
322
+ commit_path=commit_path
323
+ )
324
+
325
+ def migrate_steps(
326
+ self,
327
+ steps: List[Tuple[str, int, int]]
328
+ ) -> List[StepwiseResult]:
329
+ """
330
+ Migrate multiple tour steps through sequential commits.
331
+
332
+ Args:
333
+ steps: List of (file_path, line_number, step_id) tuples
334
+
335
+ Returns:
336
+ List of StepwiseResult objects
337
+ """
338
+ self.log.info("migrating_steps_stepwise", num_steps=len(steps))
339
+
340
+ results = []
341
+ for file_path, line_number, step_id in steps:
342
+ result = self.migrate_step(file_path, line_number, step_id)
343
+ results.append(result)
344
+
345
+ # Log summary
346
+ successful = sum(1 for r in results if r.final_result.success)
347
+ disagreements = sum(1 for r in results if r.methods_disagree)
348
+
349
+ self.log.info(
350
+ "stepwise_migration_summary",
351
+ total=len(results),
352
+ successful=successful,
353
+ methods_disagree=disagreements
354
+ )
355
+
356
+ return results
357
+
358
+
359
+ def should_use_stepwise(
360
+ repo: git.Repo,
361
+ source_commit: str,
362
+ target_commit: str,
363
+ threshold: int = 10
364
+ ) -> bool:
365
+ """
366
+ Determine if step-by-step mode should be used.
367
+
368
+ Per ADR-0001, use stepwise if:
369
+ - Number of commits is small (< threshold, default 10)
370
+
371
+ Args:
372
+ repo: GitPython repository
373
+ source_commit: Source commit SHA
374
+ target_commit: Target commit SHA
375
+ threshold: Commit count threshold (default 10)
376
+
377
+ Returns:
378
+ True if stepwise should be used, False for direct mode
379
+ """
380
+ try:
381
+ # Count commits between source and target
382
+ commits = list(repo.iter_commits(f"{source_commit}..{target_commit}"))
383
+ num_commits = len(commits)
384
+
385
+ logger.info(
386
+ "evaluating_migration_mode",
387
+ num_commits=num_commits,
388
+ threshold=threshold,
389
+ recommendation="stepwise" if num_commits < threshold else "direct"
390
+ )
391
+
392
+ return num_commits < threshold
393
+
394
+ except Exception as e:
395
+ logger.error(
396
+ "failed_to_count_commits",
397
+ error=str(e)
398
+ )
399
+ # Default to direct mode on error
400
+ return False