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,385 @@
1
+ """
2
+ Direct migration mode: A → C (single jump).
3
+
4
+ Migrates CodeTour steps directly from source commit to target commit
5
+ without replaying intermediate commits.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Dict, List
10
+ from pathlib import Path
11
+
12
+ import git
13
+ import structlog
14
+
15
+ from codetour_cli.migration.git_diff import (
16
+ GitDiffParser,
17
+ FileDiff,
18
+ HunkHeader,
19
+ LineMapping
20
+ )
21
+ from codetour_cli.tour.schema import TourStep
22
+
23
+ logger = structlog.get_logger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class MigrationResult:
28
+ """
29
+ Result of migrating a single tour step.
30
+
31
+ Contains the updated location and metadata about the migration.
32
+ """
33
+
34
+ # Original location
35
+ old_file: str
36
+ old_line: int
37
+
38
+ # New location (None if file deleted)
39
+ new_file: Optional[str]
40
+ new_line: Optional[int]
41
+
42
+ # Migration metadata
43
+ confidence: float # 0.0-1.0
44
+ method: str # 'hunk_offset', 'hunk_interior', 'file_renamed', 'file_deleted', etc.
45
+ explanation: str
46
+
47
+ # Flags
48
+ file_renamed: bool = False
49
+ file_deleted: bool = False
50
+ needs_review: bool = False
51
+
52
+ @property
53
+ def success(self) -> bool:
54
+ """Was the migration successful?"""
55
+ return not self.file_deleted and self.new_line is not None
56
+
57
+ @property
58
+ def deprecated(self) -> bool:
59
+ """Should this step be marked as deprecated?"""
60
+ return self.file_deleted or self.new_line is None
61
+
62
+
63
+ class DirectMigrator:
64
+ """
65
+ Direct migration engine: A → C.
66
+
67
+ Migrates tour steps from source commit to target commit in a single jump.
68
+ Uses Git diff analysis to track line movements.
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
+ threshold: float = 0.7
78
+ ):
79
+ """
80
+ Initialize the direct 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
+ threshold: confidence cutoff for needs_review. Previously hardcoded
88
+ and disconnected from config/--threshold (ADR-0016 QST-CONF-2);
89
+ now the real configured value flows here. Per ADR-0016's own
90
+ finding, only values <=0.5 or >0.95 change anything, since
91
+ confidence never takes any other value -- this is a discrete
92
+ classifier, not a continuous score.
93
+ """
94
+ self.repo = repo
95
+ self.threshold = threshold
96
+
97
+ # Convert Commit objects to strings
98
+ self.source_commit = (source_commit.hexsha if hasattr(source_commit, 'hexsha')
99
+ else source_commit)
100
+ self.target_commit = (target_commit.hexsha if hasattr(target_commit, 'hexsha')
101
+ else target_commit)
102
+
103
+ self.parser = GitDiffParser(repo, algorithm=diff_algorithm)
104
+ self.log = logger.bind(
105
+ source=self.source_commit[:8],
106
+ target=self.target_commit[:8]
107
+ )
108
+
109
+ # Cache file diffs (computed once for all steps)
110
+ self._file_diffs: Optional[Dict[str, FileDiff]] = None
111
+
112
+ def _get_file_diffs(self) -> Dict[str, FileDiff]:
113
+ """Get all file diffs (cached)."""
114
+ if self._file_diffs is None:
115
+ self.log.info("computing_file_diffs")
116
+ self._file_diffs = self.parser.get_all_file_diffs(
117
+ self.source_commit,
118
+ self.target_commit
119
+ )
120
+ self.log.info(
121
+ "file_diffs_computed",
122
+ num_files=len(self._file_diffs)
123
+ )
124
+ return self._file_diffs
125
+
126
+ def _get_line_content(
127
+ self,
128
+ file_path: str,
129
+ line_number: int,
130
+ commit
131
+ ) -> Optional[str]:
132
+ """
133
+ Get the content of a specific line at a given commit.
134
+
135
+ Args:
136
+ file_path: File path relative to repo root
137
+ line_number: Line number (1-indexed)
138
+ commit: Commit SHA or object
139
+
140
+ Returns:
141
+ Line content (without newline), or None if error
142
+ """
143
+ try:
144
+ commit_obj = self.repo.commit(commit)
145
+ blob = commit_obj.tree / file_path
146
+ content = blob.data_stream.read().decode('utf-8')
147
+ lines = content.splitlines()
148
+
149
+ if 1 <= line_number <= len(lines):
150
+ return lines[line_number - 1] # Convert to 0-indexed
151
+ return None
152
+ except Exception as e:
153
+ self.log.debug(
154
+ "failed_to_get_line_content",
155
+ file=file_path,
156
+ line=line_number,
157
+ error=str(e)
158
+ )
159
+ return None
160
+
161
+ def migrate_step(
162
+ self,
163
+ file_path: str,
164
+ line_number: int,
165
+ step_id: Optional[int] = None,
166
+ step: Optional[TourStep] = None
167
+ ) -> MigrationResult:
168
+ """
169
+ Migrate a single tour step from source to target commit.
170
+
171
+ Args:
172
+ file_path: File path in source commit
173
+ line_number: Line number in source commit (1-indexed)
174
+ step_id: Optional step ID for logging
175
+
176
+ Returns:
177
+ MigrationResult with new location and metadata
178
+ """
179
+ log = self.log.bind(
180
+ file=file_path,
181
+ line=line_number,
182
+ step_id=step_id
183
+ )
184
+
185
+ log.debug("migrating_step")
186
+
187
+ # Get all file diffs
188
+ all_diffs = self._get_file_diffs()
189
+
190
+ # Check if file was renamed or deleted
191
+ file_diff = None
192
+ new_file_path = file_path
193
+
194
+ # Look for the file in the diffs
195
+ # Could be under new path (if renamed) or old path
196
+ if file_path in all_diffs:
197
+ file_diff = all_diffs[file_path]
198
+
199
+ # If renamed, update the path
200
+ if file_diff.is_renamed and file_diff.new_path:
201
+ new_file_path = file_diff.new_path
202
+ log.info(
203
+ "file_renamed",
204
+ old_path=file_path,
205
+ new_path=new_file_path
206
+ )
207
+ else:
208
+ # File might have been renamed - check all diffs for old_path match
209
+ for path, diff in all_diffs.items():
210
+ if diff.is_renamed and diff.old_path == file_path:
211
+ file_diff = diff
212
+ new_file_path = diff.new_path or file_path
213
+ log.info(
214
+ "file_renamed_found",
215
+ old_path=file_path,
216
+ new_path=new_file_path
217
+ )
218
+ break
219
+
220
+ # Handle file deletion
221
+ if file_diff and file_diff.is_deleted:
222
+ log.warning("file_deleted")
223
+ return MigrationResult(
224
+ old_file=file_path,
225
+ old_line=line_number,
226
+ new_file=None,
227
+ new_line=None,
228
+ confidence=0.0,
229
+ method='file_deleted',
230
+ explanation=f'File {file_path} was deleted',
231
+ file_deleted=True,
232
+ needs_review=True
233
+ )
234
+
235
+ # If no diff found, file is unchanged
236
+ if file_diff is None:
237
+ log.debug("file_unchanged")
238
+ return MigrationResult(
239
+ old_file=file_path,
240
+ old_line=line_number,
241
+ new_file=file_path,
242
+ new_line=line_number,
243
+ confidence=1.0,
244
+ method='no_changes',
245
+ explanation='File unchanged between commits',
246
+ file_renamed=False
247
+ )
248
+
249
+ # Map the line through hunks
250
+ mapping = self.parser.map_line_through_hunks(line_number, file_diff.hunks)
251
+
252
+ log.info(
253
+ "line_mapped",
254
+ new_line=mapping.new_line,
255
+ confidence=mapping.confidence,
256
+ method=mapping.method
257
+ )
258
+
259
+ # Pattern validation (if step provided with pattern)
260
+ confidence = mapping.confidence
261
+ method = mapping.method
262
+ explanation = mapping.explanation
263
+
264
+ if step and step.pattern and mapping.new_line is not None and new_file_path:
265
+ # Get actual content at new location
266
+ actual_content = self._get_line_content(
267
+ new_file_path,
268
+ mapping.new_line,
269
+ self.target_commit
270
+ )
271
+
272
+ if actual_content is not None:
273
+ pattern_matches = step.validate_line_content(actual_content)
274
+
275
+ if pattern_matches:
276
+ # Pattern validates the migration!
277
+ if confidence == 0.5: # hunk_interior
278
+ confidence = 0.95 # Boost to high confidence
279
+ method += "_pattern_validated"
280
+ explanation += " (pattern validated)"
281
+ log.info(
282
+ "pattern_validated",
283
+ file=new_file_path,
284
+ line=mapping.new_line,
285
+ pattern=step.pattern,
286
+ confidence_boost="0.5 → 0.95"
287
+ )
288
+ else:
289
+ # Pattern doesn't match - lower confidence
290
+ confidence = min(confidence, 0.4)
291
+ explanation += " (pattern mismatch)"
292
+ log.warning(
293
+ "pattern_mismatch",
294
+ file=new_file_path,
295
+ line=mapping.new_line,
296
+ pattern=step.pattern,
297
+ actual_content=actual_content[:50] # First 50 chars
298
+ )
299
+
300
+ # Determine if needs review
301
+ needs_review = (
302
+ confidence < self.threshold or # Low confidence (ADR-0016 QST-CONF-2:
303
+ # now the real configured value, not
304
+ # a hardcoded 0.7 disconnected from it)
305
+ method == 'hunk_interior' # Inside changed region (unless validated)
306
+ )
307
+
308
+ return MigrationResult(
309
+ old_file=file_path,
310
+ old_line=line_number,
311
+ new_file=new_file_path,
312
+ new_line=mapping.new_line,
313
+ confidence=confidence,
314
+ method=method,
315
+ explanation=explanation,
316
+ file_renamed=file_diff.is_renamed,
317
+ needs_review=needs_review
318
+ )
319
+
320
+ def migrate_steps(
321
+ self,
322
+ steps: List[tuple]
323
+ ) -> List[MigrationResult]:
324
+ """
325
+ Migrate multiple tour steps.
326
+
327
+ Args:
328
+ steps: List of (file_path, line_number, step_id) tuples
329
+
330
+ Returns:
331
+ List of MigrationResult objects
332
+ """
333
+ self.log.info("migrating_steps", num_steps=len(steps))
334
+
335
+ results = []
336
+ for file_path, line_number, step_id in steps:
337
+ result = self.migrate_step(file_path, line_number, step_id)
338
+ results.append(result)
339
+
340
+ # Log summary
341
+ successful = sum(1 for r in results if r.success)
342
+ deprecated = sum(1 for r in results if r.deprecated)
343
+ needs_review = sum(1 for r in results if r.needs_review)
344
+
345
+ self.log.info(
346
+ "migration_summary",
347
+ total=len(results),
348
+ successful=successful,
349
+ deprecated=deprecated,
350
+ needs_review=needs_review
351
+ )
352
+
353
+ return results
354
+
355
+
356
+ def migrate_tour(
357
+ repo_path: Path,
358
+ source_commit: str,
359
+ target_commit: str,
360
+ tour_steps: List[Dict],
361
+ diff_algorithm: str = "histogram"
362
+ ) -> List[MigrationResult]:
363
+ """
364
+ Convenience function to migrate a full tour.
365
+
366
+ Args:
367
+ repo_path: Path to Git repository
368
+ source_commit: Source commit SHA
369
+ target_commit: Target commit SHA
370
+ tour_steps: List of tour step dicts with 'file' and 'line' keys
371
+ diff_algorithm: Git diff algorithm to use
372
+
373
+ Returns:
374
+ List of MigrationResult objects
375
+ """
376
+ repo = git.Repo(repo_path)
377
+ migrator = DirectMigrator(repo, source_commit, target_commit, diff_algorithm)
378
+
379
+ # Convert tour steps to tuples
380
+ steps = [
381
+ (step.get('file'), step.get('line'), step.get('id', idx))
382
+ for idx, step in enumerate(tour_steps)
383
+ ]
384
+
385
+ return migrator.migrate_steps(steps)