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/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())