Python · 36059 bytes Raw Blame History
1 """Tests for runtime-owned safeguard services."""
2
3 from __future__ import annotations
4
5 import tempfile
6 from pathlib import Path
7
8 import loader.agent.safeguards as agent_safeguards
9 from loader.agent.safeguards import RuntimeSafeguards as AgentRuntimeSafeguards
10 from loader.runtime.safeguard_services import (
11 ActionTracker,
12 PreActionValidator,
13 ValidationResult,
14 )
15 from loader.runtime.safeguards import RuntimeSafeguards
16 from loader.runtime.semantic_rules.html_toc import (
17 build_html_toc_edit_call_template,
18 build_html_toc_replacement_block,
19 format_html_inventory_entry,
20 task_targets_html_toc,
21 validate_html_toc,
22 )
23
24
25 def test_action_tracker_detects_duplicate_write_after_recording(tmp_path) -> None:
26 tracker = ActionTracker()
27 file_path = tmp_path / "notes.txt"
28 arguments = {"file_path": str(file_path), "content": "alpha\n"}
29
30 assert tracker.check_tool_call("write", arguments) == (False, "")
31
32 tracker.record_tool_call("write", arguments)
33
34 is_duplicate, reason = tracker.check_tool_call("write", arguments)
35
36 assert is_duplicate is True
37 assert str(file_path) in reason
38
39
40 def test_task_targets_html_toc_requires_explicit_repair_intent() -> None:
41 prompt = (
42 "Have a look at ~/Loader/guides/fortran and chapters/ within. Get a feel "
43 "for the structure and cadence of the guide. We are going to make an all "
44 "new equally thorough guide on how to use the nginx tool. It will live in "
45 "~/Loader/guides/nginx/index.html and ~/Loader/guides/nginx/chapters/."
46 )
47
48 assert task_targets_html_toc(prompt) is False
49
50
51 def test_build_html_toc_replacement_block_uses_verified_inventory(tmp_path) -> None:
52 chapters = tmp_path / "chapters"
53 chapters.mkdir()
54 (chapters / "01-introduction.html").write_text(
55 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
56 )
57 (chapters / "02-setup.html").write_text(
58 "<h1>Chapter 2: Setting Up Your Environment</h1>\n"
59 )
60 index_path = tmp_path / "index.html"
61 index_path.write_text(
62 "<h2>Table of Contents</h2>\n"
63 "<ul class=\"chapter-list\">\n"
64 " <li><a href=\"chapters/01-old.html\">Chapter 1: Old</a></li>\n"
65 "</ul>\n"
66 )
67
68 replacement = build_html_toc_replacement_block(index_path)
69
70 assert replacement is not None
71 assert "<h2>Table of Contents</h2>" in replacement
72 assert '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>' in replacement
73 assert '<li><a href="chapters/02-setup.html">Chapter 2: Setting Up Your Environment</a></li>' in replacement
74
75
76 def test_build_html_toc_edit_call_template_uses_current_and_replacement_blocks(tmp_path) -> None:
77 chapters = tmp_path / "chapters"
78 chapters.mkdir()
79 (chapters / "01-introduction.html").write_text(
80 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
81 )
82 index_path = tmp_path / "index.html"
83 index_path.write_text(
84 "<h2>Table of Contents</h2>\n"
85 '<ul class="chapter-list">\n'
86 ' <li><a href="chapters/01-old.html">Chapter 1: Old</a></li>\n'
87 "</ul>\n"
88 )
89
90 template = build_html_toc_edit_call_template(index_path)
91
92 assert template is not None
93 assert template.startswith("edit(")
94 assert f'file_path="{index_path}"' in template
95 assert 'old_string="""' in template
96 assert 'new_string="""' in template
97 assert '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>' in template
98
99
100 def test_validate_html_toc_reports_missing_and_mismatched_links(tmp_path) -> None:
101 chapters = tmp_path / "chapters"
102 chapters.mkdir()
103 (chapters / "01-introduction.html").write_text(
104 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
105 )
106 index_path = tmp_path / "index.html"
107 index_path.write_text(
108 '<ul class="chapter-list">\n'
109 ' <li><a href="chapters/01-introduction.html">Chapter 1: Wrong Title</a></li>\n'
110 ' <li><a href="chapters/02-missing.html">Chapter 2: Missing</a></li>\n'
111 "</ul>\n"
112 )
113
114 result = validate_html_toc(index_path)
115
116 assert result is not None
117 assert result.valid is False
118 assert result.link_count == 2
119 assert result.missing == ("chapters/02-missing.html -> missing",)
120 assert (
121 result.mismatched
122 == (
123 "chapters/01-introduction.html -> Chapter 1: Wrong Title != Chapter 1: Introduction to Fortran",
124 )
125 )
126
127
128 def test_validate_html_toc_reports_success(tmp_path) -> None:
129 chapters = tmp_path / "chapters"
130 chapters.mkdir()
131 (chapters / "01-introduction.html").write_text(
132 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
133 )
134 (chapters / "02-setup.html").write_text(
135 "<h1>Chapter 2: Setting Up Your Environment</h1>\n"
136 )
137 index_path = tmp_path / "index.html"
138 index_path.write_text(
139 '<ul class="chapter-list">\n'
140 ' <li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>\n'
141 ' <li><a href="chapters/02-setup.html">Chapter 2: Setting Up Your Environment</a></li>\n'
142 "</ul>\n"
143 )
144
145 result = validate_html_toc(index_path)
146
147 assert result is not None
148 assert result.valid is True
149 assert result.link_count == 2
150 assert result.missing == ()
151 assert result.mismatched == ()
152
153
154 def test_action_tracker_preserves_loop_description_format() -> None:
155 tracker = ActionTracker()
156
157 tracker.record_tool_call("read", {"file_path": "a.txt"})
158 tracker.record_tool_call("grep", {"pattern": "alpha"})
159 tracker.record_tool_call("read", {"file_path": "b.txt"})
160 tracker.record_tool_call("grep", {"pattern": "beta"})
161
162 is_loop, description = tracker.detect_loop()
163
164 assert is_loop is True
165 assert description == "Repeating pattern detected (2x): read → grep"
166
167
168 def test_action_tracker_blocks_repeated_bash_observation_without_changes() -> None:
169 tracker = ActionTracker()
170 arguments = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
171
172 tracker.record_tool_call("bash", arguments)
173
174 is_duplicate, reason = tracker.check_tool_call("bash", arguments)
175
176 assert is_duplicate is True
177 assert "read-only shell probe" in reason
178
179
180 def test_action_tracker_allows_repeated_bash_observation_after_mutation() -> None:
181 tracker = ActionTracker()
182 bash_args = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
183 patch_args = {
184 "file_path": "index.html",
185 "hunks": [
186 {
187 "old_start": 1,
188 "old_lines": 1,
189 "new_start": 1,
190 "new_lines": 1,
191 "lines": ["-old", "+new"],
192 }
193 ],
194 }
195
196 tracker.record_tool_call("bash", bash_args)
197 tracker.record_tool_call("patch", patch_args)
198
199 assert tracker.check_tool_call("bash", bash_args) == (False, "")
200
201
202 def test_action_tracker_blocks_repeated_read_without_changes(tmp_path) -> None:
203 tracker = ActionTracker()
204 file_path = tmp_path / "index.html"
205 arguments = {"file_path": str(file_path)}
206
207 tracker.record_tool_call("read", arguments)
208
209 is_duplicate, reason = tracker.check_tool_call("read", arguments)
210
211 assert is_duplicate is True
212 assert str(file_path) in reason
213
214
215 def test_action_tracker_allows_one_interleaved_reread_without_changes(tmp_path) -> None:
216 tracker = ActionTracker()
217 index_path = tmp_path / "index.html"
218 chapter_path = tmp_path / "chapter-1.html"
219
220 tracker.record_tool_call("read", {"file_path": str(index_path)})
221 tracker.record_tool_call("read", {"file_path": str(chapter_path)})
222
223 assert tracker.check_tool_call("read", {"file_path": str(index_path)}) == (False, "")
224
225
226 def test_action_tracker_allows_reading_a_different_slice_of_the_same_file(tmp_path) -> None:
227 tracker = ActionTracker()
228 index_path = tmp_path / "index.html"
229
230 tracker.record_tool_call("read", {"file_path": str(index_path)})
231
232 assert tracker.check_tool_call(
233 "read",
234 {"file_path": str(index_path), "offset": 1, "limit": 50},
235 ) == (False, "")
236
237
238 def test_action_tracker_blocks_fourth_interleaved_reread_without_changes(tmp_path) -> None:
239 tracker = ActionTracker()
240 index_path = tmp_path / "index.html"
241 chapter_a = tmp_path / "chapter-1.html"
242 chapter_b = tmp_path / "chapter-2.html"
243 chapter_c = tmp_path / "chapter-3.html"
244
245 tracker.record_tool_call("read", {"file_path": str(index_path)})
246 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
247 tracker.record_tool_call("read", {"file_path": str(index_path)})
248 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
249 tracker.record_tool_call("read", {"file_path": str(index_path)})
250 tracker.record_tool_call("read", {"file_path": str(chapter_c)})
251
252 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
253
254 assert is_duplicate is True
255 assert str(index_path) in reason
256
257
258 def test_action_tracker_allows_one_target_index_reread_after_chapter_discovery(tmp_path) -> None:
259 tracker = ActionTracker()
260 index_path = tmp_path / "index.html"
261 chapters = tmp_path / "chapters"
262 chapter_a = chapters / "01-introduction.html"
263 chapter_b = chapters / "02-setup.html"
264 chapter_c = chapters / "03-basics.html"
265
266 tracker.record_tool_call("read", {"file_path": str(index_path)})
267 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
268 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
269 tracker.record_tool_call("read", {"file_path": str(chapter_c)})
270
271 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
272
273 assert is_duplicate is False
274 assert reason == ""
275
276
277 def test_action_tracker_blocks_second_target_index_reread_after_chapter_discovery(tmp_path) -> None:
278 tracker = ActionTracker()
279 index_path = tmp_path / "index.html"
280 chapters = tmp_path / "chapters"
281
282 tracker.record_tool_call("read", {"file_path": str(index_path)})
283 tracker.record_tool_call("read", {"file_path": str(chapters / "01-introduction.html")})
284 tracker.record_tool_call("read", {"file_path": str(chapters / "02-setup.html")})
285 tracker.record_tool_call("read", {"file_path": str(chapters / "03-basics.html")})
286 tracker.record_tool_call("read", {"file_path": str(index_path)})
287
288 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
289
290 assert is_duplicate is True
291 assert "reuse the earlier read result instead of rereading" in reason
292
293
294 def test_action_tracker_blocks_repeated_chapter_directory_search_once_titles_are_known(
295 tmp_path,
296 ) -> None:
297 tracker = ActionTracker()
298 chapters = tmp_path / "chapters"
299 search_args = {"pattern": "*.html", "path": str(chapters)}
300
301 tracker.record_tool_call("glob", search_args)
302 tracker.record_tool_call("glob", search_args)
303
304 is_duplicate, reason = tracker.check_tool_call("glob", search_args)
305
306 assert is_duplicate is True
307 assert "reuse the earlier search result instead of rerunning it" in reason
308
309
310 def test_action_tracker_allows_repeated_read_after_mutation(tmp_path) -> None:
311 tracker = ActionTracker()
312 file_path = tmp_path / "index.html"
313 read_args = {"file_path": str(file_path)}
314 edit_args = {
315 "file_path": str(file_path),
316 "old_string": "old",
317 "new_string": "new",
318 }
319
320 tracker.record_tool_call("read", read_args)
321 tracker.record_tool_call("edit", edit_args)
322
323 assert tracker.check_tool_call("read", read_args) == (False, "")
324
325
326 def test_pre_action_validator_blocks_patch_without_hunks() -> None:
327 validator = PreActionValidator()
328
329 result = validator.validate(
330 "patch",
331 {"file_path": "notes.txt", "hunks": []},
332 )
333
334 assert result == ValidationResult(
335 valid=False,
336 reason="Patch hunks are missing",
337 suggestion="Provide structured patch hunks or a unified diff patch string",
338 severity="error",
339 )
340
341
342 def test_pre_action_validator_allows_patch_string_without_hunks() -> None:
343 validator = PreActionValidator()
344
345 result = validator.validate(
346 "patch",
347 {
348 "file_path": "notes.txt",
349 "patch": "--- a/notes.txt\n+++ b/notes.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
350 },
351 )
352
353 assert result == ValidationResult(valid=True)
354
355
356 def test_pre_action_validator_blocks_placeholder_html_write(tmp_path: Path) -> None:
357 validator = PreActionValidator()
358
359 result = validator.validate(
360 "write",
361 {
362 "file_path": str(tmp_path / "guide" / "chapters" / "01-introduction.html"),
363 "content": (
364 "<html><body><h1>Introduction</h1>"
365 "<p>Starter content for this chapter.</p>"
366 "<h2>Overview</h2><p>Key concepts go here.</p>"
367 "</body></html>"
368 ),
369 },
370 )
371
372 assert result.valid is False
373 assert result.reason == "HTML content contains placeholder or stub text"
374 assert "concrete user-facing content" in result.suggestion
375 assert "starter content" in result.suggestion
376
377
378 def test_pre_action_validator_blocks_placeholder_html_edit(tmp_path: Path) -> None:
379 validator = PreActionValidator()
380 page = tmp_path / "guide" / "chapters" / "01-introduction.html"
381 page.parent.mkdir(parents=True)
382 page.write_text("<html><body><h1>Introduction</h1></body></html>")
383
384 result = validator.validate(
385 "edit",
386 {
387 "file_path": str(page),
388 "old_string": "</body>",
389 "new_string": "<p>Practical steps go here.</p></body>",
390 },
391 )
392
393 assert result.valid is False
394 assert result.reason == "HTML content contains placeholder or stub text"
395 assert "practical steps go here" in result.suggestion
396
397
398 def test_pre_action_validator_blocks_placeholder_html_patch(tmp_path: Path) -> None:
399 validator = PreActionValidator()
400
401 result = validator.validate(
402 "patch",
403 {
404 "file_path": str(tmp_path / "guide" / "chapters" / "02-installation.html"),
405 "patch": (
406 "--- a/guide/chapters/02-installation.html\n"
407 "+++ b/guide/chapters/02-installation.html\n"
408 "@@ -1,1 +1,2 @@\n"
409 " <h1>Installation</h1>\n"
410 "+<p>Coming soon.</p>\n"
411 ),
412 },
413 )
414
415 assert result.valid is False
416 assert result.reason == "HTML content contains placeholder or stub text"
417 assert "coming soon" in result.suggestion
418
419
420 def test_pre_action_validator_blocks_missing_local_html_asset_href(tmp_path: Path) -> None:
421 validator = PreActionValidator()
422 page = tmp_path / "guide" / "chapters" / "07-performance.html"
423
424 result = validator.validate(
425 "write",
426 {
427 "file_path": str(page),
428 "content": (
429 '<html><head><link rel="stylesheet" href="../styles.css"></head>'
430 "<body><h1>Performance</h1><p>Concrete content.</p></body></html>"
431 ),
432 },
433 )
434
435 assert result.valid is False
436 assert result.reason == "HTML local asset references do not exist"
437 assert "../styles.css" in result.suggestion
438 assert "create the referenced asset first" in result.suggestion
439
440
441 def test_pre_action_validator_allows_existing_local_html_asset_href(
442 tmp_path: Path,
443 ) -> None:
444 validator = PreActionValidator()
445 guide = tmp_path / "guide"
446 chapters = guide / "chapters"
447 chapters.mkdir(parents=True)
448 (guide / "styles.css").write_text("body { color: #222; }\n")
449
450 result = validator.validate(
451 "write",
452 {
453 "file_path": str(chapters / "07-performance.html"),
454 "content": (
455 '<html><head><link rel="stylesheet" href="../styles.css"></head>'
456 "<body><h1>Performance</h1><p>Concrete content.</p></body></html>"
457 ),
458 },
459 )
460
461 assert result.valid is True
462
463
464 def test_pre_action_validator_blocks_shell_text_rewrite_for_html_target() -> None:
465 validator = PreActionValidator()
466
467 result = validator.validate(
468 "bash",
469 {
470 "command": (
471 "cd /tmp/fortran-qwen-recovery-check && "
472 "sed -i '1,3c\\<li>updated</li>' index.html"
473 )
474 },
475 )
476
477 assert result.valid is False
478 assert result.reason == (
479 "Shell-based text rewrites are brittle and bypass Loader's safer file tools"
480 )
481 assert "edit/patch/write" in result.suggestion
482 assert "index.html" in result.suggestion
483
484
485 def test_pre_action_validator_allows_non_mutating_sed_probe() -> None:
486 validator = PreActionValidator()
487
488 result = validator.validate(
489 "bash",
490 {"command": "sed -n '1,20p' index.html"},
491 )
492
493 assert result == ValidationResult(valid=True)
494
495
496 def test_pre_action_validator_blocks_index_edit_with_missing_chapter_href(tmp_path) -> None:
497 validator = PreActionValidator()
498 index = tmp_path / "index.html"
499 chapters = tmp_path / "chapters"
500 chapters.mkdir()
501 (chapters / "05-input-output.html").write_text(
502 "<h1>Chapter 5: Input and Output</h1>\n"
503 )
504
505 result = validator.validate(
506 "edit",
507 {
508 "file_path": str(index),
509 "old_string": '<li><a href="chapters/05-input-output.html">Chapter 5: Input and Output</a></li>',
510 "new_string": '<li><a href="chapters/05-control-structures.html">Chapter 5: Control Structures</a></li>',
511 },
512 )
513
514 assert result.valid is False
515 assert result.reason == "Edited HTML links point to files that do not exist"
516 assert "chapters/05-control-structures.html" in result.suggestion
517 assert "remove the broken link" in result.suggestion
518 assert "for example fix" not in result.suggestion
519
520
521 def test_pre_action_validator_allows_incomplete_root_index_to_reshape_missing_child_target(
522 tmp_path: Path,
523 ) -> None:
524 validator = PreActionValidator()
525 guide = tmp_path / "guide"
526 chapters = guide / "chapters"
527 chapters.mkdir(parents=True)
528 index = guide / "index.html"
529 index.write_text(
530 "\n".join(
531 [
532 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a></li>',
533 '<li><a href="chapters/02-installation.html">Chapter 2: Installation on POSIX Systems</a></li>',
534 '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
535 "",
536 ]
537 )
538 )
539 (chapters / "01-introduction.html").write_text("<html></html>\n")
540 (chapters / "02-installation.html").write_text("<html></html>\n")
541
542 result = validator.validate(
543 "edit",
544 {
545 "file_path": str(index),
546 "old_string": '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
547 "new_string": '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration Basics</a></li>',
548 },
549 )
550
551 assert result.valid is True
552
553
554 def test_pre_action_validator_allows_root_index_to_add_next_ordered_missing_sibling(
555 tmp_path: Path,
556 ) -> None:
557 validator = PreActionValidator()
558 guide = tmp_path / "guide"
559 chapters = guide / "chapters"
560 chapters.mkdir(parents=True)
561 index = guide / "index.html"
562 index.write_text(
563 "\n".join(
564 [
565 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
566 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
567 '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
568 '<li><a href="chapters/04-usage.html">Chapter 4: Usage</a></li>',
569 '<li><a href="chapters/05-advanced-topics.html">Chapter 5: Advanced Topics</a></li>',
570 "",
571 ]
572 )
573 )
574 for name in [
575 "01-introduction.html",
576 "02-installation.html",
577 "03-configuration.html",
578 "04-usage.html",
579 "05-advanced-topics.html",
580 ]:
581 (chapters / name).write_text("<html></html>\n")
582
583 result = validator.validate(
584 "edit",
585 {
586 "file_path": str(index),
587 "old_string": (
588 '<li><a href="chapters/05-advanced-topics.html">'
589 "Chapter 5: Advanced Topics</a></li>"
590 ),
591 "new_string": (
592 '<li><a href="chapters/05-advanced-topics.html">'
593 "Chapter 5: Advanced Topics</a></li>\n"
594 '<li><a href="chapters/06-troubleshooting.html">'
595 "Chapter 6: Troubleshooting</a></li>"
596 ),
597 },
598 )
599
600 assert result.valid is True
601
602
603 def test_pre_action_validator_blocks_root_index_from_skipping_to_far_missing_sibling(
604 tmp_path: Path,
605 ) -> None:
606 validator = PreActionValidator()
607 guide = tmp_path / "guide"
608 chapters = guide / "chapters"
609 chapters.mkdir(parents=True)
610 index = guide / "index.html"
611 index.write_text(
612 "\n".join(
613 [
614 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
615 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
616 '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
617 "",
618 ]
619 )
620 )
621 for name in [
622 "01-introduction.html",
623 "02-installation.html",
624 "03-configuration.html",
625 ]:
626 (chapters / name).write_text("<html></html>\n")
627
628 result = validator.validate(
629 "edit",
630 {
631 "file_path": str(index),
632 "old_string": (
633 '<li><a href="chapters/03-configuration.html">'
634 "Chapter 3: Configuration</a></li>"
635 ),
636 "new_string": (
637 '<li><a href="chapters/03-configuration.html">'
638 "Chapter 3: Configuration</a></li>\n"
639 '<li><a href="chapters/08-troubleshooting.html">'
640 "Chapter 8: Troubleshooting</a></li>"
641 ),
642 },
643 )
644
645 assert result.valid is False
646 assert result.reason == "Edited HTML links point to files that do not exist"
647 assert "chapters/08-troubleshooting.html" in result.suggestion
648 assert "remove the broken link" in result.suggestion
649 assert "for example fix" not in result.suggestion
650
651
652 def test_pre_action_validator_missing_html_link_suggestion_prefers_existing_targets(
653 tmp_path: Path,
654 ) -> None:
655 validator = PreActionValidator()
656 guide = tmp_path / "guide"
657 chapters = guide / "chapters"
658 chapters.mkdir(parents=True)
659 index = guide / "index.html"
660 current = "\n".join(
661 [
662 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
663 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
664 '<p><a href="../index.html">Back</a></p>',
665 "",
666 ]
667 )
668 index.write_text(current)
669 (chapters / "01-introduction.html").write_text("<html></html>\n")
670 (chapters / "02-installation.html").write_text("<html></html>\n")
671
672 result = validator.validate(
673 "edit",
674 {
675 "file_path": str(index),
676 "old_string": '<p><a href="../index.html">Back</a></p>',
677 "new_string": '<p><a href="../../index.html">Back</a></p>',
678 },
679 )
680
681 assert result.valid is False
682 assert result.reason == "Edited HTML links point to files that do not exist"
683 assert "Broken href(s): ../../index.html" in result.suggestion
684 assert "chapters/01-introduction.html" in result.suggestion
685 assert "chapters/02-installation.html" in result.suggestion
686 assert "for example fix: ../../index.html" not in result.suggestion
687
688
689 def test_pre_action_validator_blocks_index_edit_with_title_mismatch(tmp_path) -> None:
690 validator = PreActionValidator()
691 index = tmp_path / "index.html"
692 chapters = tmp_path / "chapters"
693 chapters.mkdir()
694 (chapters / "12-troubleshooting-tips.html").write_text(
695 "<h1>Chapter 12: Troubleshooting and Tips</h1>\n"
696 )
697
698 result = validator.validate(
699 "edit",
700 {
701 "file_path": str(index),
702 "old_string": '<li><a href="chapters/12-troubleshooting-tips.html">Chapter 12: Troubleshooting and Tips</a></li>',
703 "new_string": '<li><a href="chapters/12-troubleshooting-tips.html">Chapter 12: Troubleshooting Tips</a></li>',
704 },
705 )
706
707 assert result.valid is True
708
709
710 def test_pre_action_validator_allows_chapter_write_with_future_target_declared_by_index(
711 tmp_path: Path,
712 ) -> None:
713 validator = PreActionValidator()
714 guide = tmp_path / "guide"
715 chapters = guide / "chapters"
716 chapters.mkdir(parents=True)
717 (guide / "index.html").write_text(
718 "\n".join(
719 [
720 '<a href="chapters/introduction.html">Introduction</a>',
721 '<a href="chapters/installation.html">Installation</a>',
722 "",
723 ]
724 )
725 )
726
727 result = validator.validate(
728 "write",
729 {
730 "file_path": str(chapters / "introduction.html"),
731 "content": '<a href="installation.html">Next</a>\n',
732 },
733 )
734
735 assert result.valid is True
736
737
738 def test_pre_action_validator_blocks_undeclared_chapter_file_creation_after_root_seed(
739 tmp_path: Path,
740 ) -> None:
741 validator = PreActionValidator()
742 guide = tmp_path / "guide"
743 chapters = guide / "chapters"
744 chapters.mkdir(parents=True)
745 (guide / "index.html").write_text(
746 "\n".join(
747 [
748 '<a href="chapters/01-introduction.html">Introduction</a>',
749 '<a href="chapters/02-installation.html">Installation</a>',
750 '<a href="chapters/03-configuration.html">Configuration</a>',
751 "",
752 ]
753 )
754 )
755 (chapters / "01-introduction.html").write_text("<html></html>\n")
756 (chapters / "02-installation.html").write_text("<html></html>\n")
757
758 result = validator.validate(
759 "write",
760 {
761 "file_path": str(chapters / "09-monitoring.html"),
762 "content": "<html></html>\n",
763 },
764 )
765
766 assert result.valid is False
767 assert result.reason == "HTML file creation falls outside the current declared artifact set"
768 assert "09-monitoring.html" in result.suggestion
769 assert str((guide / "index.html").resolve(strict=False)) in result.suggestion
770
771
772 def test_pre_action_validator_allows_declared_missing_chapter_file_creation(
773 tmp_path: Path,
774 ) -> None:
775 validator = PreActionValidator()
776 guide = tmp_path / "guide"
777 chapters = guide / "chapters"
778 chapters.mkdir(parents=True)
779 (guide / "index.html").write_text(
780 "\n".join(
781 [
782 '<a href="chapters/01-introduction.html">Introduction</a>',
783 '<a href="chapters/02-installation.html">Installation</a>',
784 '<a href="chapters/03-configuration.html">Configuration</a>',
785 "",
786 ]
787 )
788 )
789 (chapters / "01-introduction.html").write_text("<html></html>\n")
790
791 result = validator.validate(
792 "write",
793 {
794 "file_path": str(chapters / "02-installation.html"),
795 "content": "<html></html>\n",
796 },
797 )
798
799 assert result.valid is True
800
801
802 def test_pre_action_validator_blocks_undeclared_file_creation_with_closest_declared_target(
803 tmp_path: Path,
804 ) -> None:
805 validator = PreActionValidator()
806 guide = tmp_path / "guide"
807 chapters = guide / "chapters"
808 chapters.mkdir(parents=True)
809 (guide / "index.html").write_text(
810 "\n".join(
811 [
812 '<a href="chapters/01-introduction.html">Introduction</a>',
813 '<a href="chapters/02-installation.html">Installation</a>',
814 '<a href="chapters/03-configuration.html">Configuration</a>',
815 "",
816 ]
817 )
818 )
819 (chapters / "01-introduction.html").write_text("<html></html>\n")
820
821 result = validator.validate(
822 "write",
823 {
824 "file_path": str(chapters / "02-basics.html"),
825 "content": "<html></html>\n",
826 },
827 )
828
829 assert result.valid is False
830 assert result.reason == "HTML file creation falls outside the current declared artifact set"
831 assert "Do not create undeclared sibling page `chapters/02-basics.html`" in result.suggestion
832 assert "Closest declared local targets include: chapters/02-installation.html" in result.suggestion
833
834
835 def test_pre_action_validator_blocks_chapter_write_with_undeclared_missing_sibling(
836 tmp_path: Path,
837 ) -> None:
838 validator = PreActionValidator()
839 guide = tmp_path / "guide"
840 chapters = guide / "chapters"
841 chapters.mkdir(parents=True)
842 (guide / "index.html").write_text(
843 "\n".join(
844 [
845 '<a href="chapters/introduction.html">Introduction</a>',
846 '<a href="chapters/installation.html">Installation</a>',
847 '<a href="chapters/configuration.html">Configuration</a>',
848 '<a href="chapters/usage.html">Usage</a>',
849 '<a href="chapters/troubleshooting.html">Troubleshooting</a>',
850 "",
851 ]
852 )
853 )
854 (chapters / "introduction.html").write_text('<a href="installation.html">Next</a>\n')
855 (chapters / "installation.html").write_text('<a href="configuration.html">Next</a>\n')
856 (chapters / "configuration.html").write_text('<a href="usage.html">Next</a>\n')
857
858 result = validator.validate(
859 "write",
860 {
861 "file_path": str(chapters / "usage.html"),
862 "content": '<a href="advanced.html">Next</a>\n',
863 },
864 )
865
866 assert result.valid is False
867 assert (
868 result.reason
869 == "HTML page introduces new local targets outside the current declared artifact set"
870 )
871 assert "advanced.html" in result.suggestion
872
873
874 def test_pre_action_validator_blocks_chapter_write_with_existing_but_undeclared_sibling(
875 tmp_path: Path,
876 ) -> None:
877 validator = PreActionValidator()
878 guide = tmp_path / "guide"
879 chapters = guide / "chapters"
880 chapters.mkdir(parents=True)
881 (guide / "index.html").write_text(
882 "\n".join(
883 [
884 '<a href="chapters/01-introduction.html">Introduction</a>',
885 '<a href="chapters/02-installation.html">Installation</a>',
886 '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
887 '<a href="chapters/04-advanced-configuration.html">Advanced Configuration</a>',
888 "",
889 ]
890 )
891 )
892 (chapters / "01-introduction.html").write_text('<a href="02-installation.html">Next</a>\n')
893 (chapters / "02-installation.html").write_text(
894 '<a href="03-basic-configuration.html">Next</a>\n'
895 )
896 (chapters / "04-locations-and-servers.html").write_text(
897 '<a href="05-static-content.html">Next</a>\n'
898 )
899
900 result = validator.validate(
901 "write",
902 {
903 "file_path": str(chapters / "03-basic-configuration.html"),
904 "content": '<a href="04-locations-and-servers.html">Next</a>\n',
905 },
906 )
907
908 assert result.valid is False
909 assert (
910 result.reason
911 == "HTML page introduces new local targets outside the current declared artifact set"
912 )
913 assert "04-locations-and-servers.html" in result.suggestion
914 assert "04-advanced-configuration.html" in result.suggestion
915
916
917 def test_pre_action_validator_does_not_suggest_unrelated_declared_html_target(
918 tmp_path: Path,
919 ) -> None:
920 validator = PreActionValidator()
921 guide = tmp_path / "guide"
922 chapters = guide / "chapters"
923 chapters.mkdir(parents=True)
924 (guide / "index.html").write_text(
925 "\n".join(
926 [
927 '<a href="chapters/introduction.html">Introduction</a>',
928 '<a href="chapters/installation.html">Installation</a>',
929 '<a href="chapters/configuration.html">Configuration</a>',
930 '<a href="chapters/basic-usage.html">Basic Usage</a>',
931 '<a href="chapters/advanced-topics.html">Advanced Topics</a>',
932 "",
933 ]
934 )
935 )
936
937 result = validator.validate(
938 "write",
939 {
940 "file_path": str(chapters / "introduction.html"),
941 "content": '<a href="troubleshooting.html">Troubleshooting</a>\n',
942 },
943 )
944
945 assert result.valid is False
946 assert "troubleshooting.html" in result.suggestion
947 assert "Closest declared local targets include:" not in result.suggestion
948
949
950 def test_pre_action_validator_allows_chapter_write_with_root_declared_sibling_and_index_link(
951 tmp_path: Path,
952 ) -> None:
953 validator = PreActionValidator()
954 guide = tmp_path / "guide"
955 chapters = guide / "chapters"
956 chapters.mkdir(parents=True)
957 (guide / "index.html").write_text(
958 "\n".join(
959 [
960 '<a href="chapters/01-introduction.html">Introduction</a>',
961 '<a href="chapters/02-installation.html">Installation</a>',
962 '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
963 "",
964 ]
965 )
966 )
967
968 result = validator.validate(
969 "write",
970 {
971 "file_path": str(chapters / "02-installation.html"),
972 "content": (
973 '<a href="../index.html">Back to guide</a>\n'
974 '<a href="03-basic-configuration.html">Next</a>\n'
975 ),
976 },
977 )
978
979 assert result.valid is True
980
981
982 def test_pre_action_validator_blocks_missing_numbered_read_with_existing_sibling(
983 tmp_path: Path,
984 ) -> None:
985 validator = PreActionValidator()
986 chapters = tmp_path / "chapters"
987 chapters.mkdir()
988 (chapters / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
989
990 result = validator.validate(
991 "read",
992 {"file_path": str(chapters / "01-introduction.html")},
993 )
994
995 assert result.valid is False
996 assert result.reason == "Read target conflicts with an existing numbered sibling"
997 assert "01-getting-started.html" in result.suggestion
998
999
1000 def test_pre_action_validator_blocks_new_numbered_sibling_drift(tmp_path) -> None:
1001 validator = PreActionValidator()
1002 chapters = tmp_path / "chapters"
1003 chapters.mkdir()
1004 (chapters / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
1005
1006 result = validator.validate(
1007 "write",
1008 {
1009 "file_path": str(chapters / "01-intro.html"),
1010 "content": "<h1>Intro</h1>\n",
1011 },
1012 )
1013
1014 assert result.valid is False
1015 assert result.reason == "New file conflicts with an existing numbered sibling"
1016 assert "01-getting-started.html" in result.suggestion
1017
1018
1019 def test_format_html_inventory_entry_handles_tmp_alias_paths() -> None:
1020 root = Path(tempfile.mkdtemp(dir="/tmp"))
1021 chapters = root / "chapters"
1022 chapters.mkdir()
1023 candidate = chapters / "05-input-output.html"
1024 candidate.write_text("<h1>Chapter 5: Input and Output</h1>\n")
1025
1026 entry = format_html_inventory_entry(root, candidate.resolve(strict=False))
1027
1028 assert entry == "chapters/05-input-output.html = Chapter 5: Input and Output"
1029
1030
1031 def test_runtime_safeguards_wrap_runtime_owned_services() -> None:
1032 safeguards = RuntimeSafeguards()
1033
1034 assert isinstance(safeguards.action_tracker, ActionTracker)
1035 assert isinstance(safeguards.validator, PreActionValidator)
1036
1037
1038 def test_agent_safeguards_reexport_runtime_safeguards() -> None:
1039 assert AgentRuntimeSafeguards is RuntimeSafeguards
1040
1041
1042 def test_agent_safeguards_exports_curated_compatibility_surface() -> None:
1043 assert agent_safeguards.__all__ == [
1044 "ActionTracker",
1045 "CodeBlockFilter",
1046 "FilterResult",
1047 "PatternDetector",
1048 "PatternMatch",
1049 "PreActionValidator",
1050 "RuntimeSafeguards",
1051 "ValidationResult",
1052 ]