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
codetour_cli/cli.py
ADDED
|
@@ -0,0 +1,2087 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeTour CLI - Command line interface for maintaining CodeTours.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import argparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
from git import Repo, InvalidGitRepositoryError
|
|
13
|
+
|
|
14
|
+
from codetour_cli.logging.setup import configure_logging
|
|
15
|
+
from codetour_cli.detection import detect_source_commit
|
|
16
|
+
from codetour_cli.tour.updater import update_tour_file
|
|
17
|
+
from codetour_cli.tour.schema import Tour
|
|
18
|
+
from codetour_cli.discovery import discover_repository, is_tour_up_to_date, find_repo_root, find_tours_directory, scan_tours
|
|
19
|
+
from codetour_cli.config import generate_config_file, load_config
|
|
20
|
+
from codetour_cli.schema_models import ParsingSchema, FieldSchema
|
|
21
|
+
|
|
22
|
+
log = structlog.get_logger()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_jinja_env():
|
|
26
|
+
"""
|
|
27
|
+
Get Jinja2 environment configured for templates.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Jinja2 Environment instance
|
|
31
|
+
"""
|
|
32
|
+
from jinja2 import Environment, FileSystemLoader
|
|
33
|
+
|
|
34
|
+
template_dir = Path(__file__).parent / 'templates'
|
|
35
|
+
return Environment(
|
|
36
|
+
loader=FileSystemLoader(str(template_dir)),
|
|
37
|
+
autoescape=False, # Markdown doesn't need HTML escaping
|
|
38
|
+
trim_blocks=True,
|
|
39
|
+
lstrip_blocks=True
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_template(template_name: str, **context) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Render a Jinja2 template with the given context.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
template_name: Name of the template file (e.g., 'review_report.md')
|
|
49
|
+
**context: Variables to pass to the template
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Rendered template as a string
|
|
53
|
+
"""
|
|
54
|
+
env = get_jinja_env()
|
|
55
|
+
template = env.get_template(template_name)
|
|
56
|
+
return template.render(**context)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_relative_time(dt: datetime) -> str:
|
|
60
|
+
"""Format datetime as relative time (e.g., '2 days ago')."""
|
|
61
|
+
now = datetime.now(timezone.utc)
|
|
62
|
+
|
|
63
|
+
# Make dt timezone-aware if it isn't
|
|
64
|
+
if dt.tzinfo is None:
|
|
65
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
66
|
+
|
|
67
|
+
delta = now - dt
|
|
68
|
+
|
|
69
|
+
seconds = delta.total_seconds()
|
|
70
|
+
|
|
71
|
+
if seconds < 60:
|
|
72
|
+
return "just now"
|
|
73
|
+
elif seconds < 3600:
|
|
74
|
+
minutes = int(seconds / 60)
|
|
75
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
|
76
|
+
elif seconds < 86400:
|
|
77
|
+
hours = int(seconds / 3600)
|
|
78
|
+
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
|
79
|
+
elif seconds < 604800:
|
|
80
|
+
days = int(seconds / 86400)
|
|
81
|
+
return f"{days} day{'s' if days != 1 else ''} ago"
|
|
82
|
+
elif seconds < 2592000:
|
|
83
|
+
weeks = int(seconds / 604800)
|
|
84
|
+
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
|
|
85
|
+
elif seconds < 31536000:
|
|
86
|
+
months = int(seconds / 2592000)
|
|
87
|
+
return f"{months} month{'s' if months != 1 else ''} ago"
|
|
88
|
+
else:
|
|
89
|
+
years = int(seconds / 31536000)
|
|
90
|
+
return f"{years} year{'s' if years != 1 else ''} ago"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def print_migration_summary(result, tour_name: str, is_dry_run: bool = False):
|
|
94
|
+
"""Print a summary table of migration changes."""
|
|
95
|
+
summary_title = "Tentative Migration Summary" if is_dry_run else "Migration Summary"
|
|
96
|
+
print(f"\n {summary_title} for {tour_name}:")
|
|
97
|
+
print(f" {'Step':<6} {'Location':<40} {'Status':<20}")
|
|
98
|
+
print(f" {'-'*6} {'-'*40} {'-'*20}")
|
|
99
|
+
|
|
100
|
+
prev_file = None
|
|
101
|
+
|
|
102
|
+
for update in result.updates:
|
|
103
|
+
step_num = update.step_index + 1
|
|
104
|
+
|
|
105
|
+
# Format old → new or just location
|
|
106
|
+
if update.new_location is None:
|
|
107
|
+
location = f"{update.old_location:<40}"
|
|
108
|
+
status = "❌ Would deprecate" if is_dry_run else "❌ Deprecated"
|
|
109
|
+
# Track file for next iteration
|
|
110
|
+
if ':' in update.old_location:
|
|
111
|
+
prev_file = update.old_location.rsplit(':', 1)[0]
|
|
112
|
+
elif update.was_updated:
|
|
113
|
+
# Parse old and new locations
|
|
114
|
+
old_file, old_line = update.old_location.rsplit(':', 1) if ':' in update.old_location else (update.old_location, '')
|
|
115
|
+
new_file, new_line = update.new_location.rsplit(':', 1) if ':' in update.new_location else (update.new_location, '')
|
|
116
|
+
|
|
117
|
+
# Optimize display - only show file when it changes
|
|
118
|
+
if old_file == new_file:
|
|
119
|
+
# Same file - just show line numbers
|
|
120
|
+
location = f":{old_line} → :{new_line}"
|
|
121
|
+
# If file changed from previous step, show it
|
|
122
|
+
if prev_file != old_file:
|
|
123
|
+
location = f"{old_file}{location}"
|
|
124
|
+
else:
|
|
125
|
+
# Different files - show full locations
|
|
126
|
+
location = f"{update.old_location} → {update.new_location}"
|
|
127
|
+
|
|
128
|
+
if len(location) > 40:
|
|
129
|
+
location = location[:37] + "..."
|
|
130
|
+
|
|
131
|
+
if update.needs_review:
|
|
132
|
+
status = "⚠️ Would need review" if is_dry_run else "⚠️ Needs review"
|
|
133
|
+
else:
|
|
134
|
+
status = "✓ Would update" if is_dry_run else "✓ Updated"
|
|
135
|
+
|
|
136
|
+
prev_file = new_file
|
|
137
|
+
else:
|
|
138
|
+
# Unchanged - optimize if same file as previous
|
|
139
|
+
if ':' in update.old_location:
|
|
140
|
+
curr_file, curr_line = update.old_location.rsplit(':', 1)
|
|
141
|
+
if prev_file == curr_file:
|
|
142
|
+
location = f":{curr_line}"
|
|
143
|
+
else:
|
|
144
|
+
location = update.old_location
|
|
145
|
+
prev_file = curr_file
|
|
146
|
+
else:
|
|
147
|
+
location = update.old_location
|
|
148
|
+
status = "— Unchanged"
|
|
149
|
+
|
|
150
|
+
print(f" {step_num:<6} {location:<40} {status:<20}")
|
|
151
|
+
|
|
152
|
+
print()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def quote_with_chevrons(text: str) -> str:
|
|
156
|
+
"""Quote multi-line text with markdown chevrons (>)."""
|
|
157
|
+
if not text:
|
|
158
|
+
return "> (No description)"
|
|
159
|
+
lines = text.split('\n')
|
|
160
|
+
return '\n'.join(f"> {line}" for line in lines)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_code_context(repo_root: Path, file_path: str, line_num: int, commit_sha: str, context_lines: int = 7) -> str:
|
|
164
|
+
"""
|
|
165
|
+
Extract code context around a line number from a specific git commit.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
repo_root: Root of the repository
|
|
169
|
+
file_path: Relative path to file (e.g., "src/main.rs")
|
|
170
|
+
line_num: Line number (1-indexed)
|
|
171
|
+
commit_sha: Git commit SHA to read file from
|
|
172
|
+
context_lines: Number of lines before and after to include
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Formatted code context with line numbers
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
from git import Repo
|
|
179
|
+
|
|
180
|
+
repo = Repo(repo_root)
|
|
181
|
+
|
|
182
|
+
# Get file content from specific commit
|
|
183
|
+
try:
|
|
184
|
+
commit = repo.commit(commit_sha)
|
|
185
|
+
file_content = commit.tree / file_path
|
|
186
|
+
lines = file_content.data_stream.read().decode('utf-8', errors='replace').splitlines()
|
|
187
|
+
except (KeyError, AttributeError):
|
|
188
|
+
return f"(File not found in commit {commit_sha[:8]}: {file_path})"
|
|
189
|
+
|
|
190
|
+
# Calculate range (1-indexed to 0-indexed)
|
|
191
|
+
start = max(0, line_num - context_lines - 1)
|
|
192
|
+
end = min(len(lines), line_num + context_lines)
|
|
193
|
+
target_idx = line_num - 1
|
|
194
|
+
|
|
195
|
+
# Build context with line numbers
|
|
196
|
+
context = []
|
|
197
|
+
for i in range(start, end):
|
|
198
|
+
line_no = i + 1
|
|
199
|
+
marker = " → " if i == target_idx else " "
|
|
200
|
+
context.append(f"{line_no:4d}{marker}{lines[i]}")
|
|
201
|
+
|
|
202
|
+
return '\n'.join(context)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
return f"(Failed to read {file_path} from commit {commit_sha[:8]}: {e})"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_frontmatter(content: str) -> dict:
|
|
208
|
+
"""
|
|
209
|
+
Extract YAML frontmatter from markdown content.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
content: Markdown content with optional frontmatter
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Dictionary of frontmatter values (empty if no frontmatter)
|
|
216
|
+
"""
|
|
217
|
+
import yaml
|
|
218
|
+
import re
|
|
219
|
+
|
|
220
|
+
# Look for YAML frontmatter delimited by ---
|
|
221
|
+
match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
|
222
|
+
if not match:
|
|
223
|
+
return {}
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
frontmatter = yaml.safe_load(match.group(1))
|
|
227
|
+
return frontmatter or {}
|
|
228
|
+
except yaml.YAMLError:
|
|
229
|
+
return {}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def has_end_schema(content: str) -> bool:
|
|
233
|
+
"""
|
|
234
|
+
Check if content has an embedded parsing schema at the end.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
content: Markdown content
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if end-schema is present
|
|
241
|
+
"""
|
|
242
|
+
return '<!-- PARSING_SCHEMA' in content
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def extract_end_schema(content: str) -> Optional[ParsingSchema]:
|
|
246
|
+
"""
|
|
247
|
+
Extract parsing schema from end of markdown content.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
content: Markdown content with optional embedded schema
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
ParsingSchema if found, None otherwise
|
|
254
|
+
"""
|
|
255
|
+
import yaml
|
|
256
|
+
import re
|
|
257
|
+
|
|
258
|
+
# Look for schema in HTML comment
|
|
259
|
+
match = re.search(
|
|
260
|
+
r'<!-- PARSING_SCHEMA.*?\n(.*?)\n-->',
|
|
261
|
+
content,
|
|
262
|
+
re.DOTALL
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if not match:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
schema_yaml = match.group(1)
|
|
270
|
+
schema_data = yaml.safe_load(schema_yaml)
|
|
271
|
+
return ParsingSchema.from_dict(schema_data)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
log.warning("failed_to_parse_end_schema", error=str(e))
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def load_parsing_schema(report_content: str) -> ParsingSchema:
|
|
278
|
+
"""
|
|
279
|
+
Load parsing schema using smart defaults.
|
|
280
|
+
|
|
281
|
+
Implements the refined Iteration 2 approach:
|
|
282
|
+
1. Extract frontmatter to get schema version/language
|
|
283
|
+
2. Load default schema for that version
|
|
284
|
+
3. Merge with custom end-schema if present
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
report_content: Full markdown content of review report
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
ParsingSchema with defaults merged with any customizations
|
|
291
|
+
"""
|
|
292
|
+
# 1. Extract frontmatter
|
|
293
|
+
frontmatter = extract_frontmatter(report_content)
|
|
294
|
+
schema_version = frontmatter.get('schema_version', '1.0')
|
|
295
|
+
language = frontmatter.get('language', 'en')
|
|
296
|
+
|
|
297
|
+
# 2. Load default schema for version
|
|
298
|
+
schemas_dir = Path(__file__).parent / 'schemas'
|
|
299
|
+
default_schema_path = schemas_dir / f"review_report_v{schema_version}.yaml"
|
|
300
|
+
|
|
301
|
+
if not default_schema_path.exists():
|
|
302
|
+
# Fallback to v1.0 if requested version doesn't exist
|
|
303
|
+
log.warning(
|
|
304
|
+
"schema_version_not_found",
|
|
305
|
+
requested=schema_version,
|
|
306
|
+
fallback="1.0"
|
|
307
|
+
)
|
|
308
|
+
default_schema_path = schemas_dir / "review_report_v1.0.yaml"
|
|
309
|
+
|
|
310
|
+
schema = ParsingSchema.from_yaml(default_schema_path)
|
|
311
|
+
|
|
312
|
+
# 3. Check for custom schema override
|
|
313
|
+
if frontmatter.get('custom_schema') or has_end_schema(report_content):
|
|
314
|
+
custom_schema = extract_end_schema(report_content)
|
|
315
|
+
if custom_schema:
|
|
316
|
+
schema = schema.merge(custom_schema)
|
|
317
|
+
log.info("merged_custom_schema", language=custom_schema.language)
|
|
318
|
+
|
|
319
|
+
return schema
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def generate_review_report(
|
|
323
|
+
tour_path: Path,
|
|
324
|
+
result,
|
|
325
|
+
source_commit: str,
|
|
326
|
+
target_commit: str,
|
|
327
|
+
repo_root: Path,
|
|
328
|
+
is_dry_run: bool = False,
|
|
329
|
+
no_guidance: bool = False,
|
|
330
|
+
threshold: Optional[float] = None
|
|
331
|
+
) -> Optional[Path]:
|
|
332
|
+
"""
|
|
333
|
+
Generate a markdown review report for migrations with low-confidence steps.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
threshold: the ACTUAL threshold value the migration just ran with
|
|
337
|
+
(ADR-0016 QST-CONF-2). When given, the report displays this
|
|
338
|
+
value rather than independently re-reading the config file --
|
|
339
|
+
those could disagree whenever `--threshold` overrides the
|
|
340
|
+
config on the command line, showing a report that describes
|
|
341
|
+
a run that didn't happen. Falls back to reading config
|
|
342
|
+
directly only when not given, for older callers.
|
|
343
|
+
|
|
344
|
+
Returns path to generated report, or None if no review needed.
|
|
345
|
+
"""
|
|
346
|
+
# Only generate if there are steps needing review
|
|
347
|
+
if result.num_needs_review == 0:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
from datetime import datetime
|
|
351
|
+
from git import Repo
|
|
352
|
+
|
|
353
|
+
tour_name = tour_path.stem # Remove .tour extension
|
|
354
|
+
# Include target commit hash in filename for versioning
|
|
355
|
+
report_path = tour_path.parent / f"MIGRATION-REVIEW-{tour_name}-{target_commit[:8]}.md"
|
|
356
|
+
|
|
357
|
+
# Get review steps
|
|
358
|
+
review_steps = [
|
|
359
|
+
(i + 1, update) for i, update in enumerate(result.updates)
|
|
360
|
+
if update.needs_review
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
# Build report content with frontmatter
|
|
364
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
365
|
+
mode = "dry-run" if is_dry_run else "live"
|
|
366
|
+
|
|
367
|
+
# Add YAML frontmatter with schema version
|
|
368
|
+
report = "---\n"
|
|
369
|
+
report += "schema_version: '1.0'\n"
|
|
370
|
+
report += "---\n\n"
|
|
371
|
+
|
|
372
|
+
# Get commit info for both source and target
|
|
373
|
+
try:
|
|
374
|
+
repo = Repo(repo_root)
|
|
375
|
+
|
|
376
|
+
# Source commit
|
|
377
|
+
source_commit_obj = repo.commit(source_commit)
|
|
378
|
+
source_timestamp = source_commit_obj.committed_datetime.strftime("%Y-%m-%d %H:%M")
|
|
379
|
+
source_desc = f"{source_commit[:8]} (committed {source_timestamp})"
|
|
380
|
+
|
|
381
|
+
# Target commit
|
|
382
|
+
target_commit_obj = repo.commit(target_commit)
|
|
383
|
+
target_timestamp = target_commit_obj.committed_datetime.strftime("%Y-%m-%d %H:%M")
|
|
384
|
+
|
|
385
|
+
# Calculate commits behind
|
|
386
|
+
try:
|
|
387
|
+
commits_behind = len(list(repo.iter_commits(f"{source_commit}..{target_commit}")))
|
|
388
|
+
except:
|
|
389
|
+
commits_behind = 0
|
|
390
|
+
|
|
391
|
+
# Check if target is HEAD
|
|
392
|
+
try:
|
|
393
|
+
head_sha = repo.head.commit.hexsha
|
|
394
|
+
if target_commit.startswith(head_sha[:8]) or head_sha.startswith(target_commit[:8]):
|
|
395
|
+
if commits_behind > 0:
|
|
396
|
+
commits_label = "commit" if commits_behind == 1 else "commits"
|
|
397
|
+
target_desc = f"HEAD, {target_commit[:8]} (committed {target_timestamp}), {commits_behind} {commits_label} behind"
|
|
398
|
+
else:
|
|
399
|
+
target_desc = f"HEAD, {target_commit[:8]} (committed {target_timestamp})"
|
|
400
|
+
else:
|
|
401
|
+
target_desc = f"{target_commit[:8]} (committed {target_timestamp})"
|
|
402
|
+
except:
|
|
403
|
+
target_desc = f"{target_commit[:8]} (committed {target_timestamp})"
|
|
404
|
+
except:
|
|
405
|
+
source_desc = f"{source_commit[:8]}"
|
|
406
|
+
target_desc = f"{target_commit[:8]}"
|
|
407
|
+
|
|
408
|
+
# Conditional language based on mode
|
|
409
|
+
verb_past = "would be updated" if is_dry_run else "updated"
|
|
410
|
+
verb_need = "would need" if is_dry_run else "need"
|
|
411
|
+
|
|
412
|
+
# Render header template
|
|
413
|
+
deprecated_line = f"- ❌ **{result.num_deprecated} steps** reference deleted files" if result.num_deprecated > 0 else ""
|
|
414
|
+
|
|
415
|
+
report += render_template(
|
|
416
|
+
'review_report.md',
|
|
417
|
+
timestamp=timestamp,
|
|
418
|
+
source_desc=source_desc,
|
|
419
|
+
target_desc=target_desc,
|
|
420
|
+
mode=mode,
|
|
421
|
+
tour_name=tour_path.name,
|
|
422
|
+
total_steps=len(result.updates),
|
|
423
|
+
num_updated_success=result.num_updated - result.num_needs_review,
|
|
424
|
+
verb_past=verb_past,
|
|
425
|
+
num_needs_review=result.num_needs_review,
|
|
426
|
+
verb_need=verb_need,
|
|
427
|
+
num_unchanged=len(result.updates) - result.num_updated,
|
|
428
|
+
deprecated_line=deprecated_line
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Add guidance sections unless --no-guidance flag is set
|
|
432
|
+
if not no_guidance:
|
|
433
|
+
# Calculate relative path from repo root for documentation
|
|
434
|
+
try:
|
|
435
|
+
report_name_relative = str(report_path.relative_to(repo_root))
|
|
436
|
+
except ValueError:
|
|
437
|
+
# If path is not relative to repo_root, use name only
|
|
438
|
+
report_name_relative = report_path.name
|
|
439
|
+
|
|
440
|
+
# Render unified guidance template with mode-specific content
|
|
441
|
+
report += render_template(
|
|
442
|
+
'guidance.md',
|
|
443
|
+
is_dry_run=is_dry_run,
|
|
444
|
+
report_name=report_name_relative,
|
|
445
|
+
tour_path=tour_path
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
report += """
|
|
449
|
+
|
|
450
|
+
## Steps Needing Review
|
|
451
|
+
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
# Add each review step
|
|
455
|
+
for step_num, update in review_steps:
|
|
456
|
+
step = result.tour.steps[update.step_index]
|
|
457
|
+
|
|
458
|
+
# Parse old and new locations
|
|
459
|
+
old_parts = update.old_location.split(':')
|
|
460
|
+
old_file = old_parts[0]
|
|
461
|
+
old_line = int(old_parts[1]) if len(old_parts) > 1 else None
|
|
462
|
+
|
|
463
|
+
if update.new_location:
|
|
464
|
+
new_parts = update.new_location.split(':')
|
|
465
|
+
new_file = new_parts[0]
|
|
466
|
+
new_line = int(new_parts[1]) if len(new_parts) > 1 else None
|
|
467
|
+
else:
|
|
468
|
+
new_file = "(file deleted)"
|
|
469
|
+
new_line = None
|
|
470
|
+
|
|
471
|
+
# Get code context for old and new locations (from specific commits)
|
|
472
|
+
old_context = get_code_context(repo_root, old_file, old_line, source_commit) if old_line else "(No line number)"
|
|
473
|
+
new_context = get_code_context(repo_root, new_file, new_line, target_commit) if new_line and update.new_location else "(File deleted)"
|
|
474
|
+
|
|
475
|
+
# Quote the description with chevrons
|
|
476
|
+
quoted_description = quote_with_chevrons(step.description)
|
|
477
|
+
|
|
478
|
+
# Build absolute paths for clickable links
|
|
479
|
+
abs_old_file = (repo_root / old_file).resolve()
|
|
480
|
+
abs_new_file = (repo_root / new_file).resolve() if update.new_location else abs_old_file
|
|
481
|
+
|
|
482
|
+
# Create VS Code URLs for clickable links
|
|
483
|
+
vscode_old_url = f"vscode://file/{abs_old_file}:{old_line}:1"
|
|
484
|
+
vscode_new_url = f"vscode://file/{abs_new_file}:{new_line if new_line else 1}:1"
|
|
485
|
+
|
|
486
|
+
hunk_interior_warning = (
|
|
487
|
+
"\n**⚠️ Verify before accepting** -- this position came from an "
|
|
488
|
+
"unverified positional guess inside a changed region (`hunk_interior`), "
|
|
489
|
+
"not from content matching. ADR-0016's calibration study measured "
|
|
490
|
+
"this class of guess at 33% correct (2 of 6 labeled cases) -- treat "
|
|
491
|
+
"the tentative fields below as a weak starting point, not a likely-"
|
|
492
|
+
"correct default.\n"
|
|
493
|
+
if update.migration_result.method == 'hunk_interior' else ""
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
report += f"""### Step {step_num}: {update.old_location} → {update.new_location} ({int(update.migration_result.confidence * 100)}% confidence) ⚠️
|
|
497
|
+
{hunk_interior_warning}
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
#### Review Status
|
|
501
|
+
|
|
502
|
+
- **Status**: [ ] Not reviewed
|
|
503
|
+
- **Tentative file**: `{new_file if update.new_location else old_file}`
|
|
504
|
+
- **Tentative line**: `{new_line if update.new_location else old_line}`
|
|
505
|
+
- **Confidence**: {int(update.migration_result.confidence * 100)}%
|
|
506
|
+
- **Modified Description**: [ ] (Check this to edit the tour step description)
|
|
507
|
+
|
|
508
|
+
**Edited Description**:
|
|
509
|
+
|
|
510
|
+
> (Edit the step description here if you checked "Modified Description" above)
|
|
511
|
+
|
|
512
|
+
**Note**: If the tentative location is incorrect, you can edit the "Tentative file" and "Tentative line" fields above. If you want to update the step description, check "Modified Description" and edit the quoted text above. Then apply using `codetour-cli apply-review`.
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
#### Original Tour Step Description
|
|
517
|
+
|
|
518
|
+
{quoted_description}
|
|
519
|
+
|
|
520
|
+
**Quick Links:**
|
|
521
|
+
- [Open old location in editor]({vscode_old_url}) (`{old_file}:{old_line}`)
|
|
522
|
+
- [Open new location in editor]({vscode_new_url}) (`{new_file}:{new_line if new_line else 'N/A'}`)
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
#### Migration Details
|
|
527
|
+
|
|
528
|
+
- **Old location**: [{update.old_location}]({vscode_old_url}) (commit {source_commit[:8]})
|
|
529
|
+
- **New location**: [{update.new_location if update.new_location else "(file deleted)"}]({vscode_new_url}) (commit {target_commit[:8]})
|
|
530
|
+
- **Reason for review**: Low confidence - {update.migration_result.method} method
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
#### Code Context at Old Location (commit {source_commit[:8]})
|
|
535
|
+
|
|
536
|
+
File: [{old_file}:{old_line}]({vscode_old_url})
|
|
537
|
+
|
|
538
|
+
```
|
|
539
|
+
{old_context}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
#### Code Context at New Location (commit {target_commit[:8]})
|
|
545
|
+
|
|
546
|
+
File: [{new_file}:{new_line if new_line else 'N/A'}]({vscode_new_url})
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
{new_context}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
(Lines marked with → indicate the tour step location)
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
#### How to Review
|
|
557
|
+
|
|
558
|
+
1. **Examine the code contexts above**:
|
|
559
|
+
- Compare old location ({source_commit[:8]}) with new location ({target_commit[:8]})
|
|
560
|
+
- Does the new location match the step's original intent?
|
|
561
|
+
- Click the links above to open files in VS Code
|
|
562
|
+
|
|
563
|
+
2. **Verify in editor**:
|
|
564
|
+
- Click [Open new location in editor]({vscode_new_url})
|
|
565
|
+
- Or run: `code -g {update.new_location if update.new_location else update.old_location}`
|
|
566
|
+
|
|
567
|
+
3. **Make your decision**:
|
|
568
|
+
- **If correct**: Change `[ ]` to `[x]` in Review Status above
|
|
569
|
+
- **If wrong**: Edit the "Tentative file" and "Tentative line" fields above with correct values
|
|
570
|
+
- Then run: `codetour-cli apply-review {tour_path.relative_to(repo_root)}`
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
# Add appendix with all steps
|
|
577
|
+
report += """## Appendix: All Steps
|
|
578
|
+
|
|
579
|
+
| Step | File | Old Line | New Line | Status | Confidence |
|
|
580
|
+
|------|------|----------|----------|--------|------------|
|
|
581
|
+
"""
|
|
582
|
+
|
|
583
|
+
for i, update in enumerate(result.updates, 1):
|
|
584
|
+
old_parts = update.old_location.split(':') if update.old_location else ['', '']
|
|
585
|
+
old_file = old_parts[0]
|
|
586
|
+
old_line = old_parts[1] if len(old_parts) > 1 else ''
|
|
587
|
+
|
|
588
|
+
if update.new_location:
|
|
589
|
+
new_parts = update.new_location.split(':')
|
|
590
|
+
new_file = new_parts[0]
|
|
591
|
+
new_line = new_parts[1] if len(new_parts) > 1 else ''
|
|
592
|
+
else:
|
|
593
|
+
new_file = '(deleted)'
|
|
594
|
+
new_line = ''
|
|
595
|
+
|
|
596
|
+
if update.new_location is None:
|
|
597
|
+
status = "**Deprecated**"
|
|
598
|
+
elif update.needs_review:
|
|
599
|
+
status = "**Review**"
|
|
600
|
+
elif update.was_updated:
|
|
601
|
+
status = "Updated"
|
|
602
|
+
else:
|
|
603
|
+
status = "Unchanged"
|
|
604
|
+
|
|
605
|
+
confidence = f"{int(update.migration_result.confidence * 100)}%" if update.migration_result.confidence is not None else "N/A"
|
|
606
|
+
|
|
607
|
+
report += f"| {i} | {old_file} | {old_line} | {new_line} | {status} | {confidence} |\n"
|
|
608
|
+
|
|
609
|
+
report += "\n"
|
|
610
|
+
|
|
611
|
+
# Add configuration appendix
|
|
612
|
+
try:
|
|
613
|
+
config_path = repo_root / ".tours" / "codetour.yaml"
|
|
614
|
+
if not config_path.exists():
|
|
615
|
+
config_path = repo_root / "codetour.yaml"
|
|
616
|
+
|
|
617
|
+
if threshold is not None:
|
|
618
|
+
# The value the migration actually just ran with (ADR-0016
|
|
619
|
+
# QST-CONF-2) -- always prefer this over re-deriving from the
|
|
620
|
+
# config file, which could disagree if --threshold overrode it.
|
|
621
|
+
effective_threshold = threshold
|
|
622
|
+
else:
|
|
623
|
+
effective_threshold = 0.7 # default
|
|
624
|
+
if config_path.exists():
|
|
625
|
+
import yaml
|
|
626
|
+
with open(config_path) as f:
|
|
627
|
+
config_data = yaml.safe_load(f) or {}
|
|
628
|
+
effective_threshold = config_data.get("migration", {}).get("threshold", 0.7)
|
|
629
|
+
threshold = effective_threshold
|
|
630
|
+
|
|
631
|
+
# Build configuration appendix
|
|
632
|
+
low_conf_steps = [
|
|
633
|
+
(i+1, update) for i, update in enumerate(result.updates)
|
|
634
|
+
if update.needs_review
|
|
635
|
+
]
|
|
636
|
+
|
|
637
|
+
if low_conf_steps:
|
|
638
|
+
confidence_list = ", ".join([
|
|
639
|
+
f"{int(update.migration_result.confidence * 100)}%"
|
|
640
|
+
for _, update in low_conf_steps
|
|
641
|
+
])
|
|
642
|
+
|
|
643
|
+
report += f"""## Appendix: Why These Steps Need Review
|
|
644
|
+
|
|
645
|
+
The {len(low_conf_steps)} step{'s' if len(low_conf_steps) != 1 else ''} flagged for review ({", ".join(f"#{num}" for num, _ in low_conf_steps)}) have confidence scores below the configured threshold.
|
|
646
|
+
|
|
647
|
+
**Confidence scores**: {confidence_list}
|
|
648
|
+
**Configured threshold**: {int(threshold * 100)}% (from `{config_path.relative_to(repo_root)}`{"" if config_path.exists() else ", not created yet -- this is the default"})
|
|
649
|
+
|
|
650
|
+
Steps with confidence below {int(threshold * 100)}% require manual review because the migration algorithm is less certain about the correct new location. This typically happens when:
|
|
651
|
+
|
|
652
|
+
- Lines were modified within a changed section (hunk_interior method)
|
|
653
|
+
- Multiple similar code patterns exist
|
|
654
|
+
- Significant refactoring occurred between commits
|
|
655
|
+
|
|
656
|
+
**To adjust the threshold**, edit your configuration file at `{config_path.relative_to(repo_root)}`{"" if config_path.exists() else " (run `codetour-cli init` first to create it at the repo root)"}:
|
|
657
|
+
|
|
658
|
+
```yaml
|
|
659
|
+
migration:
|
|
660
|
+
threshold: 0.7
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
Confidence is not a continuous score -- it only ever takes one of five
|
|
664
|
+
exact values: 1.0, 0.95, 0.5, 0.4, 0.0 (see `docs/adr/0016-*.md` for what
|
|
665
|
+
produces each). A threshold anywhere in (0.5, 0.95) -- including the
|
|
666
|
+
default 0.7 -- behaves identically, since nothing the engine produces
|
|
667
|
+
falls in that range, AND steps whose location came from inside a changed
|
|
668
|
+
hunk always require review regardless of this setting (a separate,
|
|
669
|
+
unconditional check -- lowering the threshold cannot exempt those).
|
|
670
|
+
What this setting actually changes: raise it above 0.95 to also require
|
|
671
|
+
review of pattern-validated matches; lower it to 0.4 or below to stop
|
|
672
|
+
flagging pattern-mismatch cases; lower it to 0 or below to ALSO stop
|
|
673
|
+
flagging outright file deletions and removed lines (**not recommended**
|
|
674
|
+
-- these are the cases with no location to guess at all).
|
|
675
|
+
|
|
676
|
+
"""
|
|
677
|
+
except Exception as e:
|
|
678
|
+
# If we can't load config, skip the appendix
|
|
679
|
+
pass
|
|
680
|
+
|
|
681
|
+
# No embedded schema needed - frontmatter schema_version references built-in default
|
|
682
|
+
# Custom schemas can be added via end-schema when needed for internationalization
|
|
683
|
+
|
|
684
|
+
# Write report
|
|
685
|
+
report_path.write_text(report)
|
|
686
|
+
|
|
687
|
+
return report_path
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def main():
|
|
691
|
+
"""Main CLI entry point."""
|
|
692
|
+
# Structured logging was never wired in before this call -- structlog's
|
|
693
|
+
# own unconfigured default prints every level (including debug) to
|
|
694
|
+
# stdout, interleaved with this CLI's human-facing output and breaking
|
|
695
|
+
# `lint --format json`'s output purity. configure_logging()'s own
|
|
696
|
+
# default routes to stderr at INFO level, same as every other command
|
|
697
|
+
# here already assumed but never enforced.
|
|
698
|
+
configure_logging()
|
|
699
|
+
|
|
700
|
+
parser = argparse.ArgumentParser(
|
|
701
|
+
prog='codetour-cli',
|
|
702
|
+
description='Maintain CodeTours as code changes',
|
|
703
|
+
epilog='''
|
|
704
|
+
Quick Start:
|
|
705
|
+
codetour-cli status # See what needs updating (2 seconds)
|
|
706
|
+
codetour-cli migrate # Fix everything (10 seconds)
|
|
707
|
+
git diff .tours/ # Review changes
|
|
708
|
+
git add .tours/ && git commit -m "chore: update tours"
|
|
709
|
+
|
|
710
|
+
Examples:
|
|
711
|
+
codetour-cli status # Check all tours
|
|
712
|
+
codetour-cli migrate # Migrate all out-of-date tours
|
|
713
|
+
codetour-cli migrate .tours/api-guide.tour # Migrate specific tour
|
|
714
|
+
''',
|
|
715
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
|
719
|
+
|
|
720
|
+
# init command
|
|
721
|
+
subparsers.add_parser(
|
|
722
|
+
'init',
|
|
723
|
+
help='Initialize CodeTour CLI in current repository'
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# status command
|
|
727
|
+
subparsers.add_parser(
|
|
728
|
+
'status',
|
|
729
|
+
help='Show health of all tours in repository'
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# check command
|
|
733
|
+
check_parser = subparsers.add_parser(
|
|
734
|
+
'check',
|
|
735
|
+
help='Validate tours without migrating'
|
|
736
|
+
)
|
|
737
|
+
check_parser.add_argument(
|
|
738
|
+
'tour_file',
|
|
739
|
+
type=str,
|
|
740
|
+
nargs='?', # Optional argument
|
|
741
|
+
help='Path to .tour file to check (default: check all tours)'
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# lint command (ADR-0014: step-level checks against filesystem ground truth)
|
|
745
|
+
lint_parser = subparsers.add_parser(
|
|
746
|
+
'lint',
|
|
747
|
+
help='Lint tours against the workspace (missing files, stale lines, bad patterns)'
|
|
748
|
+
)
|
|
749
|
+
lint_parser.add_argument(
|
|
750
|
+
'paths',
|
|
751
|
+
type=str,
|
|
752
|
+
nargs='*',
|
|
753
|
+
help='Tour files or directories to lint (default: discover the repository tours directory)'
|
|
754
|
+
)
|
|
755
|
+
lint_parser.add_argument(
|
|
756
|
+
'--format',
|
|
757
|
+
choices=['human', 'json'],
|
|
758
|
+
default='human',
|
|
759
|
+
dest='format',
|
|
760
|
+
help='Output format (default: human)'
|
|
761
|
+
)
|
|
762
|
+
lint_parser.add_argument(
|
|
763
|
+
'--strict',
|
|
764
|
+
action='store_true',
|
|
765
|
+
dest='strict',
|
|
766
|
+
help='Exit non-zero on warnings too, not just errors'
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# undo command
|
|
770
|
+
undo_parser = subparsers.add_parser(
|
|
771
|
+
'undo',
|
|
772
|
+
help='Restore tours from backup files'
|
|
773
|
+
)
|
|
774
|
+
undo_parser.add_argument(
|
|
775
|
+
'tour_file',
|
|
776
|
+
type=str,
|
|
777
|
+
nargs='?', # Optional argument
|
|
778
|
+
help='Path to .tour file to restore (default: restore all with backups)'
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
# apply-review command
|
|
782
|
+
apply_review_parser = subparsers.add_parser(
|
|
783
|
+
'apply-review',
|
|
784
|
+
help='Apply corrections from a migration review report'
|
|
785
|
+
)
|
|
786
|
+
apply_review_parser.add_argument(
|
|
787
|
+
'report_file',
|
|
788
|
+
type=str,
|
|
789
|
+
help='Path to migration review report (.md file)'
|
|
790
|
+
)
|
|
791
|
+
apply_review_parser.add_argument(
|
|
792
|
+
'--yes', '-y',
|
|
793
|
+
action='store_true',
|
|
794
|
+
dest='auto_approve',
|
|
795
|
+
help='Automatically approve all changes (skip confirmation prompts)'
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
# migrate command
|
|
799
|
+
migrate_parser = subparsers.add_parser(
|
|
800
|
+
'migrate',
|
|
801
|
+
help='Migrate CodeTours to current HEAD'
|
|
802
|
+
)
|
|
803
|
+
migrate_parser.add_argument(
|
|
804
|
+
'tour_file',
|
|
805
|
+
type=str,
|
|
806
|
+
nargs='?', # Optional argument
|
|
807
|
+
help='Path to .tour file to migrate (default: migrate all out-of-date tours)'
|
|
808
|
+
)
|
|
809
|
+
migrate_parser.add_argument(
|
|
810
|
+
'--from-commit',
|
|
811
|
+
type=str,
|
|
812
|
+
dest='from_commit',
|
|
813
|
+
help='Override source commit detection (default: auto-detect via git log)'
|
|
814
|
+
)
|
|
815
|
+
migrate_parser.add_argument(
|
|
816
|
+
'--to-commit',
|
|
817
|
+
type=str,
|
|
818
|
+
dest='to_commit',
|
|
819
|
+
default='HEAD',
|
|
820
|
+
help='Target commit to migrate to (default: HEAD)'
|
|
821
|
+
)
|
|
822
|
+
migrate_parser.add_argument(
|
|
823
|
+
'--threshold',
|
|
824
|
+
type=float,
|
|
825
|
+
dest='threshold',
|
|
826
|
+
help=(
|
|
827
|
+
'Confidence threshold for auto-applying changes (default: from '
|
|
828
|
+
'config, 0.7). Confidence only ever takes 5 exact values -- '
|
|
829
|
+
'anything in (0.5, 0.95) behaves like the default; see '
|
|
830
|
+
'docs/adr/0016-*.md before tuning this expecting a smooth dial'
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
migrate_parser.add_argument(
|
|
834
|
+
'--no-backup',
|
|
835
|
+
action='store_true',
|
|
836
|
+
dest='no_backup',
|
|
837
|
+
help='Do not create backup files'
|
|
838
|
+
)
|
|
839
|
+
migrate_parser.add_argument(
|
|
840
|
+
'--interactive',
|
|
841
|
+
action='store_true',
|
|
842
|
+
dest='interactive',
|
|
843
|
+
help='Interactively select tours to migrate'
|
|
844
|
+
)
|
|
845
|
+
migrate_parser.add_argument(
|
|
846
|
+
'--dry-run',
|
|
847
|
+
action='store_true',
|
|
848
|
+
dest='dry_run',
|
|
849
|
+
help='Preview changes without modifying files'
|
|
850
|
+
)
|
|
851
|
+
migrate_parser.add_argument(
|
|
852
|
+
'--no-guidance',
|
|
853
|
+
action='store_true',
|
|
854
|
+
dest='no_guidance',
|
|
855
|
+
help='Suppress instructional text in review reports'
|
|
856
|
+
)
|
|
857
|
+
migrate_parser.add_argument(
|
|
858
|
+
'--no-lint',
|
|
859
|
+
action='store_true',
|
|
860
|
+
dest='no_lint',
|
|
861
|
+
help='Skip the advisory post-migration lint pass (ADR-0014)'
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
args = parser.parse_args()
|
|
865
|
+
|
|
866
|
+
# Load configuration (merge with command-line args)
|
|
867
|
+
config = load_config()
|
|
868
|
+
|
|
869
|
+
# Require a command
|
|
870
|
+
if not args.command:
|
|
871
|
+
parser.print_help()
|
|
872
|
+
sys.exit(1)
|
|
873
|
+
|
|
874
|
+
# Handle commands
|
|
875
|
+
if args.command == 'init':
|
|
876
|
+
sys.exit(init_command(args, config))
|
|
877
|
+
elif args.command == 'status':
|
|
878
|
+
sys.exit(status_command(args, config))
|
|
879
|
+
elif args.command == 'check':
|
|
880
|
+
sys.exit(check_command(args, config))
|
|
881
|
+
elif args.command == 'undo':
|
|
882
|
+
sys.exit(undo_command(args, config))
|
|
883
|
+
elif args.command == 'apply-review':
|
|
884
|
+
sys.exit(apply_review_command(args, config))
|
|
885
|
+
elif args.command == 'lint':
|
|
886
|
+
sys.exit(lint_command(args, config))
|
|
887
|
+
elif args.command == 'migrate':
|
|
888
|
+
sys.exit(migrate_command(args, config))
|
|
889
|
+
|
|
890
|
+
parser.print_help()
|
|
891
|
+
sys.exit(1)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _quiet_repo_root(start: Optional[Path] = None) -> Path:
|
|
895
|
+
"""Walk up from `start` (or cwd, if not given) to the nearest .git, silently.
|
|
896
|
+
|
|
897
|
+
Deliberately not find_repo_root(): that helper structlogs an [error] line
|
|
898
|
+
to stdout in non-git directories, which would pollute `lint --format json`
|
|
899
|
+
output (and lint should run quietly anywhere).
|
|
900
|
+
|
|
901
|
+
`start` matters whenever the path being linted isn't inside the CWD's own
|
|
902
|
+
repo -- e.g. `codetour-cli lint /some/other/repo/.tours/foo.tour` run from
|
|
903
|
+
a different directory. CWD-only derivation silently resolved every
|
|
904
|
+
`step.file` against the WRONG repo in that case (Cicerone's gate finding,
|
|
905
|
+
Mission 7). Callers that already know the real repo root (migrate, which
|
|
906
|
+
resolved one long before any lint pass runs) should pass it directly
|
|
907
|
+
rather than have this re-derive it from cwd at all.
|
|
908
|
+
"""
|
|
909
|
+
root = (start or Path.cwd()).resolve()
|
|
910
|
+
if root.is_file():
|
|
911
|
+
root = root.parent
|
|
912
|
+
for candidate in (root, *root.parents):
|
|
913
|
+
if (candidate / ".git").exists():
|
|
914
|
+
return candidate
|
|
915
|
+
return Path.cwd().resolve()
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _post_migration_lint(tour_paths, config: dict, no_lint: bool, dry_run: bool, repo_root: Path) -> None:
|
|
919
|
+
"""Warn-only lint pass over freshly migrated tours (ADR-0014 QST-MIGRATE-HOOK).
|
|
920
|
+
|
|
921
|
+
The migration engine's confidence scoring is probabilistic; this is the
|
|
922
|
+
hard check behind it. Never changes the migrate exit code, prints nothing
|
|
923
|
+
when clean, suppressed by --no-lint, skipped on dry runs (nothing was
|
|
924
|
+
written to lint).
|
|
925
|
+
|
|
926
|
+
`repo_root` is the caller's own already-resolved repo root (migrate always
|
|
927
|
+
has one by this point) -- passed through rather than re-derived from cwd,
|
|
928
|
+
so this lint pass checks the same repo migrate just touched, not whatever
|
|
929
|
+
happens to be the working directory's own repo.
|
|
930
|
+
"""
|
|
931
|
+
if no_lint or dry_run or not tour_paths:
|
|
932
|
+
return
|
|
933
|
+
try:
|
|
934
|
+
from codetour_cli.lint import lint_collection
|
|
935
|
+
|
|
936
|
+
findings = lint_collection([Path(p) for p in tour_paths], repo_root,
|
|
937
|
+
severities=(config.get('lint') or {}).get('severities') or {})
|
|
938
|
+
except Exception:
|
|
939
|
+
return # advisory pass: never let lint break a completed migration
|
|
940
|
+
if not findings:
|
|
941
|
+
return
|
|
942
|
+
errors = [f for f in findings if f.severity == "error"]
|
|
943
|
+
warns = len(findings) - len(errors)
|
|
944
|
+
print(f"\n🔎 Post-migration lint: {len(errors)} error(s), {warns} warning(s) "
|
|
945
|
+
f"(advisory — exit code unaffected; suppress with --no-lint)")
|
|
946
|
+
for f in errors[:5]:
|
|
947
|
+
loc = f" (step {f.step_index + 1})" if f.step_index is not None else ""
|
|
948
|
+
print(f" ✗ [{f.check_id}]{loc} {Path(f.tour_file).name}: {f.message}")
|
|
949
|
+
if len(errors) > 5:
|
|
950
|
+
print(f" … and {len(errors) - 5} more error(s)")
|
|
951
|
+
print(" Full details: codetour-cli lint")
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def lint_command(args, config: dict) -> int:
|
|
955
|
+
"""
|
|
956
|
+
Execute the lint command (ADR-0014).
|
|
957
|
+
|
|
958
|
+
Step-level validation of tours against filesystem ground truth — the
|
|
959
|
+
algorithmic half of the authoring duo (models write tours, this checks
|
|
960
|
+
them). Reference-resolution linting (adr:...#qst-... handles) is
|
|
961
|
+
deliberately out of scope; that belongs to the vscode crew's tour-lint.
|
|
962
|
+
|
|
963
|
+
Exit codes: 0 = clean, 1 = findings at error level (any finding with
|
|
964
|
+
--strict), 2 = usage/environment error.
|
|
965
|
+
"""
|
|
966
|
+
import json as _json
|
|
967
|
+
|
|
968
|
+
from codetour_cli.lint import has_errors, lint_collection
|
|
969
|
+
|
|
970
|
+
# Derived from the given path (a tour file or directory) upward when one
|
|
971
|
+
# is given, not cwd -- `codetour-cli lint /other/repo/.tours/x.tour` run
|
|
972
|
+
# from a different directory used to silently check `step.file` against
|
|
973
|
+
# the WRONG repo (cwd's), not the tour's own. cwd remains the fallback
|
|
974
|
+
# for the no-args "lint everything in .tours/" case, where it's correct
|
|
975
|
+
# by definition.
|
|
976
|
+
repo_root = _quiet_repo_root(Path(args.paths[0]) if args.paths else None)
|
|
977
|
+
|
|
978
|
+
if args.paths:
|
|
979
|
+
tour_paths = []
|
|
980
|
+
for raw in args.paths:
|
|
981
|
+
p = Path(raw)
|
|
982
|
+
if p.is_dir():
|
|
983
|
+
tour_paths.extend(scan_tours(p))
|
|
984
|
+
elif p.is_file():
|
|
985
|
+
tour_paths.append(p)
|
|
986
|
+
else:
|
|
987
|
+
print(f"❌ No such file or directory: {raw}")
|
|
988
|
+
return 2
|
|
989
|
+
else:
|
|
990
|
+
tours_dir = find_tours_directory(repo_root)
|
|
991
|
+
if not tours_dir:
|
|
992
|
+
print("❌ No tours directory found; pass tour files or directories explicitly")
|
|
993
|
+
return 2
|
|
994
|
+
tour_paths = scan_tours(tours_dir)
|
|
995
|
+
|
|
996
|
+
if not tour_paths:
|
|
997
|
+
print("No tour files found to lint.")
|
|
998
|
+
return 0
|
|
999
|
+
|
|
1000
|
+
severities = (config.get('lint') or {}).get('severities') or {}
|
|
1001
|
+
findings = lint_collection(tour_paths, repo_root, severities=severities)
|
|
1002
|
+
|
|
1003
|
+
if args.format == 'json':
|
|
1004
|
+
print(_json.dumps([f.to_dict() for f in findings], indent=2))
|
|
1005
|
+
else:
|
|
1006
|
+
if not findings:
|
|
1007
|
+
print(f"✓ {len(tour_paths)} tour(s) clean — no findings")
|
|
1008
|
+
else:
|
|
1009
|
+
by_file: dict = {}
|
|
1010
|
+
for f in findings:
|
|
1011
|
+
by_file.setdefault(f.tour_file, []).append(f)
|
|
1012
|
+
for tour_file, file_findings in sorted(by_file.items()):
|
|
1013
|
+
print(f"\n{tour_file}:")
|
|
1014
|
+
for f in file_findings:
|
|
1015
|
+
icon = "✗" if f.severity == "error" else "⚠"
|
|
1016
|
+
# 1-based step display, matching review reports
|
|
1017
|
+
loc = f" (step {f.step_index + 1})" if f.step_index is not None else ""
|
|
1018
|
+
print(f" {icon} [{f.check_id}]{loc} {f.message}")
|
|
1019
|
+
errors = sum(1 for f in findings if f.severity == "error")
|
|
1020
|
+
warns = len(findings) - errors
|
|
1021
|
+
print(f"\n{errors} error(s), {warns} warning(s) across {len(tour_paths)} tour(s)")
|
|
1022
|
+
|
|
1023
|
+
return 1 if has_errors(findings, strict=args.strict) else 0
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def init_command(args, config: dict) -> int:
|
|
1027
|
+
"""
|
|
1028
|
+
Execute the init command.
|
|
1029
|
+
|
|
1030
|
+
Initializes CodeTour CLI in the current repository:
|
|
1031
|
+
- Detects git repository
|
|
1032
|
+
- Finds or creates .tours/ directory
|
|
1033
|
+
- Scans for existing tours
|
|
1034
|
+
- Generates codetour.yaml configuration
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
Exit code (0 = success, 2 = error)
|
|
1038
|
+
"""
|
|
1039
|
+
print("🔍 Initializing CodeTour CLI...\n")
|
|
1040
|
+
|
|
1041
|
+
# Check if we're in a git repository
|
|
1042
|
+
try:
|
|
1043
|
+
repo_root = find_repo_root()
|
|
1044
|
+
repo = Repo(repo_root)
|
|
1045
|
+
print(f"✓ Detected git repository: {repo_root}")
|
|
1046
|
+
|
|
1047
|
+
# Show remote if available
|
|
1048
|
+
try:
|
|
1049
|
+
if repo.remotes:
|
|
1050
|
+
remote_url = repo.remotes.origin.url if 'origin' in [r.name for r in repo.remotes] else repo.remotes[0].url
|
|
1051
|
+
print(f" Remote: {remote_url}")
|
|
1052
|
+
except:
|
|
1053
|
+
pass # No remote configured, that's fine
|
|
1054
|
+
|
|
1055
|
+
except InvalidGitRepositoryError:
|
|
1056
|
+
print("❌ Not a git repository", file=sys.stderr)
|
|
1057
|
+
print(file=sys.stderr)
|
|
1058
|
+
print("CodeTour CLI requires git to track tour drift.", file=sys.stderr)
|
|
1059
|
+
print("Initialize git first:", file=sys.stderr)
|
|
1060
|
+
print(" git init", file=sys.stderr)
|
|
1061
|
+
print(" git add .", file=sys.stderr)
|
|
1062
|
+
print(' git commit -m "Initial commit"', file=sys.stderr)
|
|
1063
|
+
return 2
|
|
1064
|
+
|
|
1065
|
+
# Find or create .tours/ directory
|
|
1066
|
+
tours_dir = find_tours_directory(repo_root)
|
|
1067
|
+
|
|
1068
|
+
if tours_dir is None:
|
|
1069
|
+
# Create .tours/ directory
|
|
1070
|
+
tours_dir = repo_root / ".tours"
|
|
1071
|
+
tours_dir.mkdir(parents=True, exist_ok=True)
|
|
1072
|
+
print(f"✓ Created tours directory: {tours_dir.relative_to(repo_root)}")
|
|
1073
|
+
else:
|
|
1074
|
+
print(f"✓ Found tours directory: {tours_dir.relative_to(repo_root)}")
|
|
1075
|
+
|
|
1076
|
+
# Scan for existing tours
|
|
1077
|
+
tour_files = scan_tours(tours_dir)
|
|
1078
|
+
num_tours = len(tour_files)
|
|
1079
|
+
|
|
1080
|
+
if num_tours > 0:
|
|
1081
|
+
print(f"✓ Found {num_tours} existing tour{'s' if num_tours != 1 else ''}")
|
|
1082
|
+
for tour_file in tour_files[:5]: # Show first 5
|
|
1083
|
+
print(f" • {tour_file.name}")
|
|
1084
|
+
if num_tours > 5:
|
|
1085
|
+
print(f" ... and {num_tours - 5} more")
|
|
1086
|
+
else:
|
|
1087
|
+
print(" No existing tours found")
|
|
1088
|
+
|
|
1089
|
+
# Generate configuration file
|
|
1090
|
+
config_path = repo_root / "codetour.yaml"
|
|
1091
|
+
|
|
1092
|
+
if config_path.exists():
|
|
1093
|
+
print(f"\n⚠ Configuration file already exists: {config_path.relative_to(repo_root)}")
|
|
1094
|
+
print(" Skipping generation (delete file to regenerate)")
|
|
1095
|
+
else:
|
|
1096
|
+
generate_config_file(config_path)
|
|
1097
|
+
print(f"\n✓ Generated configuration: {config_path.relative_to(repo_root)}")
|
|
1098
|
+
|
|
1099
|
+
# Summary and next steps
|
|
1100
|
+
print("\n" + "─" * 50)
|
|
1101
|
+
print("✓ CodeTour CLI initialized successfully!")
|
|
1102
|
+
print()
|
|
1103
|
+
print("Configuration:")
|
|
1104
|
+
print(f" • Repository: {repo_root}")
|
|
1105
|
+
print(f" • Tours directory: {tours_dir.relative_to(repo_root)}")
|
|
1106
|
+
print(f" • Config file: codetour.yaml")
|
|
1107
|
+
print()
|
|
1108
|
+
print("Next steps:")
|
|
1109
|
+
if num_tours > 0:
|
|
1110
|
+
print(" codetour-cli status # Check tour health")
|
|
1111
|
+
print(" codetour-cli migrate # Update out-of-date tours")
|
|
1112
|
+
else:
|
|
1113
|
+
print(" # Create tours using VS Code CodeTour extension")
|
|
1114
|
+
print(" # Or add .tour files to .tours/ directory")
|
|
1115
|
+
print()
|
|
1116
|
+
print(" codetour-cli status # Check tours when ready")
|
|
1117
|
+
|
|
1118
|
+
return 0
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def status_command(args, config: dict) -> int:
|
|
1122
|
+
"""
|
|
1123
|
+
Execute the status command.
|
|
1124
|
+
|
|
1125
|
+
Shows health of all tours in the repository.
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
Exit code (0 = all up to date, 1 = some need updates, 2 = error)
|
|
1129
|
+
"""
|
|
1130
|
+
# Warn if no config file
|
|
1131
|
+
try:
|
|
1132
|
+
repo_root = find_repo_root()
|
|
1133
|
+
config_path = repo_root / "codetour.yaml"
|
|
1134
|
+
if not config_path.exists():
|
|
1135
|
+
print("ℹ️ No configuration file found. Run 'codetour-cli init' to create one.")
|
|
1136
|
+
print()
|
|
1137
|
+
except:
|
|
1138
|
+
pass # If we can't find repo, discover_repository will fail below
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
# Discover repository and tours
|
|
1142
|
+
repo_info = discover_repository()
|
|
1143
|
+
except (InvalidGitRepositoryError, ValueError) as e:
|
|
1144
|
+
print(f"❌ {e}", file=sys.stderr)
|
|
1145
|
+
return 2
|
|
1146
|
+
|
|
1147
|
+
# Display summary header
|
|
1148
|
+
num_tours = len(repo_info.tours)
|
|
1149
|
+
if num_tours == 0:
|
|
1150
|
+
print(f"📊 No tours found in {repo_info.tours_dir}")
|
|
1151
|
+
print()
|
|
1152
|
+
print("Create a tour file in .tours/ or run: codetour init")
|
|
1153
|
+
return 2
|
|
1154
|
+
|
|
1155
|
+
print(f"📊 Found {num_tours} tour{'s' if num_tours != 1 else ''} in {repo_info.tours_dir.relative_to(repo_info.repo_path)}")
|
|
1156
|
+
print()
|
|
1157
|
+
|
|
1158
|
+
# Display each tour
|
|
1159
|
+
num_up_to_date = 0
|
|
1160
|
+
num_need_update = 0
|
|
1161
|
+
|
|
1162
|
+
for tour_info in repo_info.tours:
|
|
1163
|
+
tour_filename = tour_info.path.name
|
|
1164
|
+
up_to_date = is_tour_up_to_date(tour_info)
|
|
1165
|
+
|
|
1166
|
+
if up_to_date:
|
|
1167
|
+
num_up_to_date += 1
|
|
1168
|
+
status_icon = "✅"
|
|
1169
|
+
else:
|
|
1170
|
+
num_need_update += 1
|
|
1171
|
+
status_icon = "⚠️ "
|
|
1172
|
+
|
|
1173
|
+
# Tour header
|
|
1174
|
+
print(f" {status_icon} {tour_filename}")
|
|
1175
|
+
print(f" Title: \"{tour_info.tour.title}\"")
|
|
1176
|
+
print(f" Steps: {len(tour_info.tour.steps)}")
|
|
1177
|
+
|
|
1178
|
+
# Format timestamp
|
|
1179
|
+
time_ago = format_relative_time(tour_info.last_modified) if tour_info.last_modified else "unknown"
|
|
1180
|
+
print(f" Last updated: {tour_info.source_commit[:8]} ({time_ago}, {tour_info.source_method}, {tour_info.source_confidence:.0%} confidence)")
|
|
1181
|
+
|
|
1182
|
+
# Show code change timestamp
|
|
1183
|
+
try:
|
|
1184
|
+
from git import Repo
|
|
1185
|
+
repo = Repo(repo_info.repo_path)
|
|
1186
|
+
head_commit = repo.head.commit
|
|
1187
|
+
head_time_ago = format_relative_time(head_commit.committed_datetime)
|
|
1188
|
+
print(f" Code changed: {tour_info.commits_behind} commit{'s' if tour_info.commits_behind != 1 else ''} behind HEAD (last change {head_time_ago})")
|
|
1189
|
+
except:
|
|
1190
|
+
print(f" Code changed: {tour_info.commits_behind} commit{'s' if tour_info.commits_behind != 1 else ''} behind HEAD")
|
|
1191
|
+
|
|
1192
|
+
if not up_to_date:
|
|
1193
|
+
print(f" Action: codetour-cli migrate {tour_info.path.relative_to(repo_info.repo_path)}")
|
|
1194
|
+
|
|
1195
|
+
print()
|
|
1196
|
+
|
|
1197
|
+
# Summary
|
|
1198
|
+
print(f"Summary: {num_up_to_date} up to date, {num_need_update} need{'s' if num_need_update == 1 else ''} update")
|
|
1199
|
+
|
|
1200
|
+
# Exit code
|
|
1201
|
+
if num_need_update > 0:
|
|
1202
|
+
return 1 # Some tours need updates
|
|
1203
|
+
else:
|
|
1204
|
+
return 0 # All up to date
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def check_command(args, config: dict) -> int:
|
|
1208
|
+
"""
|
|
1209
|
+
Execute the check command.
|
|
1210
|
+
|
|
1211
|
+
Validates tours without migrating them.
|
|
1212
|
+
|
|
1213
|
+
Returns:
|
|
1214
|
+
Exit code (0 = all valid, 1 = issues found, 2 = error)
|
|
1215
|
+
"""
|
|
1216
|
+
try:
|
|
1217
|
+
repo_root = find_repo_root()
|
|
1218
|
+
tours_dir = find_tours_directory(repo_root)
|
|
1219
|
+
|
|
1220
|
+
if tours_dir is None:
|
|
1221
|
+
print("❌ No .tours directory found", file=sys.stderr)
|
|
1222
|
+
return 2
|
|
1223
|
+
|
|
1224
|
+
except (InvalidGitRepositoryError, ValueError) as e:
|
|
1225
|
+
print(f"❌ {e}", file=sys.stderr)
|
|
1226
|
+
return 2
|
|
1227
|
+
|
|
1228
|
+
# Load specific tour or all tours
|
|
1229
|
+
if args.tour_file:
|
|
1230
|
+
tour_path = Path(args.tour_file)
|
|
1231
|
+
if not tour_path.exists():
|
|
1232
|
+
print(f"❌ Tour file not found: {tour_path}", file=sys.stderr)
|
|
1233
|
+
return 2
|
|
1234
|
+
|
|
1235
|
+
try:
|
|
1236
|
+
tour = Tour.from_file(tour_path)
|
|
1237
|
+
tours_to_check = [(tour_path, tour)]
|
|
1238
|
+
except Exception as e:
|
|
1239
|
+
print(f"❌ Failed to load tour: {e}", file=sys.stderr)
|
|
1240
|
+
return 2
|
|
1241
|
+
else:
|
|
1242
|
+
# Check all tours
|
|
1243
|
+
try:
|
|
1244
|
+
tours_to_check = []
|
|
1245
|
+
for tour_file in tours_dir.glob("*.tour"):
|
|
1246
|
+
try:
|
|
1247
|
+
tour = Tour.from_file(tour_file)
|
|
1248
|
+
tours_to_check.append((tour_file, tour))
|
|
1249
|
+
except Exception as e:
|
|
1250
|
+
print(f"⚠️ Skipping invalid tour {tour_file.name}: {e}")
|
|
1251
|
+
except Exception as e:
|
|
1252
|
+
print(f"❌ Failed to scan tours: {e}", file=sys.stderr)
|
|
1253
|
+
return 2
|
|
1254
|
+
|
|
1255
|
+
if not tours_to_check:
|
|
1256
|
+
print("ℹ️ No tours found to check")
|
|
1257
|
+
return 0
|
|
1258
|
+
|
|
1259
|
+
print(f"🔍 Checking {len(tours_to_check)} tour{'s' if len(tours_to_check) != 1 else ''}...\n")
|
|
1260
|
+
|
|
1261
|
+
total_steps = 0
|
|
1262
|
+
total_valid = 0
|
|
1263
|
+
total_invalid_file = 0
|
|
1264
|
+
total_invalid_line = 0
|
|
1265
|
+
tours_with_issues = []
|
|
1266
|
+
|
|
1267
|
+
for tour_path, tour in tours_to_check:
|
|
1268
|
+
tour_name = tour_path.name
|
|
1269
|
+
step_issues = []
|
|
1270
|
+
|
|
1271
|
+
for i, step in enumerate(tour.steps, 1):
|
|
1272
|
+
total_steps += 1
|
|
1273
|
+
|
|
1274
|
+
# Skip steps without file reference
|
|
1275
|
+
if not step.file:
|
|
1276
|
+
total_valid += 1
|
|
1277
|
+
continue
|
|
1278
|
+
|
|
1279
|
+
# Check if file exists
|
|
1280
|
+
file_path = repo_root / step.file
|
|
1281
|
+
if not file_path.exists():
|
|
1282
|
+
total_invalid_file += 1
|
|
1283
|
+
step_issues.append((i, f"File not found: {step.file}"))
|
|
1284
|
+
continue
|
|
1285
|
+
|
|
1286
|
+
# Check if line number is valid
|
|
1287
|
+
if step.line is not None:
|
|
1288
|
+
try:
|
|
1289
|
+
with open(file_path, 'r') as f:
|
|
1290
|
+
lines = f.readlines()
|
|
1291
|
+
if step.line < 1 or step.line > len(lines):
|
|
1292
|
+
total_invalid_line += 1
|
|
1293
|
+
step_issues.append((i, f"Line {step.line} out of bounds (file has {len(lines)} lines)"))
|
|
1294
|
+
continue
|
|
1295
|
+
except Exception as e:
|
|
1296
|
+
step_issues.append((i, f"Failed to read file: {e}"))
|
|
1297
|
+
total_invalid_file += 1
|
|
1298
|
+
continue
|
|
1299
|
+
|
|
1300
|
+
total_valid += 1
|
|
1301
|
+
|
|
1302
|
+
# Report issues for this tour
|
|
1303
|
+
if step_issues:
|
|
1304
|
+
tours_with_issues.append(tour_name)
|
|
1305
|
+
print(f"⚠️ {tour_name}:")
|
|
1306
|
+
for step_num, issue in step_issues:
|
|
1307
|
+
print(f" Step {step_num}: {issue}")
|
|
1308
|
+
print()
|
|
1309
|
+
else:
|
|
1310
|
+
print(f"✓ {tour_name}: All {len(tour.steps)} steps valid")
|
|
1311
|
+
|
|
1312
|
+
# Summary
|
|
1313
|
+
print()
|
|
1314
|
+
if tours_with_issues:
|
|
1315
|
+
print(f"Summary: {total_valid}/{total_steps} steps valid")
|
|
1316
|
+
print(f" ⚠️ {total_invalid_file} steps reference missing files")
|
|
1317
|
+
print(f" ⚠️ {total_invalid_line} steps reference invalid line numbers")
|
|
1318
|
+
print(f" ⚠️ {len(tours_with_issues)} tour{'s' if len(tours_with_issues) != 1 else ''} with issues")
|
|
1319
|
+
print()
|
|
1320
|
+
print("Run 'codetour-cli migrate' to update tours")
|
|
1321
|
+
return 1
|
|
1322
|
+
else:
|
|
1323
|
+
print(f"✓ All {total_steps} steps valid across {len(tours_to_check)} tour{'s' if len(tours_to_check) != 1 else ''}")
|
|
1324
|
+
return 0
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def undo_command(args, config: dict) -> int:
|
|
1328
|
+
"""
|
|
1329
|
+
Execute the undo command.
|
|
1330
|
+
|
|
1331
|
+
Restores tours from backup files.
|
|
1332
|
+
|
|
1333
|
+
Returns:
|
|
1334
|
+
Exit code (0 = success, 2 = error)
|
|
1335
|
+
"""
|
|
1336
|
+
try:
|
|
1337
|
+
repo_root = find_repo_root()
|
|
1338
|
+
tours_dir = find_tours_directory(repo_root)
|
|
1339
|
+
|
|
1340
|
+
if tours_dir is None:
|
|
1341
|
+
print("❌ No .tours directory found", file=sys.stderr)
|
|
1342
|
+
return 2
|
|
1343
|
+
|
|
1344
|
+
except (InvalidGitRepositoryError, ValueError) as e:
|
|
1345
|
+
print(f"❌ {e}", file=sys.stderr)
|
|
1346
|
+
return 2
|
|
1347
|
+
|
|
1348
|
+
# If specific file provided, restore just that one
|
|
1349
|
+
if args.tour_file:
|
|
1350
|
+
tour_path = Path(args.tour_file)
|
|
1351
|
+
backup_path = tour_path.with_suffix('.tour.backup')
|
|
1352
|
+
|
|
1353
|
+
if not backup_path.exists():
|
|
1354
|
+
print(f"❌ No backup found for {tour_path.name}", file=sys.stderr)
|
|
1355
|
+
print(f" Expected: {backup_path}", file=sys.stderr)
|
|
1356
|
+
return 2
|
|
1357
|
+
|
|
1358
|
+
# Restore from backup
|
|
1359
|
+
import shutil
|
|
1360
|
+
shutil.copy2(backup_path, tour_path)
|
|
1361
|
+
backup_path.unlink() # Remove backup after restoring
|
|
1362
|
+
|
|
1363
|
+
print(f"✓ Restored {tour_path.name} from backup")
|
|
1364
|
+
print(f" Backup file removed: {backup_path.name}")
|
|
1365
|
+
return 0
|
|
1366
|
+
|
|
1367
|
+
# Otherwise, restore all tours with backups
|
|
1368
|
+
backup_files = list(tours_dir.glob("*.tour.backup"))
|
|
1369
|
+
|
|
1370
|
+
if not backup_files:
|
|
1371
|
+
print("ℹ️ No backup files found in .tours/")
|
|
1372
|
+
return 0
|
|
1373
|
+
|
|
1374
|
+
print(f"📁 Found {len(backup_files)} backup file{'s' if len(backup_files) != 1 else ''}\n")
|
|
1375
|
+
|
|
1376
|
+
restored = []
|
|
1377
|
+
for backup_path in backup_files:
|
|
1378
|
+
# Remove .backup suffix to get .tour file
|
|
1379
|
+
tour_path = backup_path.with_suffix('')
|
|
1380
|
+
tour_name = tour_path.name
|
|
1381
|
+
|
|
1382
|
+
try:
|
|
1383
|
+
import shutil
|
|
1384
|
+
shutil.copy2(backup_path, tour_path)
|
|
1385
|
+
backup_path.unlink()
|
|
1386
|
+
restored.append(tour_name)
|
|
1387
|
+
print(f"✓ Restored {tour_name}")
|
|
1388
|
+
except Exception as e:
|
|
1389
|
+
print(f"❌ Failed to restore {tour_name}: {e}")
|
|
1390
|
+
|
|
1391
|
+
print()
|
|
1392
|
+
print(f"✓ Restored {len(restored)} tour{'s' if len(restored) != 1 else ''} from backup")
|
|
1393
|
+
print(" All backup files have been removed")
|
|
1394
|
+
|
|
1395
|
+
return 0
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def apply_review_command(args, config: dict) -> int:
|
|
1399
|
+
"""
|
|
1400
|
+
Execute the apply-review command.
|
|
1401
|
+
|
|
1402
|
+
Parses a migration review report using schema-based parsing and applies
|
|
1403
|
+
the corrected locations and descriptions to the tour file.
|
|
1404
|
+
|
|
1405
|
+
Returns:
|
|
1406
|
+
Exit code (0 = success, 2 = error)
|
|
1407
|
+
"""
|
|
1408
|
+
from codetour_cli.review_parser import parse_review_report
|
|
1409
|
+
from codetour_cli.description_validator import (
|
|
1410
|
+
validate_edited_description,
|
|
1411
|
+
format_validation_message
|
|
1412
|
+
)
|
|
1413
|
+
import difflib
|
|
1414
|
+
|
|
1415
|
+
report_path = Path(args.report_file)
|
|
1416
|
+
auto_approve = getattr(args, 'auto_approve', False)
|
|
1417
|
+
|
|
1418
|
+
if not report_path.exists():
|
|
1419
|
+
print(f"❌ Review report not found: {report_path}", file=sys.stderr)
|
|
1420
|
+
return 2
|
|
1421
|
+
|
|
1422
|
+
try:
|
|
1423
|
+
repo_root = find_repo_root()
|
|
1424
|
+
except (InvalidGitRepositoryError, ValueError) as e:
|
|
1425
|
+
print(f"❌ {e}", file=sys.stderr)
|
|
1426
|
+
return 2
|
|
1427
|
+
|
|
1428
|
+
# Parse review report using schema-based parser
|
|
1429
|
+
try:
|
|
1430
|
+
review = parse_review_report(report_path)
|
|
1431
|
+
except Exception as e:
|
|
1432
|
+
print(f"❌ Failed to parse review report: {e}", file=sys.stderr)
|
|
1433
|
+
log.error("parse_error", error=str(e), path=str(report_path))
|
|
1434
|
+
return 2
|
|
1435
|
+
|
|
1436
|
+
# Find tour file
|
|
1437
|
+
tours_dir = find_tours_directory(repo_root)
|
|
1438
|
+
if tours_dir is None:
|
|
1439
|
+
print("❌ No .tours directory found", file=sys.stderr)
|
|
1440
|
+
return 2
|
|
1441
|
+
|
|
1442
|
+
tour_path = tours_dir / review.tour_name
|
|
1443
|
+
if not tour_path.exists():
|
|
1444
|
+
print(f"❌ Tour file not found: {tour_path}", file=sys.stderr)
|
|
1445
|
+
return 2
|
|
1446
|
+
|
|
1447
|
+
# Load tour file
|
|
1448
|
+
try:
|
|
1449
|
+
tour = Tour.from_file(tour_path)
|
|
1450
|
+
except Exception as e:
|
|
1451
|
+
print(f"❌ Failed to load tour: {e}", file=sys.stderr)
|
|
1452
|
+
return 2
|
|
1453
|
+
|
|
1454
|
+
# Get only reviewed steps (checkbox checked)
|
|
1455
|
+
corrected_steps = review.get_corrected_steps()
|
|
1456
|
+
|
|
1457
|
+
if not corrected_steps:
|
|
1458
|
+
print("ℹ️ No corrections found in review report")
|
|
1459
|
+
print(" The report may not have been edited, or no checkboxes were marked")
|
|
1460
|
+
print(" Tip: Check the 'Status' checkbox for steps you want to apply")
|
|
1461
|
+
return 0
|
|
1462
|
+
|
|
1463
|
+
# Apply corrections
|
|
1464
|
+
print(f"📝 Applying corrections from review report\n")
|
|
1465
|
+
print(f"Tour: {review.tour_name}")
|
|
1466
|
+
print(f"Corrections to apply: {len(corrected_steps)}\n")
|
|
1467
|
+
|
|
1468
|
+
# Separate deletions from updates
|
|
1469
|
+
deletions = [s for s in corrected_steps if s.is_marked_for_deletion()]
|
|
1470
|
+
updates = [s for s in corrected_steps if not s.is_marked_for_deletion()]
|
|
1471
|
+
|
|
1472
|
+
# Apply updates first
|
|
1473
|
+
applied_updates = 0
|
|
1474
|
+
applied_descriptions = 0
|
|
1475
|
+
|
|
1476
|
+
# Surgical-edit operations, tracked in parallel with the Pydantic-model
|
|
1477
|
+
# mutations below (ADR-0013 Phase 2b) -- one entry per mutation that
|
|
1478
|
+
# actually gets applied, using ORIGINAL step indices as json_source_edit
|
|
1479
|
+
# paths (Round 4 §1's coordinate-frame contract: these never need to
|
|
1480
|
+
# account for other operations in the batch, regardless of order).
|
|
1481
|
+
# Falls back to the existing full-rewrite `tour.save()` below if surgical
|
|
1482
|
+
# editing raises or fails validation for any reason.
|
|
1483
|
+
surgical_ops: list = []
|
|
1484
|
+
|
|
1485
|
+
for step_review in updates:
|
|
1486
|
+
step_idx = step_review.step_number - 1
|
|
1487
|
+
|
|
1488
|
+
if step_idx < 0 or step_idx >= len(tour.steps):
|
|
1489
|
+
print(f"⚠️ Step {step_review.step_number}: Out of range (tour has {len(tour.steps)} steps), skipping")
|
|
1490
|
+
continue
|
|
1491
|
+
|
|
1492
|
+
step = tour.steps[step_idx]
|
|
1493
|
+
old_file = step.file
|
|
1494
|
+
old_line = step.line
|
|
1495
|
+
|
|
1496
|
+
# Update location
|
|
1497
|
+
step.file = step_review.tentative_file
|
|
1498
|
+
step.line = step_review.tentative_line
|
|
1499
|
+
surgical_ops.append(("replace", f"/steps/{step_idx}/file", step_review.tentative_file))
|
|
1500
|
+
surgical_ops.append(("replace", f"/steps/{step_idx}/line", step_review.tentative_line))
|
|
1501
|
+
|
|
1502
|
+
print(f" ✓ Step {step_review.step_number}: {old_file}:{old_line} → {step_review.tentative_file}:{step_review.tentative_line}")
|
|
1503
|
+
applied_updates += 1
|
|
1504
|
+
|
|
1505
|
+
# Update description if edited
|
|
1506
|
+
if step_review.description_edited and step_review.edited_description:
|
|
1507
|
+
old_description = step.description
|
|
1508
|
+
new_description = step_review.edited_description.strip()
|
|
1509
|
+
|
|
1510
|
+
# Validate edited description
|
|
1511
|
+
validation = validate_edited_description(new_description, old_description)
|
|
1512
|
+
|
|
1513
|
+
# Log info-level metrics
|
|
1514
|
+
for info_msg in validation.info:
|
|
1515
|
+
log.info("description_edit_info", step=step_review.step_number, message=info_msg)
|
|
1516
|
+
|
|
1517
|
+
# Check for hard errors
|
|
1518
|
+
if not validation.is_valid:
|
|
1519
|
+
print(format_validation_message(step_review.step_number, validation))
|
|
1520
|
+
print(f" Skipped description edit for step {step_review.step_number}")
|
|
1521
|
+
log.warning(
|
|
1522
|
+
"description_edit_failed_validation",
|
|
1523
|
+
step=step_review.step_number,
|
|
1524
|
+
errors=validation.errors
|
|
1525
|
+
)
|
|
1526
|
+
continue # Skip this description edit
|
|
1527
|
+
|
|
1528
|
+
# Check for warnings
|
|
1529
|
+
if validation.warnings and not auto_approve:
|
|
1530
|
+
print(format_validation_message(step_review.step_number, validation))
|
|
1531
|
+
print()
|
|
1532
|
+
response = input(f" Continue with this change? [y/N]: ")
|
|
1533
|
+
if response.lower() != 'y':
|
|
1534
|
+
print(f" Skipped description edit for step {step_review.step_number}")
|
|
1535
|
+
log.info("description_edit_declined", step=step_review.step_number)
|
|
1536
|
+
continue # Skip this description edit
|
|
1537
|
+
|
|
1538
|
+
# Apply the description update
|
|
1539
|
+
if old_description != new_description:
|
|
1540
|
+
step.description = new_description
|
|
1541
|
+
surgical_ops.append(("replace", f"/steps/{step_idx}/description", new_description))
|
|
1542
|
+
|
|
1543
|
+
# Show diff
|
|
1544
|
+
if validation.warnings and auto_approve:
|
|
1545
|
+
print(f" ⚠️ Step {step_review.step_number}: Description changed significantly")
|
|
1546
|
+
print(f" ({validation.similarity:.0%} similarity, applied anyway with --yes)")
|
|
1547
|
+
else:
|
|
1548
|
+
print(f" 📝 Description updated ({validation.similarity:.0%} similarity)")
|
|
1549
|
+
|
|
1550
|
+
# Show diff details
|
|
1551
|
+
diff = difflib.unified_diff(
|
|
1552
|
+
old_description.splitlines(keepends=True),
|
|
1553
|
+
new_description.splitlines(keepends=True),
|
|
1554
|
+
lineterm='',
|
|
1555
|
+
n=1
|
|
1556
|
+
)
|
|
1557
|
+
for line in diff:
|
|
1558
|
+
if line.startswith('+++') or line.startswith('---'):
|
|
1559
|
+
continue
|
|
1560
|
+
if line.startswith('+'):
|
|
1561
|
+
print(f" {line}")
|
|
1562
|
+
elif line.startswith('-'):
|
|
1563
|
+
print(f" {line}")
|
|
1564
|
+
|
|
1565
|
+
applied_descriptions += 1
|
|
1566
|
+
|
|
1567
|
+
log.info(
|
|
1568
|
+
"description_updated",
|
|
1569
|
+
step=step_review.step_number,
|
|
1570
|
+
old_length=len(old_description),
|
|
1571
|
+
new_length=len(new_description),
|
|
1572
|
+
similarity=validation.similarity
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
# Apply deletions in reverse order to avoid index shifting
|
|
1576
|
+
applied_deletions = 0
|
|
1577
|
+
for step_review in sorted(deletions, key=lambda s: s.step_number, reverse=True):
|
|
1578
|
+
step_idx = step_review.step_number - 1
|
|
1579
|
+
|
|
1580
|
+
if step_idx < 0 or step_idx >= len(tour.steps):
|
|
1581
|
+
print(f"⚠️ Step {step_review.step_number}: Out of range (tour has {len(tour.steps)} steps), skipping")
|
|
1582
|
+
continue
|
|
1583
|
+
|
|
1584
|
+
step = tour.steps[step_idx]
|
|
1585
|
+
print(f" 🗑️ Step {step_review.step_number}: {step.file}:{step.line} (marked for deletion)")
|
|
1586
|
+
|
|
1587
|
+
# Remove step from tour
|
|
1588
|
+
tour.steps.pop(step_idx)
|
|
1589
|
+
surgical_ops.append(("remove", f"/steps/{step_idx}"))
|
|
1590
|
+
applied_deletions += 1
|
|
1591
|
+
|
|
1592
|
+
applied = applied_updates + applied_deletions
|
|
1593
|
+
|
|
1594
|
+
if applied == 0:
|
|
1595
|
+
print("\nℹ️ No corrections applied")
|
|
1596
|
+
return 0
|
|
1597
|
+
|
|
1598
|
+
# Save tour file
|
|
1599
|
+
try:
|
|
1600
|
+
# Create backup first
|
|
1601
|
+
backup_path = tour_path.with_suffix('.tour.backup')
|
|
1602
|
+
if not backup_path.exists(): # Don't overwrite existing backups
|
|
1603
|
+
import shutil
|
|
1604
|
+
shutil.copy2(tour_path, backup_path)
|
|
1605
|
+
print(f"\n 📁 Created backup: {backup_path.name}")
|
|
1606
|
+
|
|
1607
|
+
# ADR-0013 Phase 2b: try surgical (byte-preserving) editing first, on
|
|
1608
|
+
# the ORIGINAL on-disk bytes -- tour_path hasn't been written yet at
|
|
1609
|
+
# this point, so JSONEditor.from_file() reads the pristine original,
|
|
1610
|
+
# not the mutated Pydantic model. Falls back to the canonical
|
|
1611
|
+
# Pydantic rewrite (unchanged, below) on ANY failure -- a raised
|
|
1612
|
+
# exception during recording, a compile-time error (e.g. an
|
|
1613
|
+
# object-property Remove, which never happens here since every
|
|
1614
|
+
# surgical_ops path is an array-element path), or a semantic
|
|
1615
|
+
# validation failure (R3-7c).
|
|
1616
|
+
surgical_succeeded = False
|
|
1617
|
+
if surgical_ops:
|
|
1618
|
+
try:
|
|
1619
|
+
from json_source_edit import JSONEditor
|
|
1620
|
+
|
|
1621
|
+
editor = JSONEditor.from_file(tour_path)
|
|
1622
|
+
for op in surgical_ops:
|
|
1623
|
+
if op[0] == "replace":
|
|
1624
|
+
editor.replace(op[1], op[2])
|
|
1625
|
+
else:
|
|
1626
|
+
editor.remove(op[1])
|
|
1627
|
+
surgical_result = editor.save(tour_path, validate=True)
|
|
1628
|
+
surgical_succeeded = surgical_result["success"]
|
|
1629
|
+
if surgical_succeeded:
|
|
1630
|
+
log.info(
|
|
1631
|
+
"surgical_edit_succeeded",
|
|
1632
|
+
modifications=len(surgical_ops),
|
|
1633
|
+
tour=review.tour_name,
|
|
1634
|
+
)
|
|
1635
|
+
else:
|
|
1636
|
+
log.warning(
|
|
1637
|
+
"surgical_edit_failed",
|
|
1638
|
+
error=surgical_result.get("error"),
|
|
1639
|
+
fallback="full_rewrite",
|
|
1640
|
+
tour=review.tour_name,
|
|
1641
|
+
)
|
|
1642
|
+
except Exception as e: # noqa: BLE001 -- deliberately broad: any
|
|
1643
|
+
# surgical-path failure falls back to the full rewrite below,
|
|
1644
|
+
# per the ADR's own fallback strategy.
|
|
1645
|
+
log.warning(
|
|
1646
|
+
"surgical_edit_failed",
|
|
1647
|
+
error=str(e),
|
|
1648
|
+
fallback="full_rewrite",
|
|
1649
|
+
tour=review.tour_name,
|
|
1650
|
+
)
|
|
1651
|
+
surgical_succeeded = False
|
|
1652
|
+
|
|
1653
|
+
if not surgical_succeeded:
|
|
1654
|
+
tour.save(tour_path)
|
|
1655
|
+
|
|
1656
|
+
# Build summary message
|
|
1657
|
+
summary_parts = []
|
|
1658
|
+
if applied_updates > 0:
|
|
1659
|
+
summary_parts.append(f"{applied_updates} location update{'s' if applied_updates != 1 else ''}")
|
|
1660
|
+
if applied_descriptions > 0:
|
|
1661
|
+
summary_parts.append(f"{applied_descriptions} description edit{'s' if applied_descriptions != 1 else ''}")
|
|
1662
|
+
if applied_deletions > 0:
|
|
1663
|
+
summary_parts.append(f"{applied_deletions} deletion{'s' if applied_deletions != 1 else ''}")
|
|
1664
|
+
|
|
1665
|
+
summary = ", ".join(summary_parts)
|
|
1666
|
+
print(f"\n✓ Applied {summary} to {review.tour_name}")
|
|
1667
|
+
|
|
1668
|
+
print("\nNext steps:")
|
|
1669
|
+
print(" git diff .tours/")
|
|
1670
|
+
print(" git add .tours/")
|
|
1671
|
+
print(' git commit -m "chore: apply migration review corrections"')
|
|
1672
|
+
|
|
1673
|
+
return 0
|
|
1674
|
+
|
|
1675
|
+
except Exception as e:
|
|
1676
|
+
print(f"❌ Failed to save tour: {e}", file=sys.stderr)
|
|
1677
|
+
return 2
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def interactive_select_tours(tour_infos: list) -> list:
|
|
1681
|
+
"""
|
|
1682
|
+
Interactive menu to select tours for migration.
|
|
1683
|
+
|
|
1684
|
+
Args:
|
|
1685
|
+
tour_infos: List of TourInfo objects that need migration
|
|
1686
|
+
|
|
1687
|
+
Returns:
|
|
1688
|
+
List of selected TourInfo objects, or empty list if user cancels
|
|
1689
|
+
"""
|
|
1690
|
+
print("📊 Which tours do you want to migrate?\n")
|
|
1691
|
+
|
|
1692
|
+
# Show numbered list
|
|
1693
|
+
for i, tour_info in enumerate(tour_infos, 1):
|
|
1694
|
+
tour_name = tour_info.path.name
|
|
1695
|
+
commits_behind = tour_info.commits_behind
|
|
1696
|
+
print(f" [{i}] {tour_name} ({commits_behind} commit{'s' if commits_behind != 1 else ''} behind)")
|
|
1697
|
+
|
|
1698
|
+
print(f" [a] All tours")
|
|
1699
|
+
print(f" [q] Quit\n")
|
|
1700
|
+
|
|
1701
|
+
# Get user input
|
|
1702
|
+
while True:
|
|
1703
|
+
try:
|
|
1704
|
+
choice = input("Enter number(s), 'a' for all, or 'q' to quit: ").strip().lower()
|
|
1705
|
+
|
|
1706
|
+
if choice == 'q':
|
|
1707
|
+
return []
|
|
1708
|
+
|
|
1709
|
+
if choice == 'a':
|
|
1710
|
+
return tour_infos
|
|
1711
|
+
|
|
1712
|
+
# Parse comma-separated numbers
|
|
1713
|
+
indices = []
|
|
1714
|
+
for part in choice.split(','):
|
|
1715
|
+
part = part.strip()
|
|
1716
|
+
if part.isdigit():
|
|
1717
|
+
idx = int(part) - 1
|
|
1718
|
+
if 0 <= idx < len(tour_infos):
|
|
1719
|
+
indices.append(idx)
|
|
1720
|
+
else:
|
|
1721
|
+
print(f"Invalid number: {part}")
|
|
1722
|
+
break
|
|
1723
|
+
else:
|
|
1724
|
+
print(f"Invalid input: {part}")
|
|
1725
|
+
break
|
|
1726
|
+
else:
|
|
1727
|
+
# All parts valid
|
|
1728
|
+
if indices:
|
|
1729
|
+
return [tour_infos[i] for i in indices]
|
|
1730
|
+
print("No valid tours selected. Try again.")
|
|
1731
|
+
except (EOFError, KeyboardInterrupt):
|
|
1732
|
+
print("\nCancelled.")
|
|
1733
|
+
return []
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def migrate_command(args, config: dict) -> int:
|
|
1737
|
+
"""
|
|
1738
|
+
Execute the migrate command.
|
|
1739
|
+
|
|
1740
|
+
Migrates either a specific tour (if tour_file provided) or all out-of-date
|
|
1741
|
+
tours (if no tour_file).
|
|
1742
|
+
|
|
1743
|
+
Returns:
|
|
1744
|
+
Exit code (0 = success, 1 = needs review, 2 = error)
|
|
1745
|
+
"""
|
|
1746
|
+
# If no tour file specified, migrate all out-of-date tours
|
|
1747
|
+
if args.tour_file is None:
|
|
1748
|
+
return migrate_all_tours(args, config)
|
|
1749
|
+
|
|
1750
|
+
# Migrate specific tour
|
|
1751
|
+
return migrate_single_tour(args, config)
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def migrate_all_tours(args, config: dict) -> int:
|
|
1755
|
+
"""
|
|
1756
|
+
Migrate all out-of-date tours in the repository.
|
|
1757
|
+
|
|
1758
|
+
Returns:
|
|
1759
|
+
Exit code (0 = success, 1 = needs review, 2 = error)
|
|
1760
|
+
"""
|
|
1761
|
+
# Warn if no config file
|
|
1762
|
+
try:
|
|
1763
|
+
repo_root = find_repo_root()
|
|
1764
|
+
config_path = repo_root / "codetour.yaml"
|
|
1765
|
+
if not config_path.exists():
|
|
1766
|
+
print("ℹ️ No configuration file found. Run 'codetour-cli init' to create one.")
|
|
1767
|
+
print()
|
|
1768
|
+
except:
|
|
1769
|
+
pass # If we can't find repo, discover_repository will fail below
|
|
1770
|
+
|
|
1771
|
+
try:
|
|
1772
|
+
# Discover tours
|
|
1773
|
+
repo_info = discover_repository()
|
|
1774
|
+
except (InvalidGitRepositoryError, ValueError) as e:
|
|
1775
|
+
print(f"❌ {e}", file=sys.stderr)
|
|
1776
|
+
return 2
|
|
1777
|
+
|
|
1778
|
+
# Find tours that need updating
|
|
1779
|
+
out_of_date_tours = [
|
|
1780
|
+
tour_info for tour_info in repo_info.tours
|
|
1781
|
+
if not is_tour_up_to_date(tour_info)
|
|
1782
|
+
]
|
|
1783
|
+
|
|
1784
|
+
if not out_of_date_tours:
|
|
1785
|
+
print("✓ All tours are up to date!")
|
|
1786
|
+
return 0
|
|
1787
|
+
|
|
1788
|
+
# Interactive mode: let user select tours
|
|
1789
|
+
if args.interactive:
|
|
1790
|
+
selected_tours = interactive_select_tours(out_of_date_tours)
|
|
1791
|
+
if not selected_tours:
|
|
1792
|
+
print("No tours selected.")
|
|
1793
|
+
return 0
|
|
1794
|
+
out_of_date_tours = selected_tours
|
|
1795
|
+
|
|
1796
|
+
# Check for dry-run mode
|
|
1797
|
+
is_dry_run = args.dry_run if hasattr(args, 'dry_run') else False
|
|
1798
|
+
|
|
1799
|
+
if is_dry_run:
|
|
1800
|
+
print(f"🔍 DRY RUN: Previewing {len(out_of_date_tours)} tour{'s' if len(out_of_date_tours) != 1 else ''}\n")
|
|
1801
|
+
print("ℹ️ No files will be modified. This is a preview only.\n")
|
|
1802
|
+
else:
|
|
1803
|
+
print(f"🔍 Found {len(out_of_date_tours)} tour{'s' if len(out_of_date_tours) != 1 else ''} needing migration\n")
|
|
1804
|
+
|
|
1805
|
+
# Merge config with command-line args
|
|
1806
|
+
diff_algorithm = config.get("migration", {}).get("diff_algorithm", "histogram")
|
|
1807
|
+
save_backup = not args.no_backup if hasattr(args, 'no_backup') else config.get("migration", {}).get("backup", True)
|
|
1808
|
+
threshold = (
|
|
1809
|
+
args.threshold if getattr(args, 'threshold', None) is not None
|
|
1810
|
+
else config.get("migration", {}).get("threshold", 0.7)
|
|
1811
|
+
) # ADR-0016 QST-CONF-2 -- previously computed for display text only, never passed to the engine
|
|
1812
|
+
|
|
1813
|
+
# Migrate each tour
|
|
1814
|
+
total_updated = 0
|
|
1815
|
+
total_needs_review = 0
|
|
1816
|
+
total_deprecated = 0
|
|
1817
|
+
failed_tours = []
|
|
1818
|
+
review_reports = [] # Track generated review reports
|
|
1819
|
+
|
|
1820
|
+
for tour_info in out_of_date_tours:
|
|
1821
|
+
tour_name = tour_info.path.name
|
|
1822
|
+
action_verb = "Analyzing" if is_dry_run else "Migrating"
|
|
1823
|
+
print(f"{action_verb} {tour_name}...")
|
|
1824
|
+
|
|
1825
|
+
try:
|
|
1826
|
+
result = update_tour_file(
|
|
1827
|
+
repo_path=repo_info.repo_path,
|
|
1828
|
+
tour_file=tour_info.path,
|
|
1829
|
+
target_commit=args.to_commit,
|
|
1830
|
+
source_commit=args.from_commit or tour_info.source_commit,
|
|
1831
|
+
diff_algorithm=diff_algorithm,
|
|
1832
|
+
save_backup=save_backup,
|
|
1833
|
+
dry_run=is_dry_run,
|
|
1834
|
+
threshold=threshold
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
total_updated += result.num_updated
|
|
1838
|
+
total_needs_review += result.num_needs_review
|
|
1839
|
+
total_deprecated += result.num_deprecated
|
|
1840
|
+
|
|
1841
|
+
# Brief summary per tour
|
|
1842
|
+
verb_updated = "would be updated" if is_dry_run else "updated"
|
|
1843
|
+
verb_need = "would need" if is_dry_run else "need"
|
|
1844
|
+
print(f" ✓ {result.num_updated} steps {verb_updated}")
|
|
1845
|
+
if result.num_needs_review > 0:
|
|
1846
|
+
print(f" ⚠ {result.num_needs_review} steps {verb_need} review")
|
|
1847
|
+
|
|
1848
|
+
# Show detailed migration summary
|
|
1849
|
+
print_migration_summary(result, tour_name, is_dry_run)
|
|
1850
|
+
|
|
1851
|
+
# Generate review report if needed
|
|
1852
|
+
if result.num_needs_review > 0:
|
|
1853
|
+
report_path = generate_review_report(
|
|
1854
|
+
tour_path=tour_info.path,
|
|
1855
|
+
result=result,
|
|
1856
|
+
source_commit=args.from_commit or tour_info.source_commit,
|
|
1857
|
+
target_commit=args.to_commit,
|
|
1858
|
+
repo_root=repo_info.repo_path,
|
|
1859
|
+
is_dry_run=is_dry_run,
|
|
1860
|
+
no_guidance=getattr(args, 'no_guidance', False),
|
|
1861
|
+
threshold=threshold
|
|
1862
|
+
)
|
|
1863
|
+
if report_path:
|
|
1864
|
+
review_reports.append(report_path)
|
|
1865
|
+
if is_dry_run:
|
|
1866
|
+
print(f" 📄 Review report generated (despite dry-run): {report_path.relative_to(repo_info.repo_path)}")
|
|
1867
|
+
else:
|
|
1868
|
+
print(f" 📄 Review report: {report_path.relative_to(repo_info.repo_path)}")
|
|
1869
|
+
|
|
1870
|
+
except Exception as e:
|
|
1871
|
+
log.error("migration_failed", tour=tour_name, error=str(e))
|
|
1872
|
+
print(f" ❌ Failed: {e}\n")
|
|
1873
|
+
failed_tours.append(tour_name)
|
|
1874
|
+
|
|
1875
|
+
# Overall summary
|
|
1876
|
+
print("─" * 50)
|
|
1877
|
+
if is_dry_run:
|
|
1878
|
+
print(f"Dry Run Summary: Analyzed {len(out_of_date_tours)}/{len(out_of_date_tours)} tours")
|
|
1879
|
+
print(f" • {total_updated} steps would be updated")
|
|
1880
|
+
if total_needs_review > 0:
|
|
1881
|
+
print(f" ⚠ {total_needs_review} steps would need review")
|
|
1882
|
+
if total_deprecated > 0:
|
|
1883
|
+
print(f" ⚠ {total_deprecated} steps reference deleted files")
|
|
1884
|
+
if failed_tours:
|
|
1885
|
+
print(f" ❌ {len(failed_tours)} tours failed to analyze")
|
|
1886
|
+
print()
|
|
1887
|
+
print("To apply these changes:")
|
|
1888
|
+
|
|
1889
|
+
# Build accurate command suggestion based on what was previewed
|
|
1890
|
+
if len(out_of_date_tours) == 1:
|
|
1891
|
+
# Single tour - show both specific file and interactive options
|
|
1892
|
+
tour_file = out_of_date_tours[0].path
|
|
1893
|
+
print(f" • Migrate this specific tour directly:")
|
|
1894
|
+
print(f" codetour-cli migrate {tour_file}")
|
|
1895
|
+
print(f" • Or use interactive mode:")
|
|
1896
|
+
print(f" codetour-cli migrate --interactive")
|
|
1897
|
+
elif args.interactive:
|
|
1898
|
+
# Multiple tours with interactive - suggest interactive mode and specific files
|
|
1899
|
+
print(f" • Run 'codetour-cli migrate --interactive' and select the same tours")
|
|
1900
|
+
print(f" • Or migrate specific tours directly:")
|
|
1901
|
+
for tour_info in out_of_date_tours[:3]: # Show first 3
|
|
1902
|
+
print(f" - codetour-cli migrate {tour_info.path}")
|
|
1903
|
+
if len(out_of_date_tours) > 3:
|
|
1904
|
+
print(f" ... and {len(out_of_date_tours) - 3} more")
|
|
1905
|
+
else:
|
|
1906
|
+
# Multiple tours without interactive - list them or suggest batch
|
|
1907
|
+
if len(out_of_date_tours) <= 3:
|
|
1908
|
+
# Few tours - list them with bullet points
|
|
1909
|
+
print(f" • Migrate specific tours:")
|
|
1910
|
+
for tour_info in out_of_date_tours:
|
|
1911
|
+
print(f" - codetour-cli migrate {tour_info.path}")
|
|
1912
|
+
else:
|
|
1913
|
+
# Many tours - suggest batch or interactive
|
|
1914
|
+
print(f" • Migrate all tours:")
|
|
1915
|
+
print(f" codetour-cli migrate")
|
|
1916
|
+
print(f" • Or use interactive mode to select:")
|
|
1917
|
+
print(f" codetour-cli migrate --interactive")
|
|
1918
|
+
else:
|
|
1919
|
+
print(f"Summary: Migrated {len(out_of_date_tours) - len(failed_tours)}/{len(out_of_date_tours)} tours")
|
|
1920
|
+
print(f" • {total_updated} steps updated")
|
|
1921
|
+
if total_needs_review > 0:
|
|
1922
|
+
print(f" ⚠ {total_needs_review} steps need review")
|
|
1923
|
+
if total_deprecated > 0:
|
|
1924
|
+
print(f" ⚠ {total_deprecated} steps reference deleted files")
|
|
1925
|
+
if failed_tours:
|
|
1926
|
+
print(f" ❌ {len(failed_tours)} tours failed")
|
|
1927
|
+
|
|
1928
|
+
print()
|
|
1929
|
+
if review_reports:
|
|
1930
|
+
print(f"📋 Review reports generated:")
|
|
1931
|
+
for report_path in review_reports:
|
|
1932
|
+
print(f" • {report_path.relative_to(repo_info.repo_path)}")
|
|
1933
|
+
print()
|
|
1934
|
+
_post_migration_lint(
|
|
1935
|
+
[t.path for t in out_of_date_tours],
|
|
1936
|
+
config,
|
|
1937
|
+
no_lint=getattr(args, 'no_lint', False),
|
|
1938
|
+
dry_run=False,
|
|
1939
|
+
repo_root=repo_info.repo_path,
|
|
1940
|
+
)
|
|
1941
|
+
print("Next steps:")
|
|
1942
|
+
print(" git diff .tours/")
|
|
1943
|
+
print(" git add .tours/")
|
|
1944
|
+
print(' git commit -m "chore: update tours to current HEAD"')
|
|
1945
|
+
|
|
1946
|
+
# Exit code
|
|
1947
|
+
if failed_tours:
|
|
1948
|
+
return 2
|
|
1949
|
+
elif total_needs_review > 0 or total_deprecated > 0:
|
|
1950
|
+
return 1
|
|
1951
|
+
else:
|
|
1952
|
+
return 0
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def migrate_single_tour(args, config: dict) -> int:
|
|
1956
|
+
"""
|
|
1957
|
+
Migrate a single specified tour file.
|
|
1958
|
+
|
|
1959
|
+
Returns:
|
|
1960
|
+
Exit code (0 = success, 1 = needs review, 2 = error)
|
|
1961
|
+
"""
|
|
1962
|
+
tour_path = Path(args.tour_file)
|
|
1963
|
+
|
|
1964
|
+
# Verify tour file exists
|
|
1965
|
+
if not tour_path.exists():
|
|
1966
|
+
print(f"❌ Tour file not found: {tour_path}", file=sys.stderr)
|
|
1967
|
+
return 2
|
|
1968
|
+
|
|
1969
|
+
# Check Git exists
|
|
1970
|
+
try:
|
|
1971
|
+
repo = Repo('.', search_parent_directories=True)
|
|
1972
|
+
except InvalidGitRepositoryError:
|
|
1973
|
+
print("❌ CodeTour CLI requires Git to track tour drift.", file=sys.stderr)
|
|
1974
|
+
print(file=sys.stderr)
|
|
1975
|
+
print("This repository is not a Git repository.", file=sys.stderr)
|
|
1976
|
+
print(file=sys.stderr)
|
|
1977
|
+
print("To enable tour maintenance:", file=sys.stderr)
|
|
1978
|
+
print(" git init", file=sys.stderr)
|
|
1979
|
+
print(" git add .", file=sys.stderr)
|
|
1980
|
+
print(' git commit -m "Initial commit"', file=sys.stderr)
|
|
1981
|
+
print(file=sys.stderr)
|
|
1982
|
+
print("Then re-run: codetour-cli migrate", file=sys.stderr)
|
|
1983
|
+
return 2
|
|
1984
|
+
|
|
1985
|
+
# Detect or use override for source commit
|
|
1986
|
+
if args.from_commit:
|
|
1987
|
+
source_commit = args.from_commit
|
|
1988
|
+
log.info("using_override", commit=source_commit[:8] if len(source_commit) >= 8 else source_commit)
|
|
1989
|
+
print(f"ℹ Using override source commit: {source_commit[:8]}...")
|
|
1990
|
+
else:
|
|
1991
|
+
# Make tour_path relative to repo root for git operations
|
|
1992
|
+
try:
|
|
1993
|
+
tour_path_rel = tour_path.relative_to(repo.working_dir)
|
|
1994
|
+
except ValueError:
|
|
1995
|
+
# If path is already relative or outside repo, use as-is
|
|
1996
|
+
tour_path_rel = tour_path
|
|
1997
|
+
|
|
1998
|
+
source_commit, confidence, method = detect_source_commit(repo, tour_path_rel)
|
|
1999
|
+
|
|
2000
|
+
print(f"ℹ Detected source commit: {source_commit[:8]}... (confidence: {confidence:.0%}, method: {method})")
|
|
2001
|
+
|
|
2002
|
+
# Merge config with command-line args
|
|
2003
|
+
diff_algorithm = config.get("migration", {}).get("diff_algorithm", "histogram")
|
|
2004
|
+
save_backup = not args.no_backup if hasattr(args, 'no_backup') else config.get("migration", {}).get("backup", True)
|
|
2005
|
+
threshold = (
|
|
2006
|
+
args.threshold if getattr(args, 'threshold', None) is not None
|
|
2007
|
+
else config.get("migration", {}).get("threshold", 0.7)
|
|
2008
|
+
) # ADR-0016 QST-CONF-2 -- previously computed for display text only, never passed to the engine
|
|
2009
|
+
is_dry_run = args.dry_run if hasattr(args, 'dry_run') else False
|
|
2010
|
+
|
|
2011
|
+
if is_dry_run:
|
|
2012
|
+
print("\nℹ️ DRY RUN: No files will be modified. This is a preview only.\n")
|
|
2013
|
+
|
|
2014
|
+
# Run migration
|
|
2015
|
+
try:
|
|
2016
|
+
result = update_tour_file(
|
|
2017
|
+
repo_path=repo.working_dir,
|
|
2018
|
+
tour_file=tour_path,
|
|
2019
|
+
target_commit=args.to_commit,
|
|
2020
|
+
source_commit=source_commit,
|
|
2021
|
+
diff_algorithm=diff_algorithm,
|
|
2022
|
+
save_backup=save_backup,
|
|
2023
|
+
dry_run=is_dry_run,
|
|
2024
|
+
threshold=threshold
|
|
2025
|
+
)
|
|
2026
|
+
except Exception as e:
|
|
2027
|
+
log.error("migration_failed", error=str(e))
|
|
2028
|
+
print(f"❌ Migration failed: {e}", file=sys.stderr)
|
|
2029
|
+
return 2
|
|
2030
|
+
|
|
2031
|
+
# Report results
|
|
2032
|
+
print()
|
|
2033
|
+
if is_dry_run:
|
|
2034
|
+
print(f"✓ Would migrate {result.num_updated} steps")
|
|
2035
|
+
else:
|
|
2036
|
+
print(f"✓ Migrated {result.num_updated} steps")
|
|
2037
|
+
|
|
2038
|
+
if result.num_needs_review > 0:
|
|
2039
|
+
verb = "would need" if is_dry_run else "need"
|
|
2040
|
+
print(f"⚠ {result.num_needs_review} steps {verb} review (confidence < 0.7)")
|
|
2041
|
+
|
|
2042
|
+
if result.num_deprecated > 0:
|
|
2043
|
+
print(f"⚠ {result.num_deprecated} steps reference deleted files")
|
|
2044
|
+
|
|
2045
|
+
# Show detailed migration summary
|
|
2046
|
+
print_migration_summary(result, tour_path.name, is_dry_run)
|
|
2047
|
+
|
|
2048
|
+
# Generate review report if needed
|
|
2049
|
+
if result.num_needs_review > 0:
|
|
2050
|
+
report_path = generate_review_report(
|
|
2051
|
+
tour_path=tour_path,
|
|
2052
|
+
result=result,
|
|
2053
|
+
source_commit=source_commit,
|
|
2054
|
+
target_commit=args.to_commit,
|
|
2055
|
+
repo_root=Path(repo.working_dir),
|
|
2056
|
+
is_dry_run=is_dry_run,
|
|
2057
|
+
no_guidance=getattr(args, 'no_guidance', False),
|
|
2058
|
+
threshold=threshold
|
|
2059
|
+
)
|
|
2060
|
+
if report_path:
|
|
2061
|
+
if is_dry_run:
|
|
2062
|
+
print(f"\n 📄 Review report generated (despite dry-run): {report_path.relative_to(repo.working_dir)}")
|
|
2063
|
+
else:
|
|
2064
|
+
print(f"\n 📄 Review report: {report_path.relative_to(repo.working_dir)}")
|
|
2065
|
+
|
|
2066
|
+
if is_dry_run:
|
|
2067
|
+
print("\nTo apply these changes, run:")
|
|
2068
|
+
print(f" codetour-cli migrate {tour_path}")
|
|
2069
|
+
print()
|
|
2070
|
+
|
|
2071
|
+
_post_migration_lint(
|
|
2072
|
+
[tour_path],
|
|
2073
|
+
config,
|
|
2074
|
+
no_lint=getattr(args, 'no_lint', False),
|
|
2075
|
+
dry_run=is_dry_run,
|
|
2076
|
+
repo_root=Path(repo.working_dir),
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
# Determine exit code
|
|
2080
|
+
if result.num_needs_review > 0 or result.num_deprecated > 0:
|
|
2081
|
+
return 1 # Needs review
|
|
2082
|
+
else:
|
|
2083
|
+
return 0 # Success
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
if __name__ == '__main__':
|
|
2087
|
+
sys.exit(main())
|