Python · 51673 bytes Raw Blame History
1 """Tests for runtime-owned safeguard services."""
2
3 from __future__ import annotations
4
5 import json
6 import tempfile
7 from pathlib import Path
8
9 import loader.agent.safeguards as agent_safeguards
10 from loader.agent.safeguards import RuntimeSafeguards as AgentRuntimeSafeguards
11 from loader.runtime.safeguard_services import (
12 ActionTracker,
13 PreActionValidator,
14 ValidationResult,
15 )
16 from loader.runtime.safeguards import RuntimeSafeguards
17 from loader.runtime.semantic_rules.html_toc import (
18 build_html_toc_edit_call_template,
19 build_html_toc_replacement_block,
20 format_html_inventory_entry,
21 task_targets_html_toc,
22 validate_html_toc,
23 )
24
25
26 def test_action_tracker_detects_duplicate_write_after_recording(tmp_path) -> None:
27 tracker = ActionTracker()
28 file_path = tmp_path / "notes.txt"
29 arguments = {"file_path": str(file_path), "content": "alpha\n"}
30
31 assert tracker.check_tool_call("write", arguments) == (False, "")
32
33 tracker.record_tool_call("write", arguments)
34
35 is_duplicate, reason = tracker.check_tool_call("write", arguments)
36
37 assert is_duplicate is True
38 assert str(file_path) in reason
39
40
41 def test_task_targets_html_toc_requires_explicit_repair_intent() -> None:
42 prompt = (
43 "Have a look at ~/Loader/guides/fortran and chapters/ within. Get a feel "
44 "for the structure and cadence of the guide. We are going to make an all "
45 "new equally thorough guide on how to use the nginx tool. It will live in "
46 "~/Loader/guides/nginx/index.html and ~/Loader/guides/nginx/chapters/."
47 )
48
49 assert task_targets_html_toc(prompt) is False
50
51
52 def test_build_html_toc_replacement_block_uses_verified_inventory(tmp_path) -> None:
53 chapters = tmp_path / "chapters"
54 chapters.mkdir()
55 (chapters / "01-introduction.html").write_text(
56 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
57 )
58 (chapters / "02-setup.html").write_text(
59 "<h1>Chapter 2: Setting Up Your Environment</h1>\n"
60 )
61 index_path = tmp_path / "index.html"
62 index_path.write_text(
63 "<h2>Table of Contents</h2>\n"
64 "<ul class=\"chapter-list\">\n"
65 " <li><a href=\"chapters/01-old.html\">Chapter 1: Old</a></li>\n"
66 "</ul>\n"
67 )
68
69 replacement = build_html_toc_replacement_block(index_path)
70
71 assert replacement is not None
72 assert "<h2>Table of Contents</h2>" in replacement
73 assert '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>' in replacement
74 assert '<li><a href="chapters/02-setup.html">Chapter 2: Setting Up Your Environment</a></li>' in replacement
75
76
77 def test_build_html_toc_edit_call_template_uses_current_and_replacement_blocks(tmp_path) -> None:
78 chapters = tmp_path / "chapters"
79 chapters.mkdir()
80 (chapters / "01-introduction.html").write_text(
81 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
82 )
83 index_path = tmp_path / "index.html"
84 index_path.write_text(
85 "<h2>Table of Contents</h2>\n"
86 '<ul class="chapter-list">\n'
87 ' <li><a href="chapters/01-old.html">Chapter 1: Old</a></li>\n'
88 "</ul>\n"
89 )
90
91 template = build_html_toc_edit_call_template(index_path)
92
93 assert template is not None
94 assert template.startswith("edit(")
95 assert f'file_path="{index_path}"' in template
96 assert 'old_string="""' in template
97 assert 'new_string="""' in template
98 assert '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>' in template
99
100
101 def test_validate_html_toc_reports_missing_and_mismatched_links(tmp_path) -> None:
102 chapters = tmp_path / "chapters"
103 chapters.mkdir()
104 (chapters / "01-introduction.html").write_text(
105 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
106 )
107 index_path = tmp_path / "index.html"
108 index_path.write_text(
109 '<ul class="chapter-list">\n'
110 ' <li><a href="chapters/01-introduction.html">Chapter 1: Wrong Title</a></li>\n'
111 ' <li><a href="chapters/02-missing.html">Chapter 2: Missing</a></li>\n'
112 "</ul>\n"
113 )
114
115 result = validate_html_toc(index_path)
116
117 assert result is not None
118 assert result.valid is False
119 assert result.link_count == 2
120 assert result.missing == ("chapters/02-missing.html -> missing",)
121 assert (
122 result.mismatched
123 == (
124 "chapters/01-introduction.html -> Chapter 1: Wrong Title != Chapter 1: Introduction to Fortran",
125 )
126 )
127
128
129 def test_validate_html_toc_reports_success(tmp_path) -> None:
130 chapters = tmp_path / "chapters"
131 chapters.mkdir()
132 (chapters / "01-introduction.html").write_text(
133 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
134 )
135 (chapters / "02-setup.html").write_text(
136 "<h1>Chapter 2: Setting Up Your Environment</h1>\n"
137 )
138 index_path = tmp_path / "index.html"
139 index_path.write_text(
140 '<ul class="chapter-list">\n'
141 ' <li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>\n'
142 ' <li><a href="chapters/02-setup.html">Chapter 2: Setting Up Your Environment</a></li>\n'
143 "</ul>\n"
144 )
145
146 result = validate_html_toc(index_path)
147
148 assert result is not None
149 assert result.valid is True
150 assert result.link_count == 2
151 assert result.missing == ()
152 assert result.mismatched == ()
153
154
155 def test_action_tracker_preserves_loop_description_format() -> None:
156 tracker = ActionTracker()
157
158 tracker.record_tool_call("read", {"file_path": "a.txt"})
159 tracker.record_tool_call("grep", {"pattern": "alpha"})
160 tracker.record_tool_call("read", {"file_path": "b.txt"})
161 tracker.record_tool_call("grep", {"pattern": "beta"})
162
163 is_loop, description = tracker.detect_loop()
164
165 assert is_loop is True
166 assert description == "Repeating pattern detected (2x): read → grep"
167
168
169 def test_action_tracker_blocks_repeated_bash_observation_without_changes() -> None:
170 tracker = ActionTracker()
171 arguments = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
172
173 tracker.record_tool_call("bash", arguments)
174
175 is_duplicate, reason = tracker.check_tool_call("bash", arguments)
176
177 assert is_duplicate is True
178 assert "read-only shell probe" in reason
179
180
181 def test_action_tracker_allows_repeated_bash_observation_after_mutation() -> None:
182 tracker = ActionTracker()
183 bash_args = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
184 patch_args = {
185 "file_path": "~/Loader/guides/fortran/chapters/01-introduction.html",
186 "hunks": [
187 {
188 "old_start": 1,
189 "old_lines": 1,
190 "new_start": 1,
191 "new_lines": 1,
192 "lines": ["-old", "+new"],
193 }
194 ],
195 }
196
197 tracker.record_tool_call("bash", bash_args)
198 tracker.record_tool_call("patch", patch_args)
199
200 assert tracker.check_tool_call("bash", bash_args) == (False, "")
201
202
203 def test_action_tracker_blocks_repeated_bash_observation_after_unrelated_mutation() -> None:
204 tracker = ActionTracker()
205 bash_args = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
206 mkdir_args = {"command": "mkdir -p ~/Loader/guides/nginx/chapters"}
207
208 tracker.record_tool_call("bash", bash_args)
209 tracker.record_tool_call("bash", bash_args)
210 tracker.record_tool_call("bash", mkdir_args)
211
212 is_duplicate, reason = tracker.check_tool_call("bash", bash_args)
213
214 assert is_duplicate is True
215 assert "relevant intervening changes" in reason
216
217
218 def test_action_tracker_blocks_repeated_read_without_changes(tmp_path) -> None:
219 tracker = ActionTracker()
220 file_path = tmp_path / "index.html"
221 arguments = {"file_path": str(file_path)}
222
223 tracker.record_tool_call("read", arguments)
224
225 is_duplicate, reason = tracker.check_tool_call("read", arguments)
226
227 assert is_duplicate is True
228 assert str(file_path) in reason
229
230
231 def test_action_tracker_allows_one_interleaved_reread_without_changes(tmp_path) -> None:
232 tracker = ActionTracker()
233 index_path = tmp_path / "index.html"
234 chapter_path = tmp_path / "chapter-1.html"
235
236 tracker.record_tool_call("read", {"file_path": str(index_path)})
237 tracker.record_tool_call("read", {"file_path": str(chapter_path)})
238
239 assert tracker.check_tool_call("read", {"file_path": str(index_path)}) == (False, "")
240
241
242 def test_action_tracker_allows_reading_a_different_slice_of_the_same_file(tmp_path) -> None:
243 tracker = ActionTracker()
244 index_path = tmp_path / "index.html"
245
246 tracker.record_tool_call("read", {"file_path": str(index_path)})
247
248 assert tracker.check_tool_call(
249 "read",
250 {"file_path": str(index_path), "offset": 1, "limit": 50},
251 ) == (False, "")
252
253
254 def test_action_tracker_blocks_fourth_interleaved_reread_without_changes(tmp_path) -> None:
255 tracker = ActionTracker()
256 index_path = tmp_path / "index.html"
257 chapter_a = tmp_path / "chapter-1.html"
258 chapter_b = tmp_path / "chapter-2.html"
259 chapter_c = tmp_path / "chapter-3.html"
260
261 tracker.record_tool_call("read", {"file_path": str(index_path)})
262 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
263 tracker.record_tool_call("read", {"file_path": str(index_path)})
264 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
265 tracker.record_tool_call("read", {"file_path": str(index_path)})
266 tracker.record_tool_call("read", {"file_path": str(chapter_c)})
267
268 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
269
270 assert is_duplicate is True
271 assert str(index_path) in reason
272
273
274 def test_action_tracker_blocks_reread_after_unrelated_target_mutation(tmp_path) -> None:
275 tracker = ActionTracker()
276 reference_index = tmp_path / "fortran" / "index.html"
277 target_chapters = tmp_path / "nginx" / "chapters"
278 chapter_a = tmp_path / "fortran" / "chapters" / "chapter-1.html"
279 chapter_b = tmp_path / "fortran" / "chapters" / "chapter-2.html"
280
281 tracker.record_tool_call("read", {"file_path": str(reference_index)})
282 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
283 tracker.record_tool_call("read", {"file_path": str(reference_index)})
284 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
285 tracker.record_tool_call("read", {"file_path": str(reference_index)})
286 tracker.record_tool_call("bash", {"command": f"mkdir -p {target_chapters}"})
287
288 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(reference_index)})
289
290 assert is_duplicate is True
291 assert "relevant intervening changes" in reason
292
293
294 def test_action_tracker_blocks_repeated_search_after_unrelated_target_mutation(tmp_path) -> None:
295 tracker = ActionTracker()
296 reference_chapters = tmp_path / "fortran" / "chapters"
297 target_chapters = tmp_path / "nginx" / "chapters"
298 search_args = {"pattern": "*.html", "path": str(reference_chapters)}
299
300 tracker.record_tool_call("glob", search_args)
301 tracker.record_tool_call("glob", search_args)
302 tracker.record_tool_call("bash", {"command": f"mkdir -p {target_chapters}"})
303
304 is_duplicate, reason = tracker.check_tool_call("glob", search_args)
305
306 assert is_duplicate is True
307 assert "relevant intervening changes" in reason
308
309
310 def test_action_tracker_allows_one_target_index_reread_after_chapter_discovery(tmp_path) -> None:
311 tracker = ActionTracker()
312 index_path = tmp_path / "index.html"
313 chapters = tmp_path / "chapters"
314 chapter_a = chapters / "01-introduction.html"
315 chapter_b = chapters / "02-setup.html"
316 chapter_c = chapters / "03-basics.html"
317
318 tracker.record_tool_call("read", {"file_path": str(index_path)})
319 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
320 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
321 tracker.record_tool_call("read", {"file_path": str(chapter_c)})
322
323 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
324
325 assert is_duplicate is False
326 assert reason == ""
327
328
329 def test_action_tracker_blocks_second_target_index_reread_after_chapter_discovery(tmp_path) -> None:
330 tracker = ActionTracker()
331 index_path = tmp_path / "index.html"
332 chapters = tmp_path / "chapters"
333
334 tracker.record_tool_call("read", {"file_path": str(index_path)})
335 tracker.record_tool_call("read", {"file_path": str(chapters / "01-introduction.html")})
336 tracker.record_tool_call("read", {"file_path": str(chapters / "02-setup.html")})
337 tracker.record_tool_call("read", {"file_path": str(chapters / "03-basics.html")})
338 tracker.record_tool_call("read", {"file_path": str(index_path)})
339
340 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
341
342 assert is_duplicate is True
343 assert "reuse the earlier read result instead of rereading" in reason
344
345
346 def test_action_tracker_blocks_repeated_chapter_directory_search_once_titles_are_known(
347 tmp_path,
348 ) -> None:
349 tracker = ActionTracker()
350 chapters = tmp_path / "chapters"
351 search_args = {"pattern": "*.html", "path": str(chapters)}
352
353 tracker.record_tool_call("glob", search_args)
354 tracker.record_tool_call("glob", search_args)
355
356 is_duplicate, reason = tracker.check_tool_call("glob", search_args)
357
358 assert is_duplicate is True
359 assert "reuse the earlier search result instead of rerunning it" in reason
360
361
362 def test_action_tracker_allows_repeated_read_after_mutation(tmp_path) -> None:
363 tracker = ActionTracker()
364 file_path = tmp_path / "index.html"
365 read_args = {"file_path": str(file_path)}
366 edit_args = {
367 "file_path": str(file_path),
368 "old_string": "old",
369 "new_string": "new",
370 }
371
372 tracker.record_tool_call("read", read_args)
373 tracker.record_tool_call("edit", edit_args)
374
375 assert tracker.check_tool_call("read", read_args) == (False, "")
376
377
378 def test_pre_action_validator_blocks_patch_without_hunks() -> None:
379 validator = PreActionValidator()
380
381 result = validator.validate(
382 "patch",
383 {"file_path": "notes.txt", "hunks": []},
384 )
385
386 assert result == ValidationResult(
387 valid=False,
388 reason="Patch hunks are missing",
389 suggestion="Provide structured patch hunks or a unified diff patch string",
390 severity="error",
391 )
392
393
394 def test_pre_action_validator_allows_patch_string_without_hunks() -> None:
395 validator = PreActionValidator()
396
397 result = validator.validate(
398 "patch",
399 {
400 "file_path": "notes.txt",
401 "patch": "--- a/notes.txt\n+++ b/notes.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
402 },
403 )
404
405 assert result == ValidationResult(valid=True)
406
407
408 def test_pre_action_validator_allows_json_encoded_patch_hunks() -> None:
409 validator = PreActionValidator()
410
411 result = validator.validate(
412 "patch",
413 {
414 "file_path": "notes.txt",
415 "hunks": json.dumps(
416 [
417 {
418 "old_start": 1,
419 "old_lines": 1,
420 "new_start": 1,
421 "new_lines": 1,
422 "lines": ["-old", "+new"],
423 }
424 ]
425 ),
426 },
427 )
428
429 assert result == ValidationResult(valid=True)
430
431
432 def test_pre_action_validator_allows_json_patch_hunks_missing_outer_close() -> None:
433 validator = PreActionValidator()
434 hunk_payload = json.dumps(
435 [
436 {
437 "old_start": 1,
438 "old_lines": 1,
439 "new_start": 1,
440 "new_lines": 1,
441 "lines": ["-old", "+new"],
442 }
443 ]
444 )[:-1]
445
446 result = validator.validate(
447 "patch",
448 {
449 "file_path": "notes.txt",
450 "hunks": hunk_payload,
451 },
452 )
453
454 assert result == ValidationResult(valid=True)
455
456
457 def test_pre_action_validator_allows_python_literal_patch_hunks() -> None:
458 validator = PreActionValidator()
459
460 result = validator.validate(
461 "patch",
462 {
463 "file_path": "notes.txt",
464 "hunks": repr(
465 [
466 {
467 "old_start": 1,
468 "old_lines": 1,
469 "new_start": 1,
470 "new_lines": 1,
471 "lines": ["-old", "+new"],
472 }
473 ]
474 ),
475 },
476 )
477
478 assert result == ValidationResult(valid=True)
479
480
481 def test_pre_action_validator_blocks_placeholder_html_write(tmp_path: Path) -> None:
482 validator = PreActionValidator()
483
484 result = validator.validate(
485 "write",
486 {
487 "file_path": str(tmp_path / "guide" / "chapters" / "01-introduction.html"),
488 "content": (
489 "<html><body><h1>Introduction</h1>"
490 "<p>Starter content for this chapter.</p>"
491 "<h2>Overview</h2><p>Key concepts go here.</p>"
492 "</body></html>"
493 ),
494 },
495 )
496
497 assert result.valid is False
498 assert result.reason == "HTML content contains placeholder or stub text"
499 assert "concrete user-facing content" in result.suggestion
500 assert "starter content" in result.suggestion
501
502
503 def test_pre_action_validator_blocks_generic_html_chapter_scaffold(
504 tmp_path: Path,
505 ) -> None:
506 validator = PreActionValidator()
507
508 result = validator.validate(
509 "write",
510 {
511 "file_path": str(tmp_path / "guide" / "chapters" / "04-reverse-proxy.html"),
512 "content": (
513 "<html><body><h1>Chapter 4: Reverse Proxy Setup</h1>"
514 "<p>Chapter 4: Reverse Proxy Setup frames the chapter topic "
515 "and connects it to the guide workflow.</p>"
516 "<h2>Core Concepts</h2>"
517 "<p>This section introduces the essential ideas, configuration "
518 "choices, and operational tradeoffs.</p>"
519 "<h2>Practical Workflow</h2>"
520 "<p>This section walks through the actions, checks, and expected "
521 "outcomes for a real environment.</p>"
522 "</body></html>"
523 ),
524 },
525 )
526
527 assert result.valid is False
528 assert result.reason == "HTML content contains placeholder or stub text"
529 assert "frames the chapter topic" in result.suggestion
530 assert "generic core concepts section" in result.suggestion
531
532
533 def test_pre_action_validator_blocks_placeholder_html_edit(tmp_path: Path) -> None:
534 validator = PreActionValidator()
535 page = tmp_path / "guide" / "chapters" / "01-introduction.html"
536 page.parent.mkdir(parents=True)
537 page.write_text("<html><body><h1>Introduction</h1></body></html>")
538
539 result = validator.validate(
540 "edit",
541 {
542 "file_path": str(page),
543 "old_string": "</body>",
544 "new_string": "<p>Practical steps go here.</p></body>",
545 },
546 )
547
548 assert result.valid is False
549 assert result.reason == "HTML content contains placeholder or stub text"
550 assert "practical steps go here" in result.suggestion
551
552
553 def test_pre_action_validator_blocks_placeholder_html_patch(tmp_path: Path) -> None:
554 validator = PreActionValidator()
555
556 result = validator.validate(
557 "patch",
558 {
559 "file_path": str(tmp_path / "guide" / "chapters" / "02-installation.html"),
560 "patch": (
561 "--- a/guide/chapters/02-installation.html\n"
562 "+++ b/guide/chapters/02-installation.html\n"
563 "@@ -1,1 +1,2 @@\n"
564 " <h1>Installation</h1>\n"
565 "+<p>Coming soon.</p>\n"
566 ),
567 },
568 )
569
570 assert result.valid is False
571 assert result.reason == "HTML content contains placeholder or stub text"
572 assert "coming soon" in result.suggestion
573
574
575 def test_pre_action_validator_blocks_html_patch_that_removes_closing_body(
576 tmp_path: Path,
577 ) -> None:
578 validator = PreActionValidator()
579 page = tmp_path / "guide" / "chapters" / "02-installation.html"
580 page.parent.mkdir(parents=True)
581 page.write_text("<html>\n<body>\n<h1>Installation</h1>\n</body>\n</html>\n")
582
583 result = validator.validate(
584 "patch",
585 {
586 "file_path": str(page),
587 "hunks": [
588 {
589 "old_start": 4,
590 "old_lines": 1,
591 "new_start": 4,
592 "new_lines": 0,
593 "lines": ["-</body>"],
594 }
595 ],
596 },
597 )
598
599 assert result.valid is False
600 assert result.reason == "HTML document structure would be invalid"
601 assert "expected exactly one closing </body> tag (found 0)" in result.suggestion
602 assert (
603 "insert substantive body sections before the current `</body></html>` tail"
604 in result.suggestion
605 )
606
607
608 def test_pre_action_validator_blocks_html_edit_that_wraps_tail_in_new_body(
609 tmp_path: Path,
610 ) -> None:
611 validator = PreActionValidator()
612 page = tmp_path / "guide" / "chapters" / "02-installation.html"
613 page.parent.mkdir(parents=True)
614 page.write_text("<html><body><h1>Installation</h1></body></html>")
615
616 result = validator.validate(
617 "edit",
618 {
619 "file_path": str(page),
620 "old_string": "</body></html>",
621 "new_string": (
622 "<body><h1>Installation</h1>"
623 "<p>Short replacement fragment.</p></body></html>"
624 ),
625 },
626 )
627
628 assert result.valid is False
629 assert result.reason == "HTML document structure would be invalid"
630 assert "expected exactly one opening <body> tag (found 2)" in result.suggestion
631
632
633 def test_pre_action_validator_allows_html_edit_inserted_before_closing_tail(
634 tmp_path: Path,
635 ) -> None:
636 validator = PreActionValidator()
637 page = tmp_path / "guide" / "chapters" / "02-installation.html"
638 page.parent.mkdir(parents=True)
639 page.write_text("<html><body><h1>Installation</h1></body></html>")
640
641 result = validator.validate(
642 "edit",
643 {
644 "file_path": str(page),
645 "old_string": "</body></html>",
646 "new_string": (
647 "<section><h2>Package Managers</h2>"
648 "<p>Install Nginx with the package manager for your platform.</p>"
649 "</section></body></html>"
650 ),
651 },
652 )
653
654 assert result.valid is True
655
656
657 def test_pre_action_validator_blocks_missing_local_html_asset_href(tmp_path: Path) -> None:
658 validator = PreActionValidator()
659 page = tmp_path / "guide" / "chapters" / "07-performance.html"
660
661 result = validator.validate(
662 "write",
663 {
664 "file_path": str(page),
665 "content": (
666 '<html><head><link rel="stylesheet" href="../styles.css"></head>'
667 "<body><h1>Performance</h1><p>Concrete content.</p></body></html>"
668 ),
669 },
670 )
671
672 assert result.valid is False
673 assert result.reason == "HTML local asset references do not exist"
674 assert "../styles.css" in result.suggestion
675 assert "create the referenced asset first" in result.suggestion
676
677
678 def test_pre_action_validator_allows_existing_local_html_asset_href(
679 tmp_path: Path,
680 ) -> None:
681 validator = PreActionValidator()
682 guide = tmp_path / "guide"
683 chapters = guide / "chapters"
684 chapters.mkdir(parents=True)
685 (guide / "styles.css").write_text("body { color: #222; }\n")
686
687 result = validator.validate(
688 "write",
689 {
690 "file_path": str(chapters / "07-performance.html"),
691 "content": (
692 '<html><head><link rel="stylesheet" href="../styles.css"></head>'
693 "<body><h1>Performance</h1><p>Concrete content.</p></body></html>"
694 ),
695 },
696 )
697
698 assert result.valid is True
699
700
701 def test_pre_action_validator_blocks_thin_root_declared_guide_chapter(
702 tmp_path: Path,
703 ) -> None:
704 validator = PreActionValidator()
705 guide = tmp_path / "guides" / "nginx"
706 chapters = guide / "chapters"
707 chapters.mkdir(parents=True)
708 (guide / "index.html").write_text(
709 "<html><body>"
710 '<a href="chapters/01-introduction.html">Introduction</a>'
711 '<a href="chapters/02-installation.html">Installation</a>'
712 '<a href="chapters/03-configuration.html">Configuration</a>'
713 '<a href="chapters/04-reverse-proxy.html">Reverse Proxy</a>'
714 "</body></html>"
715 )
716
717 result = validator.validate(
718 "write",
719 {
720 "file_path": str(chapters / "01-introduction.html"),
721 "content": (
722 '<!DOCTYPE html><html lang="en"><body><h1>Introduction</h1>'
723 "<p>Nginx is a web server and reverse proxy.</p>"
724 '<p><a href="../index.html">Back</a></p></body></html>'
725 ),
726 },
727 )
728
729 assert result.valid is False
730 assert result.reason == "HTML guide chapter content is too thin"
731 assert "root-declared multi-page guide chapter" in result.suggestion
732 assert "text chars" in result.suggestion
733 assert "structured block" in result.suggestion
734
735
736 def test_pre_action_validator_allows_small_non_guide_html_chapter(
737 tmp_path: Path,
738 ) -> None:
739 validator = PreActionValidator()
740 page = tmp_path / "scratch" / "chapters" / "01-note.html"
741 page.parent.mkdir(parents=True)
742
743 result = validator.validate(
744 "write",
745 {
746 "file_path": str(page),
747 "content": (
748 '<!DOCTYPE html><html lang="en"><body><h1>Note</h1>'
749 "<p>Small standalone HTML is allowed outside a root-declared guide.</p>"
750 "</body></html>"
751 ),
752 },
753 )
754
755 assert result.valid is True
756
757
758 def test_pre_action_validator_allows_substantive_root_declared_guide_chapter(
759 tmp_path: Path,
760 ) -> None:
761 validator = PreActionValidator()
762 guide = tmp_path / "guides" / "nginx"
763 chapters = guide / "chapters"
764 chapters.mkdir(parents=True)
765 (guide / "index.html").write_text(
766 "<html><body>"
767 '<a href="chapters/01-introduction.html">Introduction</a>'
768 '<a href="chapters/02-installation.html">Installation</a>'
769 '<a href="chapters/03-configuration.html">Configuration</a>'
770 '<a href="chapters/04-reverse-proxy.html">Reverse Proxy</a>'
771 "</body></html>"
772 )
773 paragraph = (
774 "Concrete Nginx guide content with commands, examples, tradeoffs, "
775 "checks, and operational context. "
776 )
777 sections = "".join(
778 (
779 f"<h2>Section {index}</h2>"
780 f"<p>{paragraph * 3}</p>"
781 f"<ul><li>Action {index}</li><li>Verification {index}</li></ul>"
782 )
783 for index in range(1, 8)
784 )
785
786 result = validator.validate(
787 "write",
788 {
789 "file_path": str(chapters / "01-introduction.html"),
790 "content": (
791 f'<!DOCTYPE html><html lang="en"><body><h1>Introduction</h1>{sections}'
792 '<p><a href="../index.html">Back</a></p></body></html>'
793 ),
794 },
795 )
796
797 assert result.valid is True
798
799
800 def test_pre_action_validator_blocks_new_root_index_parent_html_link(
801 tmp_path: Path,
802 ) -> None:
803 validator = PreActionValidator()
804 guide = tmp_path / "guides" / "nginx"
805 guide.mkdir(parents=True)
806
807 result = validator.validate(
808 "write",
809 {
810 "file_path": str(guide / "index.html"),
811 "content": (
812 '<html><body><a href="chapters/01-introduction.html">Intro</a>'
813 '<p><a href="../index.html">Back to Main Index</a></p></body></html>'
814 ),
815 },
816 )
817
818 assert result.valid is False
819 assert result.reason == "HTML page links outside the current artifact root"
820 assert "../index.html" in result.suggestion
821
822
823 def test_pre_action_validator_allows_new_root_index_to_seed_child_html_links(
824 tmp_path: Path,
825 ) -> None:
826 validator = PreActionValidator()
827 guide = tmp_path / "guides" / "nginx"
828 guide.mkdir(parents=True)
829
830 result = validator.validate(
831 "write",
832 {
833 "file_path": str(guide / "index.html"),
834 "content": (
835 '<html><body><a href="chapters/01-introduction.html">Intro</a>'
836 '<a href="chapters/02-installation.html">Install</a></body></html>'
837 ),
838 },
839 )
840
841 assert result.valid is True
842
843
844 def test_pre_action_validator_blocks_existing_root_write_with_far_missing_links(
845 tmp_path: Path,
846 ) -> None:
847 validator = PreActionValidator()
848 guide = tmp_path / "guides" / "nginx"
849 chapters = guide / "chapters"
850 chapters.mkdir(parents=True)
851 for index, name in enumerate(
852 [
853 "introduction",
854 "installation",
855 "configuration",
856 "virtual-hosts",
857 "reverse-proxy",
858 "load-balancing",
859 "security",
860 ],
861 start=1,
862 ):
863 (chapters / f"{index:02d}-{name}.html").write_text("<html></html>\n")
864 index = guide / "index.html"
865 index.write_text(
866 "\n".join(
867 f'<li><a href="chapters/{path.name}">Chapter {number}</a></li>'
868 for number, path in enumerate(sorted(chapters.glob("*.html")), start=1)
869 )
870 )
871
872 result = validator.validate(
873 "write",
874 {
875 "file_path": str(index),
876 "content": (
877 index.read_text()
878 + "\n"
879 '<li><a href="chapters/08-performance.html">Chapter 8</a></li>\n'
880 '<li><a href="chapters/09-ssl.html">Chapter 9</a></li>\n'
881 '<li><a href="chapters/10-monitoring.html">Chapter 10</a></li>\n'
882 ),
883 },
884 )
885
886 assert result.valid is False
887 assert result.reason == "Edited HTML links point to files that do not exist"
888 assert "chapters/09-ssl.html" in result.suggestion
889
890
891 def test_pre_action_validator_blocks_toc_inflation_with_duplicate_page_links(
892 tmp_path: Path,
893 ) -> None:
894 validator = PreActionValidator()
895 guide = tmp_path / "guides" / "nginx"
896 chapters = guide / "chapters"
897 chapters.mkdir(parents=True)
898 security = chapters / "07-security.html"
899 security.write_text("<html></html>\n")
900 index = guide / "index.html"
901 index.write_text(
902 "\n".join(
903 [
904 "<ul>",
905 '<li><a href="chapters/07-security.html">Chapter 7: Security</a></li>',
906 "</ul>",
907 "",
908 ]
909 )
910 )
911
912 result = validator.validate(
913 "edit",
914 {
915 "file_path": str(index),
916 "old_string": "</ul>",
917 "new_string": (
918 '<li><a href="chapters/07-security.html">Chapter 8: Performance</a></li>\n'
919 '<li><a href="chapters/07-security.html">Chapter 9: SSL</a></li>\n'
920 '<li><a href="chapters/07-security.html">Chapter 10: Monitoring</a></li>\n'
921 "</ul>"
922 ),
923 },
924 )
925
926 assert result.valid is False
927 assert (
928 result.reason
929 == "HTML root page repeats one local page as multiple distinct links"
930 )
931 assert "Do not inflate a root index or table of contents" in result.suggestion
932 assert "chapters/07-security.html" in result.suggestion
933
934
935 def test_pre_action_validator_blocks_shell_text_rewrite_for_html_target() -> None:
936 validator = PreActionValidator()
937
938 result = validator.validate(
939 "bash",
940 {
941 "command": (
942 "cd /tmp/fortran-qwen-recovery-check && "
943 "sed -i '1,3c\\<li>updated</li>' index.html"
944 )
945 },
946 )
947
948 assert result.valid is False
949 assert result.reason == (
950 "Shell-based text rewrites are brittle and bypass Loader's safer file tools"
951 )
952 assert "edit/patch/write" in result.suggestion
953 assert "index.html" in result.suggestion
954
955
956 def test_pre_action_validator_allows_non_mutating_sed_probe() -> None:
957 validator = PreActionValidator()
958
959 result = validator.validate(
960 "bash",
961 {"command": "sed -n '1,20p' index.html"},
962 )
963
964 assert result == ValidationResult(valid=True)
965
966
967 def test_pre_action_validator_blocks_index_edit_with_missing_chapter_href(tmp_path) -> None:
968 validator = PreActionValidator()
969 index = tmp_path / "index.html"
970 chapters = tmp_path / "chapters"
971 chapters.mkdir()
972 (chapters / "05-input-output.html").write_text(
973 "<h1>Chapter 5: Input and Output</h1>\n"
974 )
975
976 result = validator.validate(
977 "edit",
978 {
979 "file_path": str(index),
980 "old_string": '<li><a href="chapters/05-input-output.html">Chapter 5: Input and Output</a></li>',
981 "new_string": '<li><a href="chapters/05-control-structures.html">Chapter 5: Control Structures</a></li>',
982 },
983 )
984
985 assert result.valid is False
986 assert result.reason == "Edited HTML links point to files that do not exist"
987 assert "chapters/05-control-structures.html" in result.suggestion
988 assert "remove the broken link" in result.suggestion
989 assert "for example fix" not in result.suggestion
990
991
992 def test_pre_action_validator_allows_incomplete_root_index_to_reshape_missing_child_target(
993 tmp_path: Path,
994 ) -> None:
995 validator = PreActionValidator()
996 guide = tmp_path / "guide"
997 chapters = guide / "chapters"
998 chapters.mkdir(parents=True)
999 index = guide / "index.html"
1000 index.write_text(
1001 "\n".join(
1002 [
1003 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a></li>',
1004 '<li><a href="chapters/02-installation.html">Chapter 2: Installation on POSIX Systems</a></li>',
1005 '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
1006 "",
1007 ]
1008 )
1009 )
1010 (chapters / "01-introduction.html").write_text("<html></html>\n")
1011 (chapters / "02-installation.html").write_text("<html></html>\n")
1012
1013 result = validator.validate(
1014 "edit",
1015 {
1016 "file_path": str(index),
1017 "old_string": '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
1018 "new_string": '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration Basics</a></li>',
1019 },
1020 )
1021
1022 assert result.valid is True
1023
1024
1025 def test_pre_action_validator_allows_root_index_to_add_next_ordered_missing_sibling(
1026 tmp_path: Path,
1027 ) -> None:
1028 validator = PreActionValidator()
1029 guide = tmp_path / "guide"
1030 chapters = guide / "chapters"
1031 chapters.mkdir(parents=True)
1032 index = guide / "index.html"
1033 index.write_text(
1034 "\n".join(
1035 [
1036 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
1037 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
1038 '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
1039 '<li><a href="chapters/04-usage.html">Chapter 4: Usage</a></li>',
1040 '<li><a href="chapters/05-advanced-topics.html">Chapter 5: Advanced Topics</a></li>',
1041 "",
1042 ]
1043 )
1044 )
1045 for name in [
1046 "01-introduction.html",
1047 "02-installation.html",
1048 "03-configuration.html",
1049 "04-usage.html",
1050 "05-advanced-topics.html",
1051 ]:
1052 (chapters / name).write_text("<html></html>\n")
1053
1054 result = validator.validate(
1055 "edit",
1056 {
1057 "file_path": str(index),
1058 "old_string": (
1059 '<li><a href="chapters/05-advanced-topics.html">'
1060 "Chapter 5: Advanced Topics</a></li>"
1061 ),
1062 "new_string": (
1063 '<li><a href="chapters/05-advanced-topics.html">'
1064 "Chapter 5: Advanced Topics</a></li>\n"
1065 '<li><a href="chapters/06-troubleshooting.html">'
1066 "Chapter 6: Troubleshooting</a></li>"
1067 ),
1068 },
1069 )
1070
1071 assert result.valid is True
1072
1073
1074 def test_pre_action_validator_blocks_root_index_from_skipping_to_far_missing_sibling(
1075 tmp_path: Path,
1076 ) -> None:
1077 validator = PreActionValidator()
1078 guide = tmp_path / "guide"
1079 chapters = guide / "chapters"
1080 chapters.mkdir(parents=True)
1081 index = guide / "index.html"
1082 index.write_text(
1083 "\n".join(
1084 [
1085 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
1086 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
1087 '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
1088 "",
1089 ]
1090 )
1091 )
1092 for name in [
1093 "01-introduction.html",
1094 "02-installation.html",
1095 "03-configuration.html",
1096 ]:
1097 (chapters / name).write_text("<html></html>\n")
1098
1099 result = validator.validate(
1100 "edit",
1101 {
1102 "file_path": str(index),
1103 "old_string": (
1104 '<li><a href="chapters/03-configuration.html">'
1105 "Chapter 3: Configuration</a></li>"
1106 ),
1107 "new_string": (
1108 '<li><a href="chapters/03-configuration.html">'
1109 "Chapter 3: Configuration</a></li>\n"
1110 '<li><a href="chapters/08-troubleshooting.html">'
1111 "Chapter 8: Troubleshooting</a></li>"
1112 ),
1113 },
1114 )
1115
1116 assert result.valid is False
1117 assert result.reason == "Edited HTML links point to files that do not exist"
1118 assert "chapters/08-troubleshooting.html" in result.suggestion
1119 assert "remove the broken link" in result.suggestion
1120 assert "for example fix" not in result.suggestion
1121
1122
1123 def test_pre_action_validator_missing_html_link_suggestion_prefers_existing_targets(
1124 tmp_path: Path,
1125 ) -> None:
1126 validator = PreActionValidator()
1127 guide = tmp_path / "guide"
1128 chapters = guide / "chapters"
1129 chapters.mkdir(parents=True)
1130 index = guide / "index.html"
1131 current = "\n".join(
1132 [
1133 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
1134 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
1135 '<p><a href="../index.html">Back</a></p>',
1136 "",
1137 ]
1138 )
1139 index.write_text(current)
1140 (chapters / "01-introduction.html").write_text("<html></html>\n")
1141 (chapters / "02-installation.html").write_text("<html></html>\n")
1142
1143 result = validator.validate(
1144 "edit",
1145 {
1146 "file_path": str(index),
1147 "old_string": '<p><a href="../index.html">Back</a></p>',
1148 "new_string": '<p><a href="../../index.html">Back</a></p>',
1149 },
1150 )
1151
1152 assert result.valid is False
1153 assert result.reason == "Edited HTML links point to files that do not exist"
1154 assert "Broken href(s): ../../index.html" in result.suggestion
1155 assert "chapters/01-introduction.html" in result.suggestion
1156 assert "chapters/02-installation.html" in result.suggestion
1157 assert "for example fix: ../../index.html" not in result.suggestion
1158
1159
1160 def test_pre_action_validator_blocks_index_edit_with_title_mismatch(tmp_path) -> None:
1161 validator = PreActionValidator()
1162 index = tmp_path / "index.html"
1163 chapters = tmp_path / "chapters"
1164 chapters.mkdir()
1165 (chapters / "12-troubleshooting-tips.html").write_text(
1166 "<h1>Chapter 12: Troubleshooting and Tips</h1>\n"
1167 )
1168
1169 result = validator.validate(
1170 "edit",
1171 {
1172 "file_path": str(index),
1173 "old_string": '<li><a href="chapters/12-troubleshooting-tips.html">Chapter 12: Troubleshooting and Tips</a></li>',
1174 "new_string": '<li><a href="chapters/12-troubleshooting-tips.html">Chapter 12: Troubleshooting Tips</a></li>',
1175 },
1176 )
1177
1178 assert result.valid is True
1179
1180
1181 def test_pre_action_validator_allows_chapter_write_with_future_target_declared_by_index(
1182 tmp_path: Path,
1183 ) -> None:
1184 validator = PreActionValidator()
1185 guide = tmp_path / "guide"
1186 chapters = guide / "chapters"
1187 chapters.mkdir(parents=True)
1188 (guide / "index.html").write_text(
1189 "\n".join(
1190 [
1191 '<a href="chapters/introduction.html">Introduction</a>',
1192 '<a href="chapters/installation.html">Installation</a>',
1193 "",
1194 ]
1195 )
1196 )
1197
1198 result = validator.validate(
1199 "write",
1200 {
1201 "file_path": str(chapters / "introduction.html"),
1202 "content": '<a href="installation.html">Next</a>\n',
1203 },
1204 )
1205
1206 assert result.valid is True
1207
1208
1209 def test_pre_action_validator_blocks_undeclared_chapter_file_creation_after_root_seed(
1210 tmp_path: Path,
1211 ) -> None:
1212 validator = PreActionValidator()
1213 guide = tmp_path / "guide"
1214 chapters = guide / "chapters"
1215 chapters.mkdir(parents=True)
1216 (guide / "index.html").write_text(
1217 "\n".join(
1218 [
1219 '<a href="chapters/01-introduction.html">Introduction</a>',
1220 '<a href="chapters/02-installation.html">Installation</a>',
1221 '<a href="chapters/03-configuration.html">Configuration</a>',
1222 "",
1223 ]
1224 )
1225 )
1226 (chapters / "01-introduction.html").write_text("<html></html>\n")
1227 (chapters / "02-installation.html").write_text("<html></html>\n")
1228
1229 result = validator.validate(
1230 "write",
1231 {
1232 "file_path": str(chapters / "09-monitoring.html"),
1233 "content": "<html></html>\n",
1234 },
1235 )
1236
1237 assert result.valid is False
1238 assert result.reason == "HTML file creation falls outside the current declared artifact set"
1239 assert "09-monitoring.html" in result.suggestion
1240 assert str((guide / "index.html").resolve(strict=False)) in result.suggestion
1241
1242
1243 def test_pre_action_validator_allows_declared_missing_chapter_file_creation(
1244 tmp_path: Path,
1245 ) -> None:
1246 validator = PreActionValidator()
1247 guide = tmp_path / "guide"
1248 chapters = guide / "chapters"
1249 chapters.mkdir(parents=True)
1250 (guide / "index.html").write_text(
1251 "\n".join(
1252 [
1253 '<a href="chapters/01-introduction.html">Introduction</a>',
1254 '<a href="chapters/02-installation.html">Installation</a>',
1255 '<a href="chapters/03-configuration.html">Configuration</a>',
1256 "",
1257 ]
1258 )
1259 )
1260 (chapters / "01-introduction.html").write_text("<html></html>\n")
1261
1262 result = validator.validate(
1263 "write",
1264 {
1265 "file_path": str(chapters / "02-installation.html"),
1266 "content": "<html></html>\n",
1267 },
1268 )
1269
1270 assert result.valid is True
1271
1272
1273 def test_pre_action_validator_blocks_undeclared_file_creation_with_closest_declared_target(
1274 tmp_path: Path,
1275 ) -> None:
1276 validator = PreActionValidator()
1277 guide = tmp_path / "guide"
1278 chapters = guide / "chapters"
1279 chapters.mkdir(parents=True)
1280 (guide / "index.html").write_text(
1281 "\n".join(
1282 [
1283 '<a href="chapters/01-introduction.html">Introduction</a>',
1284 '<a href="chapters/02-installation.html">Installation</a>',
1285 '<a href="chapters/03-configuration.html">Configuration</a>',
1286 "",
1287 ]
1288 )
1289 )
1290 (chapters / "01-introduction.html").write_text("<html></html>\n")
1291
1292 result = validator.validate(
1293 "write",
1294 {
1295 "file_path": str(chapters / "02-basics.html"),
1296 "content": "<html></html>\n",
1297 },
1298 )
1299
1300 assert result.valid is False
1301 assert result.reason == "HTML file creation falls outside the current declared artifact set"
1302 assert "Do not create undeclared sibling page `chapters/02-basics.html`" in result.suggestion
1303 assert "Closest declared local targets include: chapters/02-installation.html" in result.suggestion
1304
1305
1306 def test_pre_action_validator_blocks_chapter_write_with_undeclared_missing_sibling(
1307 tmp_path: Path,
1308 ) -> None:
1309 validator = PreActionValidator()
1310 guide = tmp_path / "guide"
1311 chapters = guide / "chapters"
1312 chapters.mkdir(parents=True)
1313 (guide / "index.html").write_text(
1314 "\n".join(
1315 [
1316 '<a href="chapters/introduction.html">Introduction</a>',
1317 '<a href="chapters/installation.html">Installation</a>',
1318 '<a href="chapters/configuration.html">Configuration</a>',
1319 '<a href="chapters/usage.html">Usage</a>',
1320 '<a href="chapters/troubleshooting.html">Troubleshooting</a>',
1321 "",
1322 ]
1323 )
1324 )
1325 (chapters / "introduction.html").write_text('<a href="installation.html">Next</a>\n')
1326 (chapters / "installation.html").write_text('<a href="configuration.html">Next</a>\n')
1327 (chapters / "configuration.html").write_text('<a href="usage.html">Next</a>\n')
1328
1329 result = validator.validate(
1330 "write",
1331 {
1332 "file_path": str(chapters / "usage.html"),
1333 "content": '<a href="advanced.html">Next</a>\n',
1334 },
1335 )
1336
1337 assert result.valid is False
1338 assert (
1339 result.reason
1340 == "HTML page introduces new local targets outside the current declared artifact set"
1341 )
1342 assert "advanced.html" in result.suggestion
1343 assert "Allowed hrefs from this file include:" in result.suggestion
1344 assert "../index.html" in result.suggestion
1345 assert "installation.html" in result.suggestion
1346
1347
1348 def test_pre_action_validator_blocks_chapter_write_with_existing_but_undeclared_sibling(
1349 tmp_path: Path,
1350 ) -> None:
1351 validator = PreActionValidator()
1352 guide = tmp_path / "guide"
1353 chapters = guide / "chapters"
1354 chapters.mkdir(parents=True)
1355 (guide / "index.html").write_text(
1356 "\n".join(
1357 [
1358 '<a href="chapters/01-introduction.html">Introduction</a>',
1359 '<a href="chapters/02-installation.html">Installation</a>',
1360 '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
1361 '<a href="chapters/04-advanced-configuration.html">Advanced Configuration</a>',
1362 "",
1363 ]
1364 )
1365 )
1366 (chapters / "01-introduction.html").write_text('<a href="02-installation.html">Next</a>\n')
1367 (chapters / "02-installation.html").write_text(
1368 '<a href="03-basic-configuration.html">Next</a>\n'
1369 )
1370 (chapters / "04-locations-and-servers.html").write_text(
1371 '<a href="05-static-content.html">Next</a>\n'
1372 )
1373
1374 result = validator.validate(
1375 "write",
1376 {
1377 "file_path": str(chapters / "03-basic-configuration.html"),
1378 "content": '<a href="04-locations-and-servers.html">Next</a>\n',
1379 },
1380 )
1381
1382 assert result.valid is False
1383 assert (
1384 result.reason
1385 == "HTML page introduces new local targets outside the current declared artifact set"
1386 )
1387 assert "04-locations-and-servers.html" in result.suggestion
1388 assert "04-advanced-configuration.html" in result.suggestion
1389 assert "Allowed hrefs from this file include:" in result.suggestion
1390 assert "../index.html" in result.suggestion
1391
1392
1393 def test_pre_action_validator_does_not_suggest_unrelated_declared_html_target(
1394 tmp_path: Path,
1395 ) -> None:
1396 validator = PreActionValidator()
1397 guide = tmp_path / "guide"
1398 chapters = guide / "chapters"
1399 chapters.mkdir(parents=True)
1400 (guide / "index.html").write_text(
1401 "\n".join(
1402 [
1403 '<a href="chapters/introduction.html">Introduction</a>',
1404 '<a href="chapters/installation.html">Installation</a>',
1405 '<a href="chapters/configuration.html">Configuration</a>',
1406 '<a href="chapters/basic-usage.html">Basic Usage</a>',
1407 '<a href="chapters/advanced-topics.html">Advanced Topics</a>',
1408 "",
1409 ]
1410 )
1411 )
1412
1413 result = validator.validate(
1414 "write",
1415 {
1416 "file_path": str(chapters / "introduction.html"),
1417 "content": '<a href="troubleshooting.html">Troubleshooting</a>\n',
1418 },
1419 )
1420
1421 assert result.valid is False
1422 assert "troubleshooting.html" in result.suggestion
1423 assert "Closest declared local targets include:" not in result.suggestion
1424
1425
1426 def test_pre_action_validator_allows_chapter_write_with_root_declared_sibling_and_index_link(
1427 tmp_path: Path,
1428 ) -> None:
1429 validator = PreActionValidator()
1430 guide = tmp_path / "guide"
1431 chapters = guide / "chapters"
1432 chapters.mkdir(parents=True)
1433 (guide / "index.html").write_text(
1434 "\n".join(
1435 [
1436 '<a href="chapters/01-introduction.html">Introduction</a>',
1437 '<a href="chapters/02-installation.html">Installation</a>',
1438 '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
1439 "",
1440 ]
1441 )
1442 )
1443
1444 result = validator.validate(
1445 "write",
1446 {
1447 "file_path": str(chapters / "02-installation.html"),
1448 "content": (
1449 '<a href="../index.html">Back to guide</a>\n'
1450 '<a href="03-basic-configuration.html">Next</a>\n'
1451 ),
1452 },
1453 )
1454
1455 assert result.valid is True
1456
1457
1458 def test_pre_action_validator_blocks_missing_numbered_read_with_existing_sibling(
1459 tmp_path: Path,
1460 ) -> None:
1461 validator = PreActionValidator()
1462 chapters = tmp_path / "chapters"
1463 chapters.mkdir()
1464 (chapters / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
1465
1466 result = validator.validate(
1467 "read",
1468 {"file_path": str(chapters / "01-introduction.html")},
1469 )
1470
1471 assert result.valid is False
1472 assert result.reason == "Read target conflicts with an existing numbered sibling"
1473 assert "01-getting-started.html" in result.suggestion
1474
1475
1476 def test_pre_action_validator_blocks_new_numbered_sibling_drift(tmp_path) -> None:
1477 validator = PreActionValidator()
1478 chapters = tmp_path / "chapters"
1479 chapters.mkdir()
1480 (chapters / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
1481
1482 result = validator.validate(
1483 "write",
1484 {
1485 "file_path": str(chapters / "01-intro.html"),
1486 "content": "<h1>Intro</h1>\n",
1487 },
1488 )
1489
1490 assert result.valid is False
1491 assert result.reason == "New file conflicts with an existing numbered sibling"
1492 assert "01-getting-started.html" in result.suggestion
1493
1494
1495 def test_format_html_inventory_entry_handles_tmp_alias_paths() -> None:
1496 root = Path(tempfile.mkdtemp(dir="/tmp"))
1497 chapters = root / "chapters"
1498 chapters.mkdir()
1499 candidate = chapters / "05-input-output.html"
1500 candidate.write_text("<h1>Chapter 5: Input and Output</h1>\n")
1501
1502 entry = format_html_inventory_entry(root, candidate.resolve(strict=False))
1503
1504 assert entry == "chapters/05-input-output.html = Chapter 5: Input and Output"
1505
1506
1507 def test_runtime_safeguards_wrap_runtime_owned_services() -> None:
1508 safeguards = RuntimeSafeguards()
1509
1510 assert isinstance(safeguards.action_tracker, ActionTracker)
1511 assert isinstance(safeguards.validator, PreActionValidator)
1512
1513
1514 def test_agent_safeguards_reexport_runtime_safeguards() -> None:
1515 assert AgentRuntimeSafeguards is RuntimeSafeguards
1516
1517
1518 def test_agent_safeguards_exports_curated_compatibility_surface() -> None:
1519 assert agent_safeguards.__all__ == [
1520 "ActionTracker",
1521 "CodeBlockFilter",
1522 "FilterResult",
1523 "PatternDetector",
1524 "PatternMatch",
1525 "PreActionValidator",
1526 "RuntimeSafeguards",
1527 "ValidationResult",
1528 ]