tenseleyflow/loader / b3febff

Browse files

Allow next ordered root link seeds

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b3febff98ddb626c6567c002f311afb2e5503e5d
Parents
67643a7
Tree
b522f6f

2 changed files

StatusFile+-
M src/loader/runtime/safeguard_services.py 63 2
M tests/test_safeguard_services.py 96 0
src/loader/runtime/safeguard_services.pymodified
@@ -45,6 +45,18 @@ TEXT_REWRITE_SUFFIXES = frozenset(
4545
 def _html_target_tokens(target: str) -> set[str]:
4646
     stem = Path(target).stem.lower()
4747
     return {token for token in re.split(r"[^a-z0-9]+", stem) if token}
48
+
49
+
50
+def _ordered_html_target_number(target: str) -> int | None:
51
+    match = re.match(r"(\d+)[-_]", Path(target).name)
52
+    if match is None:
53
+        return None
54
+    try:
55
+        return int(match.group(1))
56
+    except ValueError:
57
+        return None
58
+
59
+
4860
 TEXT_REWRITE_FILENAMES = frozenset(
4961
     {
5062
         "dockerfile",
@@ -1075,7 +1087,25 @@ class PreActionValidator:
10751087
         missing_after = self._collect_missing_local_html_targets(normalized, content)
10761088
         if not missing_after:
10771089
             return False
1078
-        if len(missing_after) > len(self._collect_existing_missing_local_html_targets(normalized)):
1090
+        existing_missing = self._collect_existing_missing_local_html_targets(normalized)
1091
+        if len(missing_after) > len(existing_missing):
1092
+            declared_targets, authoritative_root_graph = self._collect_declared_html_targets(
1093
+                root,
1094
+                normalized,
1095
+            )
1096
+            if not authoritative_root_graph:
1097
+                return False
1098
+            newly_missing = [
1099
+                href
1100
+                for href in missing_after
1101
+                if href not in existing_missing
1102
+            ]
1103
+            if not newly_missing:
1104
+                return False
1105
+            if any(
1106
+                not self._is_next_ordered_html_target(root, href, declared_targets)
1107
+                for href in newly_missing
1108
+            ):
10791109
                 return False
10801110
 
10811111
         for href in missing:
@@ -1085,6 +1115,37 @@ class PreActionValidator:
10851115
                 return False
10861116
         return True
10871117
 
1118
+    def _is_next_ordered_html_target(
1119
+        self,
1120
+        root: Path,
1121
+        href: str,
1122
+        declared_targets: set[str],
1123
+    ) -> bool:
1124
+        relative_href = self._relative_html_target(root, (root / href).resolve(strict=False))
1125
+        if relative_href is None:
1126
+            return False
1127
+
1128
+        expected_number = _ordered_html_target_number(relative_href)
1129
+        if expected_number is None:
1130
+            return False
1131
+
1132
+        parent = Path(relative_href).parent
1133
+        sibling_numbers = sorted(
1134
+            number
1135
+            for target in declared_targets
1136
+            if Path(target).parent == parent
1137
+            if (number := _ordered_html_target_number(target)) is not None
1138
+        )
1139
+        if not sibling_numbers:
1140
+            return False
1141
+
1142
+        min_number = sibling_numbers[0]
1143
+        max_number = sibling_numbers[-1]
1144
+        if expected_number != max_number + 1:
1145
+            return False
1146
+
1147
+        return sibling_numbers == list(range(min_number, max_number + 1))
1148
+
10881149
     def _collect_existing_missing_local_html_targets(self, file_path: Path) -> list[str]:
10891150
         try:
10901151
             current = file_path.read_text()
tests/test_safeguard_services.pymodified
@@ -441,6 +441,102 @@ def test_pre_action_validator_allows_incomplete_root_index_to_reshape_missing_ch
441441
     assert result.valid is True
442442
 
443443
 
444
+def test_pre_action_validator_allows_root_index_to_add_next_ordered_missing_sibling(
445
+    tmp_path: Path,
446
+) -> None:
447
+    validator = PreActionValidator()
448
+    guide = tmp_path / "guide"
449
+    chapters = guide / "chapters"
450
+    chapters.mkdir(parents=True)
451
+    index = guide / "index.html"
452
+    index.write_text(
453
+        "\n".join(
454
+            [
455
+                '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
456
+                '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
457
+                '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
458
+                '<li><a href="chapters/04-usage.html">Chapter 4: Usage</a></li>',
459
+                '<li><a href="chapters/05-advanced-topics.html">Chapter 5: Advanced Topics</a></li>',
460
+                "",
461
+            ]
462
+        )
463
+    )
464
+    for name in [
465
+        "01-introduction.html",
466
+        "02-installation.html",
467
+        "03-configuration.html",
468
+        "04-usage.html",
469
+        "05-advanced-topics.html",
470
+    ]:
471
+        (chapters / name).write_text("<html></html>\n")
472
+
473
+    result = validator.validate(
474
+        "edit",
475
+        {
476
+            "file_path": str(index),
477
+            "old_string": (
478
+                '<li><a href="chapters/05-advanced-topics.html">'
479
+                "Chapter 5: Advanced Topics</a></li>"
480
+            ),
481
+            "new_string": (
482
+                '<li><a href="chapters/05-advanced-topics.html">'
483
+                "Chapter 5: Advanced Topics</a></li>\n"
484
+                '<li><a href="chapters/06-troubleshooting.html">'
485
+                "Chapter 6: Troubleshooting</a></li>"
486
+            ),
487
+        },
488
+    )
489
+
490
+    assert result.valid is True
491
+
492
+
493
+def test_pre_action_validator_blocks_root_index_from_skipping_to_far_missing_sibling(
494
+    tmp_path: Path,
495
+) -> None:
496
+    validator = PreActionValidator()
497
+    guide = tmp_path / "guide"
498
+    chapters = guide / "chapters"
499
+    chapters.mkdir(parents=True)
500
+    index = guide / "index.html"
501
+    index.write_text(
502
+        "\n".join(
503
+            [
504
+                '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
505
+                '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
506
+                '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
507
+                "",
508
+            ]
509
+        )
510
+    )
511
+    for name in [
512
+        "01-introduction.html",
513
+        "02-installation.html",
514
+        "03-configuration.html",
515
+    ]:
516
+        (chapters / name).write_text("<html></html>\n")
517
+
518
+    result = validator.validate(
519
+        "edit",
520
+        {
521
+            "file_path": str(index),
522
+            "old_string": (
523
+                '<li><a href="chapters/03-configuration.html">'
524
+                "Chapter 3: Configuration</a></li>"
525
+            ),
526
+            "new_string": (
527
+                '<li><a href="chapters/03-configuration.html">'
528
+                "Chapter 3: Configuration</a></li>\n"
529
+                '<li><a href="chapters/08-troubleshooting.html">'
530
+                "Chapter 8: Troubleshooting</a></li>"
531
+            ),
532
+        },
533
+    )
534
+
535
+    assert result.valid is False
536
+    assert result.reason == "Edited HTML links point to files that do not exist"
537
+    assert "chapters/08-troubleshooting.html" in result.suggestion
538
+
539
+
444540
 def test_pre_action_validator_blocks_index_edit_with_title_mismatch(tmp_path) -> None:
445541
     validator = PreActionValidator()
446542
     index = tmp_path / "index.html"