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.
- codetour_cli/__init__.py +9 -0
- codetour_cli/cli.py +2087 -0
- codetour_cli/config.py +144 -0
- codetour_cli/description_validator.py +197 -0
- codetour_cli/detection.py +91 -0
- codetour_cli/discovery.py +226 -0
- codetour_cli/lint.py +334 -0
- codetour_cli/logging/__init__.py +6 -0
- codetour_cli/logging/setup.py +63 -0
- codetour_cli/migration/__init__.py +6 -0
- codetour_cli/migration/direct.py +385 -0
- codetour_cli/migration/git_diff.py +418 -0
- codetour_cli/migration/stepwise.py +400 -0
- codetour_cli/review_parser.py +348 -0
- codetour_cli/schema_models.py +203 -0
- codetour_cli/tour/__init__.py +6 -0
- codetour_cli/tour/schema.py +255 -0
- codetour_cli/tour/updater.py +428 -0
- codetour_cli-0.1.0.dist-info/METADATA +47 -0
- codetour_cli-0.1.0.dist-info/RECORD +24 -0
- codetour_cli-0.1.0.dist-info/WHEEL +5 -0
- codetour_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codetour_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- codetour_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|