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,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeTour schema definitions using Pydantic.
|
|
3
|
+
|
|
4
|
+
Based on the CodeTour specification:
|
|
5
|
+
https://github.com/microsoft/codetour
|
|
6
|
+
|
|
7
|
+
A tour is a JSON file (.tour) containing:
|
|
8
|
+
- Metadata (title, description, ref commit)
|
|
9
|
+
- Steps (file, line, description, optional pattern)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Optional, List, Literal
|
|
13
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TourStep(BaseModel):
|
|
20
|
+
"""
|
|
21
|
+
A single step in a CodeTour.
|
|
22
|
+
|
|
23
|
+
Steps can reference code by:
|
|
24
|
+
- file + line (most common)
|
|
25
|
+
- file + pattern (regex, mutually exclusive with line)
|
|
26
|
+
- Just description (no code reference)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra='allow') # Allow extra fields for future compatibility
|
|
30
|
+
|
|
31
|
+
# Core fields
|
|
32
|
+
file: Optional[str] = None # Relative path from workspace root
|
|
33
|
+
line: Optional[int] = None # Line number (1-indexed)
|
|
34
|
+
description: str # Markdown description shown to user
|
|
35
|
+
|
|
36
|
+
# Optional fields
|
|
37
|
+
pattern: Optional[str] = None # Regex pattern (mutually exclusive with line)
|
|
38
|
+
title: Optional[str] = None # Optional step title
|
|
39
|
+
directory: Optional[str] = None # For directory-only steps
|
|
40
|
+
uri: Optional[str] = None # Full URI (for non-file resources)
|
|
41
|
+
|
|
42
|
+
# Selection range (for highlighting multiple lines)
|
|
43
|
+
selection: Optional[dict] = None # {start: {line, character}, end: {line, character}}
|
|
44
|
+
|
|
45
|
+
# Commands to execute
|
|
46
|
+
commands: Optional[List[str]] = None
|
|
47
|
+
|
|
48
|
+
def has_line_reference(self) -> bool:
|
|
49
|
+
"""Does this step reference a specific line?"""
|
|
50
|
+
return self.file is not None and self.line is not None
|
|
51
|
+
|
|
52
|
+
def has_pattern_reference(self) -> bool:
|
|
53
|
+
"""Does this step use a regex pattern?"""
|
|
54
|
+
return self.file is not None and self.pattern is not None
|
|
55
|
+
|
|
56
|
+
def has_code_reference(self) -> bool:
|
|
57
|
+
"""Does this step reference any code location?"""
|
|
58
|
+
return self.has_line_reference() or self.has_pattern_reference()
|
|
59
|
+
|
|
60
|
+
def validate_line_content(self, content: str) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Validate that line content matches the step's pattern.
|
|
63
|
+
|
|
64
|
+
Used for confidence validation - if a step has a pattern,
|
|
65
|
+
we can check that the migrated line still matches it.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
content: The actual line content from the file
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if no pattern, or pattern matches content
|
|
72
|
+
"""
|
|
73
|
+
if not self.pattern:
|
|
74
|
+
return True # No pattern = always valid
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return bool(re.search(self.pattern, content))
|
|
78
|
+
except re.error:
|
|
79
|
+
# Invalid regex pattern - treat as no match
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Tour(BaseModel):
|
|
84
|
+
"""
|
|
85
|
+
A complete CodeTour.
|
|
86
|
+
|
|
87
|
+
Contains metadata and a sequence of steps guiding through code.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(extra='allow')
|
|
91
|
+
|
|
92
|
+
# Schema reference (optional, used by VS Code)
|
|
93
|
+
schema_: Optional[str] = Field(None, alias='$schema')
|
|
94
|
+
|
|
95
|
+
# Required fields
|
|
96
|
+
title: str # Tour title
|
|
97
|
+
steps: List[TourStep] # Ordered list of steps
|
|
98
|
+
|
|
99
|
+
# Optional metadata
|
|
100
|
+
description: Optional[str] = None # Tour description
|
|
101
|
+
ref: Optional[str] = None # Git commit SHA this tour targets
|
|
102
|
+
isPrimary: Optional[bool] = None # Is this the primary tour for the repo?
|
|
103
|
+
nextTour: Optional[str] = None # Path to next tour in sequence
|
|
104
|
+
|
|
105
|
+
# Migration metadata (our additions for tracking tour migrations)
|
|
106
|
+
original_ref: Optional[str] = Field(None, alias='originalRef') # Original commit before migration
|
|
107
|
+
migration_log: Optional[List[dict]] = Field(None, alias='migrationLog') # Migration history
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def num_steps(self) -> int:
|
|
111
|
+
"""Number of steps in this tour."""
|
|
112
|
+
return len(self.steps)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def has_ref(self) -> bool:
|
|
116
|
+
"""Does this tour have a target commit?"""
|
|
117
|
+
return self.ref is not None
|
|
118
|
+
|
|
119
|
+
def steps_with_line_refs(self) -> List[tuple]:
|
|
120
|
+
"""
|
|
121
|
+
Get all steps that have line references.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of (index, step) tuples for steps with file+line
|
|
125
|
+
"""
|
|
126
|
+
return [
|
|
127
|
+
(idx, step)
|
|
128
|
+
for idx, step in enumerate(self.steps)
|
|
129
|
+
if step.has_line_reference()
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def to_json(self, **kwargs) -> str:
|
|
133
|
+
"""Serialize tour to JSON string."""
|
|
134
|
+
return self.model_dump_json(
|
|
135
|
+
exclude_none=True,
|
|
136
|
+
by_alias=True,
|
|
137
|
+
indent=2,
|
|
138
|
+
**kwargs
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict:
|
|
142
|
+
"""Convert tour to dictionary."""
|
|
143
|
+
return self.model_dump(exclude_none=True, by_alias=True)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_json(cls, json_str: str) -> "Tour":
|
|
147
|
+
"""Parse tour from JSON string."""
|
|
148
|
+
return cls.model_validate_json(json_str)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_file(cls, path: Path) -> "Tour":
|
|
152
|
+
"""Load tour from .tour file."""
|
|
153
|
+
return cls.model_validate_json(path.read_text())
|
|
154
|
+
|
|
155
|
+
def save(self, path: Path) -> None:
|
|
156
|
+
"""Save tour to .tour file."""
|
|
157
|
+
path.write_text(self.to_json())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TourCollection(BaseModel):
|
|
161
|
+
"""
|
|
162
|
+
Collection of tours in a workspace.
|
|
163
|
+
|
|
164
|
+
The .tours directory contains multiple .tour files.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
tours: List[tuple] # List of (filename, Tour) pairs
|
|
168
|
+
workspace_path: Path
|
|
169
|
+
|
|
170
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_directory(cls, tours_dir: Path) -> "TourCollection":
|
|
174
|
+
"""
|
|
175
|
+
Load all tours from a .tours directory.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
tours_dir: Path to .tours directory
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
TourCollection with all loaded tours
|
|
182
|
+
"""
|
|
183
|
+
tours = []
|
|
184
|
+
for tour_file in sorted(tours_dir.glob("*.tour")):
|
|
185
|
+
try:
|
|
186
|
+
tour = Tour.from_file(tour_file)
|
|
187
|
+
tours.append((tour_file.name, tour))
|
|
188
|
+
except Exception as e:
|
|
189
|
+
# Log error but continue loading other tours
|
|
190
|
+
print(f"Warning: Failed to load {tour_file.name}: {e}")
|
|
191
|
+
|
|
192
|
+
return cls(
|
|
193
|
+
tours=tours,
|
|
194
|
+
workspace_path=tours_dir.parent
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def save_all(self, tours_dir: Path) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Save all tours back to directory.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
tours_dir: Path to .tours directory
|
|
203
|
+
"""
|
|
204
|
+
tours_dir.mkdir(exist_ok=True)
|
|
205
|
+
|
|
206
|
+
for filename, tour in self.tours:
|
|
207
|
+
tour_path = tours_dir / filename
|
|
208
|
+
tour.save(tour_path)
|
|
209
|
+
|
|
210
|
+
def get_tour(self, filename: str) -> Optional[Tour]:
|
|
211
|
+
"""Get a tour by filename."""
|
|
212
|
+
for name, tour in self.tours:
|
|
213
|
+
if name == filename:
|
|
214
|
+
return tour
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def create_example_tour() -> Tour:
|
|
219
|
+
"""
|
|
220
|
+
Create an example tour for testing/documentation.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
A sample Tour object
|
|
224
|
+
"""
|
|
225
|
+
return Tour(
|
|
226
|
+
title="Introduction to the Codebase",
|
|
227
|
+
description="A guided tour through the main components",
|
|
228
|
+
ref="abc123def456", # Git commit SHA
|
|
229
|
+
steps=[
|
|
230
|
+
TourStep(
|
|
231
|
+
file="src/main.py",
|
|
232
|
+
line=1,
|
|
233
|
+
description="# Welcome!\n\nThis is the main entry point."
|
|
234
|
+
),
|
|
235
|
+
TourStep(
|
|
236
|
+
file="src/core/engine.py",
|
|
237
|
+
line=42,
|
|
238
|
+
description="The core processing engine starts here."
|
|
239
|
+
),
|
|
240
|
+
TourStep(
|
|
241
|
+
file="src/utils/helpers.py",
|
|
242
|
+
pattern=r"def process_data\(",
|
|
243
|
+
description="Helper functions for data processing"
|
|
244
|
+
),
|
|
245
|
+
TourStep(
|
|
246
|
+
description="# That's it!\n\nYou've completed the tour."
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
# Example usage
|
|
254
|
+
tour = create_example_tour()
|
|
255
|
+
print(tour.to_json())
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tour updater - applies migrations to CodeTour files.
|
|
3
|
+
|
|
4
|
+
This module connects the migration engine to actual .tour files,
|
|
5
|
+
updating step locations when code changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
import git
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
from codetour_cli.tour.schema import Tour, TourStep
|
|
17
|
+
from codetour_cli.migration.direct import DirectMigrator, MigrationResult
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class StepUpdate:
|
|
24
|
+
"""
|
|
25
|
+
Record of a single step update.
|
|
26
|
+
|
|
27
|
+
Tracks what changed for each tour step during migration.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
step_index: int
|
|
31
|
+
old_location: str # "file.py:42"
|
|
32
|
+
new_location: Optional[str] # "file.py:45" or None if deprecated
|
|
33
|
+
|
|
34
|
+
migration_result: MigrationResult
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def was_updated(self) -> bool:
|
|
38
|
+
"""Did the location change?"""
|
|
39
|
+
return self.old_location != self.new_location
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def needs_review(self) -> bool:
|
|
43
|
+
"""Should this update be reviewed by user?"""
|
|
44
|
+
return self.migration_result.needs_review
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def deprecated(self) -> bool:
|
|
48
|
+
"""Was this step deprecated (file deleted)?"""
|
|
49
|
+
return self.migration_result.deprecated
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TourUpdateResult:
|
|
54
|
+
"""
|
|
55
|
+
Result of updating an entire tour.
|
|
56
|
+
|
|
57
|
+
Contains the updated tour and metadata about changes.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
tour: Tour
|
|
61
|
+
updates: List[StepUpdate]
|
|
62
|
+
|
|
63
|
+
source_commit: str
|
|
64
|
+
target_commit: str
|
|
65
|
+
timestamp: datetime
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def num_updated(self) -> int:
|
|
69
|
+
"""Number of steps that changed location."""
|
|
70
|
+
return sum(1 for u in self.updates if u.was_updated)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def num_deprecated(self) -> int:
|
|
74
|
+
"""Number of steps marked as deprecated."""
|
|
75
|
+
return sum(1 for u in self.updates if u.deprecated)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def num_needs_review(self) -> int:
|
|
79
|
+
"""Number of steps that need manual review."""
|
|
80
|
+
return sum(1 for u in self.updates if u.needs_review)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def all_successful(self) -> bool:
|
|
84
|
+
"""Were all migrations successful (no deprecated steps)?"""
|
|
85
|
+
return self.num_deprecated == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TourUpdater:
|
|
89
|
+
"""
|
|
90
|
+
Updates CodeTour files by migrating steps through git commits.
|
|
91
|
+
|
|
92
|
+
Uses DirectMigrator to track line movements, then updates
|
|
93
|
+
the tour file with new locations.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
repo: git.Repo,
|
|
99
|
+
diff_algorithm: str = "histogram",
|
|
100
|
+
threshold: float = 0.7
|
|
101
|
+
):
|
|
102
|
+
"""
|
|
103
|
+
Initialize the tour updater.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
repo: GitPython repository object
|
|
107
|
+
diff_algorithm: Git diff algorithm to use
|
|
108
|
+
threshold: confidence cutoff passed through to DirectMigrator
|
|
109
|
+
(ADR-0016 QST-CONF-2 -- previously never reached the scorer)
|
|
110
|
+
"""
|
|
111
|
+
self.repo = repo
|
|
112
|
+
self.diff_algorithm = diff_algorithm
|
|
113
|
+
self.threshold = threshold
|
|
114
|
+
self.log = logger.bind(repo=str(repo.working_dir))
|
|
115
|
+
|
|
116
|
+
def update_tour(
|
|
117
|
+
self,
|
|
118
|
+
tour: Tour,
|
|
119
|
+
target_commit: str,
|
|
120
|
+
source_commit: Optional[str] = None
|
|
121
|
+
) -> TourUpdateResult:
|
|
122
|
+
"""
|
|
123
|
+
Update a tour to target commit.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
tour: Tour object to update
|
|
127
|
+
target_commit: Commit SHA to migrate to
|
|
128
|
+
source_commit: Source commit (defaults to tour.ref)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
TourUpdateResult with updated tour and metadata
|
|
132
|
+
"""
|
|
133
|
+
# Use tour.ref if no source specified
|
|
134
|
+
if source_commit is None:
|
|
135
|
+
if tour.ref is None:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"Tour has no ref commit and no source_commit provided"
|
|
138
|
+
)
|
|
139
|
+
source_commit = tour.ref
|
|
140
|
+
|
|
141
|
+
# Resolve commit refs to actual SHAs (HEAD, branch names, etc.)
|
|
142
|
+
# This ensures we store concrete commit IDs, not moving targets
|
|
143
|
+
source_commit = self.repo.commit(source_commit).hexsha
|
|
144
|
+
target_commit = self.repo.commit(target_commit).hexsha
|
|
145
|
+
|
|
146
|
+
log = self.log.bind(
|
|
147
|
+
source=source_commit[:8],
|
|
148
|
+
target=target_commit[:8],
|
|
149
|
+
tour_title=tour.title
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
log.info("updating_tour")
|
|
153
|
+
|
|
154
|
+
# Create migrator
|
|
155
|
+
migrator = DirectMigrator(
|
|
156
|
+
self.repo,
|
|
157
|
+
source_commit,
|
|
158
|
+
target_commit,
|
|
159
|
+
self.diff_algorithm,
|
|
160
|
+
self.threshold
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Migrate each step with line reference
|
|
164
|
+
updates = []
|
|
165
|
+
for idx, step in enumerate(tour.steps):
|
|
166
|
+
if step.has_line_reference():
|
|
167
|
+
update = self._migrate_step(migrator, idx, step, log)
|
|
168
|
+
updates.append(update)
|
|
169
|
+
|
|
170
|
+
# Apply update to step
|
|
171
|
+
if update.migration_result.file_renamed:
|
|
172
|
+
step.file = update.migration_result.new_file
|
|
173
|
+
if update.migration_result.new_line is not None:
|
|
174
|
+
step.line = update.migration_result.new_line
|
|
175
|
+
else:
|
|
176
|
+
log.debug(
|
|
177
|
+
"step_skipped",
|
|
178
|
+
step_index=idx,
|
|
179
|
+
reason="no_line_reference"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Update tour metadata
|
|
183
|
+
self._update_tour_metadata(
|
|
184
|
+
tour,
|
|
185
|
+
source_commit,
|
|
186
|
+
target_commit,
|
|
187
|
+
updates
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Create result
|
|
191
|
+
result = TourUpdateResult(
|
|
192
|
+
tour=tour,
|
|
193
|
+
updates=updates,
|
|
194
|
+
source_commit=source_commit,
|
|
195
|
+
target_commit=target_commit,
|
|
196
|
+
timestamp=datetime.now()
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
log.info(
|
|
200
|
+
"tour_updated",
|
|
201
|
+
num_steps=len(tour.steps),
|
|
202
|
+
num_updated=result.num_updated,
|
|
203
|
+
num_deprecated=result.num_deprecated,
|
|
204
|
+
num_needs_review=result.num_needs_review
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
def _migrate_step(
|
|
210
|
+
self,
|
|
211
|
+
migrator: DirectMigrator,
|
|
212
|
+
step_index: int,
|
|
213
|
+
step: TourStep,
|
|
214
|
+
log: structlog.BoundLogger
|
|
215
|
+
) -> StepUpdate:
|
|
216
|
+
"""
|
|
217
|
+
Migrate a single tour step.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
migrator: DirectMigrator instance
|
|
221
|
+
step_index: Index of step in tour
|
|
222
|
+
step: TourStep to migrate
|
|
223
|
+
log: Structured logger
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
StepUpdate with migration result
|
|
227
|
+
"""
|
|
228
|
+
old_location = f"{step.file}:{step.line}"
|
|
229
|
+
|
|
230
|
+
# Perform migration (pass step for pattern validation)
|
|
231
|
+
result = migrator.migrate_step(
|
|
232
|
+
step.file,
|
|
233
|
+
step.line,
|
|
234
|
+
step_id=step_index,
|
|
235
|
+
step=step # Pass full step for pattern validation
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Determine new location (`is not None`, not bare truthiness -- line 0
|
|
239
|
+
# is a real, if invalid, int that Python's truthiness treats as falsy;
|
|
240
|
+
# ADR-0016 QST-CONF-1 found this exact bug caused this display logic
|
|
241
|
+
# to report "deprecated" while the mutation below -- which already
|
|
242
|
+
# correctly used `is not None` -- wrote a real line into the tour)
|
|
243
|
+
if result.new_file and result.new_line is not None:
|
|
244
|
+
new_location = f"{result.new_file}:{result.new_line}"
|
|
245
|
+
else:
|
|
246
|
+
new_location = None # Deprecated
|
|
247
|
+
|
|
248
|
+
log.debug(
|
|
249
|
+
"step_migrated",
|
|
250
|
+
step_index=step_index,
|
|
251
|
+
old=old_location,
|
|
252
|
+
new=new_location,
|
|
253
|
+
method=result.method,
|
|
254
|
+
confidence=result.confidence
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return StepUpdate(
|
|
258
|
+
step_index=step_index,
|
|
259
|
+
old_location=old_location,
|
|
260
|
+
new_location=new_location,
|
|
261
|
+
migration_result=result
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _update_tour_metadata(
|
|
265
|
+
self,
|
|
266
|
+
tour: Tour,
|
|
267
|
+
source_commit: str,
|
|
268
|
+
target_commit: str,
|
|
269
|
+
updates: List[StepUpdate]
|
|
270
|
+
) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Update tour metadata to reflect migration.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
tour: Tour object to update (modified in-place)
|
|
276
|
+
source_commit: Source commit SHA
|
|
277
|
+
target_commit: Target commit SHA
|
|
278
|
+
updates: List of step updates
|
|
279
|
+
"""
|
|
280
|
+
# Store original ref if this is first migration
|
|
281
|
+
if tour.original_ref is None:
|
|
282
|
+
tour.original_ref = source_commit
|
|
283
|
+
|
|
284
|
+
# Update current ref to target
|
|
285
|
+
tour.ref = target_commit
|
|
286
|
+
|
|
287
|
+
# Add migration log entry
|
|
288
|
+
if tour.migration_log is None:
|
|
289
|
+
tour.migration_log = []
|
|
290
|
+
|
|
291
|
+
tour.migration_log.append({
|
|
292
|
+
"from": source_commit,
|
|
293
|
+
"to": target_commit,
|
|
294
|
+
"date": datetime.now().isoformat(),
|
|
295
|
+
"num_updated": sum(1 for u in updates if u.was_updated),
|
|
296
|
+
"num_deprecated": sum(1 for u in updates if u.deprecated),
|
|
297
|
+
"num_needs_review": sum(1 for u in updates if u.needs_review)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def update_tour_file(
|
|
302
|
+
repo_path: Path,
|
|
303
|
+
tour_file: Path,
|
|
304
|
+
target_commit: str,
|
|
305
|
+
source_commit: Optional[str] = None,
|
|
306
|
+
diff_algorithm: str = "histogram",
|
|
307
|
+
save_backup: bool = True,
|
|
308
|
+
dry_run: bool = False,
|
|
309
|
+
threshold: float = 0.7
|
|
310
|
+
) -> TourUpdateResult:
|
|
311
|
+
"""
|
|
312
|
+
Convenience function to update a .tour file.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
repo_path: Path to Git repository
|
|
316
|
+
tour_file: Path to .tour file
|
|
317
|
+
target_commit: Commit to migrate to
|
|
318
|
+
source_commit: Source commit (uses tour.ref if None)
|
|
319
|
+
diff_algorithm: Git diff algorithm
|
|
320
|
+
save_backup: Save .tour.backup before updating
|
|
321
|
+
dry_run: Preview changes without modifying files
|
|
322
|
+
threshold: confidence cutoff for needs_review, passed through to
|
|
323
|
+
DirectMigrator (ADR-0016 QST-CONF-2)
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
TourUpdateResult with updated tour
|
|
327
|
+
"""
|
|
328
|
+
log = logger.bind(
|
|
329
|
+
repo=str(repo_path),
|
|
330
|
+
tour=tour_file.name
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if dry_run:
|
|
334
|
+
log.info("updating_tour_file_dry_run")
|
|
335
|
+
else:
|
|
336
|
+
log.info("updating_tour_file")
|
|
337
|
+
|
|
338
|
+
# Load tour
|
|
339
|
+
tour = Tour.from_file(tour_file)
|
|
340
|
+
|
|
341
|
+
# Create updater
|
|
342
|
+
repo = git.Repo(repo_path)
|
|
343
|
+
updater = TourUpdater(repo, diff_algorithm, threshold)
|
|
344
|
+
|
|
345
|
+
# Update tour
|
|
346
|
+
result = updater.update_tour(tour, target_commit, source_commit)
|
|
347
|
+
|
|
348
|
+
# Save files only if not dry-run
|
|
349
|
+
if not dry_run:
|
|
350
|
+
# Save backup if requested (use direct copy to preserve formatting)
|
|
351
|
+
if save_backup:
|
|
352
|
+
import shutil
|
|
353
|
+
backup_path = tour_file.with_suffix('.tour.backup')
|
|
354
|
+
shutil.copy2(tour_file, backup_path)
|
|
355
|
+
log.info("backup_saved", backup_path=str(backup_path))
|
|
356
|
+
|
|
357
|
+
# Save updated tour
|
|
358
|
+
result.tour.save(tour_file)
|
|
359
|
+
log.info("tour_file_updated")
|
|
360
|
+
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def update_all_tours(
|
|
365
|
+
repo_path: Path,
|
|
366
|
+
tours_dir: Path,
|
|
367
|
+
target_commit: str,
|
|
368
|
+
diff_algorithm: str = "histogram",
|
|
369
|
+
save_backups: bool = True,
|
|
370
|
+
threshold: float = 0.7
|
|
371
|
+
) -> List[TourUpdateResult]:
|
|
372
|
+
"""
|
|
373
|
+
Update all .tour files in a directory.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
repo_path: Path to Git repository
|
|
377
|
+
tours_dir: Path to .tours directory
|
|
378
|
+
target_commit: Commit to migrate to
|
|
379
|
+
diff_algorithm: Git diff algorithm
|
|
380
|
+
save_backups: Save .tour.backup files
|
|
381
|
+
threshold: confidence cutoff for needs_review (ADR-0016 QST-CONF-2)
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
List of TourUpdateResult objects
|
|
385
|
+
"""
|
|
386
|
+
log = logger.bind(
|
|
387
|
+
repo=str(repo_path),
|
|
388
|
+
tours_dir=str(tours_dir)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Find all .tour files
|
|
392
|
+
tour_files = list(tours_dir.glob("*.tour"))
|
|
393
|
+
log.info("updating_all_tours", num_tours=len(tour_files))
|
|
394
|
+
|
|
395
|
+
# Update each tour
|
|
396
|
+
results = []
|
|
397
|
+
for tour_file in sorted(tour_files):
|
|
398
|
+
try:
|
|
399
|
+
result = update_tour_file(
|
|
400
|
+
repo_path,
|
|
401
|
+
tour_file,
|
|
402
|
+
target_commit,
|
|
403
|
+
diff_algorithm=diff_algorithm,
|
|
404
|
+
save_backup=save_backups,
|
|
405
|
+
threshold=threshold
|
|
406
|
+
)
|
|
407
|
+
results.append(result)
|
|
408
|
+
except Exception as e:
|
|
409
|
+
log.error(
|
|
410
|
+
"tour_update_failed",
|
|
411
|
+
tour=tour_file.name,
|
|
412
|
+
error=str(e)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Summary
|
|
416
|
+
total_updated = sum(r.num_updated for r in results)
|
|
417
|
+
total_deprecated = sum(r.num_deprecated for r in results)
|
|
418
|
+
total_needs_review = sum(r.num_needs_review for r in results)
|
|
419
|
+
|
|
420
|
+
log.info(
|
|
421
|
+
"all_tours_updated",
|
|
422
|
+
num_tours=len(results),
|
|
423
|
+
total_updated=total_updated,
|
|
424
|
+
total_deprecated=total_deprecated,
|
|
425
|
+
total_needs_review=total_needs_review
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return results
|