Files
awesome-copilot/skills/code-tour/scripts/validate_tour.py
Srinivas Vaddi 09049e3b78 feat: add code-tour skill — AI-generated CodeTour walkthroughs (#1277)
* feat: add code-tour skill for AI-generated CodeTour walkthroughs

* fix: trim SKILL.md from 645 to 432 lines (under 500 limit)

Reduce persona table to top 10, condense verbose examples and notes,
trim redundant anti-patterns, compress step type docs and PR recipe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: run npm run build to update README with code-tour skill

Addresses review feedback from @aaronpowell

* fix: add missing scripts/ and references/ files referenced in SKILL.md

Addresses reviewer feedback — SKILL.md referenced bundled files
(validate_tour.py, generate_from_docs.py, codetour-schema.json,
examples.md) that were not included in the PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: run npm run build to update skills README with new assets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:52:59 +10:00

347 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
CodeTour validator — bundled with the code-tour skill.
Checks a .tour file for:
- Valid JSON
- Required fields (title, steps, description per step)
- File paths that actually exist in the repo
- Line numbers within file bounds
- Selection ranges within file bounds
- Directory paths that exist
- Pattern regexes that compile AND match at least one line
- URI format (must start with https://)
- nextTour matches an existing tour title in .tours/
- Content-only step count (max 2 recommended)
- Narrative arc (first step should orient, last step should close)
Usage:
python validate_tour.py <tour_file> [--repo-root <path>]
Examples:
python validate_tour.py .tours/new-joiner.tour
python validate_tour.py .tours/new-joiner.tour --repo-root /path/to/repo
"""
import json
import re
import sys
import os
from pathlib import Path
RESET = "\033[0m"
RED = "\033[31m"
YELLOW = "\033[33m"
GREEN = "\033[32m"
BOLD = "\033[1m"
DIM = "\033[2m"
def _line_count(path: Path) -> int:
try:
with open(path, errors="replace") as f:
return sum(1 for _ in f)
except Exception:
return 0
def _file_content(path: Path) -> str:
try:
return path.read_text(errors="replace")
except Exception:
return ""
def validate_tour(tour_path: str, repo_root: str = ".") -> dict:
repo = Path(repo_root).resolve()
errors = []
warnings = []
info = []
# ── 1. JSON validity ────────────────────────────────────────────────────
try:
with open(tour_path, errors="replace") as f:
tour = json.load(f)
except json.JSONDecodeError as e:
return {
"passed": False,
"errors": [f"Invalid JSON: {e}"],
"warnings": [],
"info": [],
"stats": {},
}
except FileNotFoundError:
return {
"passed": False,
"errors": [f"File not found: {tour_path}"],
"warnings": [],
"info": [],
"stats": {},
}
# ── 2. Required top-level fields ────────────────────────────────────────
if "title" not in tour:
errors.append("Missing required field: 'title'")
if "steps" not in tour:
errors.append("Missing required field: 'steps'")
return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}}
steps = tour["steps"]
if not isinstance(steps, list):
errors.append("'steps' must be an array")
return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}}
if len(steps) == 0:
errors.append("Tour has no steps")
return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}}
# ── 3. Tour-level optional fields ───────────────────────────────────────
if "nextTour" in tour:
tours_dir = Path(tour_path).parent
next_title = tour["nextTour"]
found_next = False
for tf in tours_dir.glob("*.tour"):
if tf.resolve() == Path(tour_path).resolve():
continue
try:
other = json.loads(tf.read_text())
if other.get("title") == next_title:
found_next = True
break
except Exception:
pass
if not found_next:
warnings.append(
f"nextTour '{next_title}' — no .tour file in .tours/ has a matching title"
)
# ── 4. Per-step validation ───────────────────────────────────────────────
content_only_count = 0
file_step_count = 0
dir_step_count = 0
uri_step_count = 0
for i, step in enumerate(steps):
label = f"Step {i + 1}"
if "title" in step:
label += f"{step['title']!r}"
# description required on every step
if "description" not in step:
errors.append(f"{label}: Missing required field 'description'")
has_file = "file" in step
has_dir = "directory" in step
has_uri = "uri" in step
has_selection = "selection" in step
if not has_file and not has_dir and not has_uri:
content_only_count += 1
# ── file ──────────────────────────────────────────────────────────
if has_file:
file_step_count += 1
raw_path = step["file"]
# must be relative — no leading slash, no ./
if raw_path.startswith("/"):
errors.append(f"{label}: File path must be relative (no leading /): {raw_path!r}")
elif raw_path.startswith("./"):
warnings.append(f"{label}: File path should not start with './': {raw_path!r}")
file_path = repo / raw_path
if not file_path.exists():
errors.append(f"{label}: File does not exist: {raw_path!r}")
elif not file_path.is_file():
errors.append(f"{label}: Path is not a file: {raw_path!r}")
else:
lc = _line_count(file_path)
# line number
if "line" in step:
ln = step["line"]
if not isinstance(ln, int):
errors.append(f"{label}: 'line' must be an integer, got {ln!r}")
elif ln < 1:
errors.append(f"{label}: Line number must be >= 1, got {ln}")
elif ln > lc:
errors.append(
f"{label}: Line {ln} exceeds file length ({lc} lines): {raw_path!r}"
)
# selection
if has_selection:
sel = step["selection"]
start = sel.get("start", {})
end = sel.get("end", {})
s_line = start.get("line", 0)
e_line = end.get("line", 0)
if s_line > lc:
errors.append(
f"{label}: Selection start line {s_line} exceeds file length ({lc})"
)
if e_line > lc:
errors.append(
f"{label}: Selection end line {e_line} exceeds file length ({lc})"
)
if s_line > e_line:
errors.append(
f"{label}: Selection start ({s_line}) is after end ({e_line})"
)
# pattern
if "pattern" in step:
try:
compiled = re.compile(step["pattern"], re.MULTILINE)
content = _file_content(file_path)
if not compiled.search(content):
errors.append(
f"{label}: Pattern {step['pattern']!r} matches nothing in {raw_path!r}"
)
except re.error as e:
errors.append(f"{label}: Invalid regex pattern: {e}")
# ── directory ─────────────────────────────────────────────────────
if has_dir:
dir_step_count += 1
raw_dir = step["directory"]
dir_path = repo / raw_dir
if not dir_path.exists():
errors.append(f"{label}: Directory does not exist: {raw_dir!r}")
elif not dir_path.is_dir():
errors.append(f"{label}: Path is not a directory: {raw_dir!r}")
# ── uri ───────────────────────────────────────────────────────────
if has_uri:
uri_step_count += 1
uri = step["uri"]
if not uri.startswith("https://") and not uri.startswith("http://"):
warnings.append(f"{label}: URI should start with https://: {uri!r}")
# ── commands ──────────────────────────────────────────────────────
if "commands" in step:
if not isinstance(step["commands"], list):
errors.append(f"{label}: 'commands' must be an array")
else:
for cmd in step["commands"]:
if not isinstance(cmd, str):
errors.append(f"{label}: Each command must be a string, got {cmd!r}")
# ── 5. Content-only step count ──────────────────────────────────────────
if content_only_count > 2:
warnings.append(
f"{content_only_count} content-only steps (no file/dir/uri). "
f"Recommended max: 2 (intro + closing)."
)
# ── 6. Narrative arc checks ─────────────────────────────────────────────
first = steps[0]
last = steps[-1]
first_is_orient = "file" not in first and "directory" not in first and "uri" not in first
last_is_closing = "file" not in last and "directory" not in last and "uri" not in last
if not first_is_orient and "directory" not in first:
info.append(
"First step is a file/uri step — consider starting with a content or directory "
"orientation step."
)
if not last_is_closing:
info.append(
"Last step is not a content step — consider ending with a closing/summary step."
)
stats = {
"total_steps": len(steps),
"file_steps": file_step_count,
"directory_steps": dir_step_count,
"content_steps": content_only_count,
"uri_steps": uri_step_count,
}
return {
"passed": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"info": info,
"stats": stats,
}
def print_report(tour_path: str, result: dict) -> None:
title = f"{BOLD}{tour_path}{RESET}"
print(f"\n{title}")
print("" * 60)
stats = result.get("stats", {})
if stats:
parts = [
f"{stats.get('total_steps', 0)} steps",
f"{stats.get('file_steps', 0)} file",
f"{stats.get('directory_steps', 0)} dir",
f"{stats.get('content_steps', 0)} content",
f"{stats.get('uri_steps', 0)} uri",
]
print(f"{DIM} {' · '.join(parts)}{RESET}")
errors = result.get("errors", [])
warnings = result.get("warnings", [])
info = result.get("info", [])
for e in errors:
print(f" {RED}{e}{RESET}")
for w in warnings:
print(f" {YELLOW}{w}{RESET}")
for i in info:
print(f" {DIM} {i}{RESET}")
if result["passed"] and not warnings:
print(f" {GREEN}✓ All checks passed{RESET}")
elif result["passed"]:
print(f" {GREEN}✓ Passed{RESET} {YELLOW}(with warnings){RESET}")
else:
print(f" {RED}✗ Failed — {len(errors)} error(s){RESET}")
print()
def main():
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help"):
print(__doc__)
sys.exit(0)
repo_root = "."
tour_files = []
i = 0
while i < len(args):
if args[i] == "--repo-root" and i + 1 < len(args):
repo_root = args[i + 1]
i += 2
else:
tour_files.append(args[i])
i += 1
if not tour_files:
# validate all tours in .tours/
tours_dir = Path(".tours")
if tours_dir.exists():
tour_files = [str(p) for p in sorted(tours_dir.glob("*.tour"))]
if not tour_files:
print("No .tour files found. Pass a file path or run from a repo with a .tours/ directory.")
sys.exit(1)
all_passed = True
for tf in tour_files:
result = validate_tour(tf, repo_root)
print_report(tf, result)
if not result["passed"]:
all_passed = False
sys.exit(0 if all_passed else 1)
if __name__ == "__main__":
main()