Setting the file. One moment.
Validate Tour · Code Tour · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page scripts/validate_tour.py
scripts/ validate_tour.py
Python · 346 lines · 13 KB
16 - Narrative arc (first step should orient, last step should close)
17
18 Usage:
19 python validate_tour.py <tour_file> [--repo-root <path>]
20
21 Examples:
22 python validate_tour.py .tours/new-joiner.tour
23 python validate_tour.py .tours/new-joiner.tour --repo-root /path/to/repo
24 """
25
26 import json
27 import re
28 import sys
29 import os
30 from pathlib import Path
31
32
33 RESET = " \033 [0m"
34 RED = " \033 [31m"
35 YELLOW = " \033 [33m"
36 GREEN = " \033 [32m"
37 BOLD = " \033 [1m"
38 DIM = " \033 [2m"
39
40
41 def _line_count (path: Path) -> int :
42 try :
43 with open (path, errors = "replace" ) as f:
44 return sum ( 1 for _ in f)
45 except Exception :
46 return 0
47
48
49 def _file_content (path: Path) -> str :
50 try :
51 return path.read_text( errors = "replace" )
52 except Exception :
53 return ""
54
55
56 def validate_tour (tour_path: str , repo_root: str = "." ) -> dict :
57 repo = Path(repo_root).resolve()
58 errors = []
59 warnings = []
60 info = []
61
62 # ── 1. JSON validity ────────────────────────────────────────────────────
63 try :
64 with open (tour_path, errors = "replace" ) as f:
65 tour = json.load(f)
66 except json.JSONDecodeError as e:
67 return {
68 "passed" : False ,
69 "errors" : [ f "Invalid JSON: { e } " ],
70 "warnings" : [],
71 "info" : [],
72 "stats" : {},
73 }
74 except FileNotFoundError :
75 return {
76 "passed" : False ,
77 "errors" : [ f "File not found: { tour_path } " ],
78 "warnings" : [],
79 "info" : [],
80 "stats" : {},
81 }
82
83 # ── 2. Required top-level fields ────────────────────────────────────────
84 if "title" not in tour:
85 errors.append( "Missing required field: 'title'" )
86 if "steps" not in tour:
87 errors.append( "Missing required field: 'steps'" )
88 return { "passed" : False , "errors" : errors, "warnings" : warnings, "info" : info, "stats" : {}}
89
90 steps = tour[ "steps" ]
91 if not isinstance (steps, list ):
92 errors.append( "'steps' must be an array" )
93 return { "passed" : False , "errors" : errors, "warnings" : warnings, "info" : info, "stats" : {}}
94
95 if len (steps) == 0 :
96 errors.append( "Tour has no steps" )
97 return { "passed" : False , "errors" : errors, "warnings" : warnings, "info" : info, "stats" : {}}
98
99 # ── 3. Tour-level optional fields ───────────────────────────────────────
100 if "nextTour" in tour:
101 tours_dir = Path(tour_path).parent
102 next_title = tour[ "nextTour" ]
103 found_next = False
104 for tf in tours_dir.glob( "*.tour" ):
105 if tf.resolve() == Path(tour_path).resolve():
106 continue
107 try :
108 other = json.loads(tf.read_text())
109 if other.get( "title" ) == next_title:
110 found_next = True
111 break
112 except Exception :
113 pass
114 if not found_next:
115 warnings.append(
116 f "nextTour ' { next_title } ' — no .tour file in .tours/ has a matching title"
117 )
118
119 # ── 4. Per-step validation ───────────────────────────────────────────────
120 content_only_count = 0
121 file_step_count = 0
122 dir_step_count = 0
123 uri_step_count = 0
124
125 for i, step in enumerate (steps):
126 label = f "Step { i + 1 } "
127 if "title" in step:
128 label += f " — { step[ 'title' ] !r} "
129
130 # description required on every step
131 if "description" not in step:
132 errors.append( f " { label } : Missing required field 'description'" )
133
134 has_file = "file" in step
135 has_dir = "directory" in step
136 has_uri = "uri" in step
137 has_selection = "selection" in step
138
139 if not has_file and not has_dir and not has_uri:
140 content_only_count += 1
141
142 # ── file ──────────────────────────────────────────────────────────
143 if has_file:
144 file_step_count += 1
145 raw_path = step[ "file" ]
146
147 # must be relative — no leading slash, no ./
148 if raw_path.startswith( "/" ):
149 errors.append( f " { label } : File path must be relative (no leading /): { raw_path !r} " )
150 elif raw_path.startswith( "./" ):
151 warnings.append( f " { label } : File path should not start with './': { raw_path !r} " )
152
153 file_path = repo / raw_path
154 if not file_path.exists():
155 errors.append( f " { label } : File does not exist: { raw_path !r} " )
156 elif not file_path.is_file():
157 errors.append( f " { label } : Path is not a file: { raw_path !r} " )
158 else :
159 lc = _line_count(file_path)
160
161 # line number
162 if "line" in step:
163 ln = step[ "line" ]
164 if not isinstance (ln, int ):
165 errors.append( f " { label } : 'line' must be an integer, got { ln !r} " )
166 elif ln < 1 :
167 errors.append( f " { label } : Line number must be >= 1, got { ln } " )
168 elif ln > lc:
169 errors.append(
170 f " { label } : Line { ln } exceeds file length ( { lc } lines): { raw_path !r} "
171 )
172
173 # selection
174 if has_selection:
175 sel = step[ "selection" ]
176 start = sel.get( "start" , {})
177 end = sel.get( "end" , {})
178 s_line = start.get( "line" , 0 )
179 e_line = end.get( "line" , 0 )
180 if s_line > lc:
181 errors.append(
182 f " { label } : Selection start line { s_line } exceeds file length ( { lc } )"
183 )
184 if e_line > lc:
185 errors.append(
186 f " { label } : Selection end line { e_line } exceeds file length ( { lc } )"
187 )
188 if s_line > e_line:
189 errors.append(
190 f " { label } : Selection start ( { s_line } ) is after end ( { e_line } )"
191 )
192
193 # pattern
194 if "pattern" in step:
195 try :
196 compiled = re.compile(step[ "pattern" ], re. MULTILINE )
197 content = _file_content(file_path)
198 if not compiled.search(content):
199 errors.append(
200 f " { label } : Pattern { step[ 'pattern' ] !r} matches nothing in { raw_path !r} "
201 )
202 except re.error as e:
203 errors.append( f " { label } : Invalid regex pattern: { e } " )
204
205 # ── directory ─────────────────────────────────────────────────────
206 if has_dir:
207 dir_step_count += 1
208 raw_dir = step[ "directory" ]
209 dir_path = repo / raw_dir
210 if not dir_path.exists():
211 errors.append( f " { label } : Directory does not exist: { raw_dir !r} " )
212 elif not dir_path.is_dir():
213 errors.append( f " { label } : Path is not a directory: { raw_dir !r} " )
214
215 # ── uri ───────────────────────────────────────────────────────────
216 if has_uri:
217 uri_step_count += 1
218 uri = step[ "uri" ]
219 if not uri.startswith( "https://" ) and not uri.startswith( "http://" ):
220 warnings.append( f " { label } : URI should start with https://: { uri !r} " )
221
222 # ── commands ──────────────────────────────────────────────────────
223 if "commands" in step:
224 if not isinstance (step[ "commands" ], list ):
225 errors.append( f " { label } : 'commands' must be an array" )
226 else :
227 for cmd in step[ "commands" ]:
228 if not isinstance (cmd, str ):
229 errors.append( f " { label } : Each command must be a string, got { cmd !r} " )
230
231 # ── 5. Content-only step count ──────────────────────────────────────────
232 if content_only_count > 2 :
233 warnings.append(
234 f " { content_only_count } content-only steps (no file/dir/uri). "
235 f "Recommended max: 2 (intro + closing)."
236 )
237
238 # ── 6. Narrative arc checks ─────────────────────────────────────────────
239 first = steps[ 0 ]
240 last = steps[ - 1 ]
241 first_is_orient = "file" not in first and "directory" not in first and "uri" not in first
242 last_is_closing = "file" not in last and "directory" not in last and "uri" not in last
243
244 if not first_is_orient and "directory" not in first:
245 info.append(
246 "First step is a file/uri step — consider starting with a content or directory "
247 "orientation step."
248 )
249 if not last_is_closing:
250 info.append(
251 "Last step is not a content step — consider ending with a closing/summary step."
252 )
253
254 stats = {
255 "total_steps" : len (steps),
256 "file_steps" : file_step_count,
257 "directory_steps" : dir_step_count,
258 "content_steps" : content_only_count,
259 "uri_steps" : uri_step_count,
260 }
261
262 return {
263 "passed" : len (errors) == 0 ,
264 "errors" : errors,
265 "warnings" : warnings,
266 "info" : info,
267 "stats" : stats,
268 }
269
270
271 def print_report (tour_path: str , result: dict ) -> None :
272 title = f " { BOLD }{ tour_path }{ RESET } "
273 print ( f " \n{ title } " )
274 print ( "─" * 60 )
275
276 stats = result.get( "stats" , {})
277 if stats:
278 parts = [
279 f " { stats.get( 'total_steps' , 0 ) } steps" ,
280 f " { stats.get( 'file_steps' , 0 ) } file" ,
281 f " { stats.get( 'directory_steps' , 0 ) } dir" ,
282 f " { stats.get( 'content_steps' , 0 ) } content" ,
283 f " { stats.get( 'uri_steps' , 0 ) } uri" ,
284 ]
285 print ( f " { DIM } { ' · ' .join(parts) }{ RESET } " )
286
287 errors = result.get( "errors" , [])
288 warnings = result.get( "warnings" , [])
289 info = result.get( "info" , [])
290
291 for e in errors:
292 print ( f " { RED } ✗ { e }{ RESET } " )
293 for w in warnings:
294 print ( f " { YELLOW } ⚠ { w }{ RESET } " )
295 for i in info:
296 print ( f " { DIM } ℹ { i }{ RESET } " )
297
298 if result[ "passed" ] and not warnings:
299 print ( f " { GREEN } ✓ All checks passed { RESET } " )
300 elif result[ "passed" ]:
301 print ( f " { GREEN } ✓ Passed { RESET } { YELLOW } (with warnings) { RESET } " )
302 else :
303 print ( f " { RED } ✗ Failed — { len (errors) } error(s) { RESET } " )
304
305 print ()
306
307
308 def main ():
309 args = sys.argv[ 1 :]
310 if not args or args[ 0 ] in ( "-h" , "--help" ):
311 print ( __doc__ )
312 sys.exit( 0 )
313
314 repo_root = "."
315 tour_files = []
316
317 i = 0
318 while i < len (args):
319 if args[i] == "--repo-root" and i + 1 < len (args):
320 repo_root = args[i + 1 ]
321 i += 2
322 else :
323 tour_files.append(args[i])
324 i += 1
325
326 if not tour_files:
327 # validate all tours in .tours/
328 tours_dir = Path( ".tours" )
329 if tours_dir.exists():
330 tour_files = [ str (p) for p in sorted (tours_dir.glob( "*.tour" ))]
331 if not tour_files:
332 print ( "No .tour files found. Pass a file path or run from a repo with a .tours/ directory." )
333 sys.exit( 1 )
334
335 all_passed = True
336 for tf in tour_files:
337 result = validate_tour(tf, repo_root)
338 print_report(tf, result)
339 if not result[ "passed" ]:
340 all_passed = False
341
342 sys.exit( 0 if all_passed else 1 )
343
344
345 if __name__ == "__main__" :
346 main()