tenseleyflow/loader / 6d1d55c

Browse files

Align root graph seed edits

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6d1d55cfebd7984f0ff2c3138f7bcf3cb82c8fad
Parents
332bab7
Tree
5850875

2 changed files

StatusFile+-
M src/loader/runtime/safeguard_services.py 78 2
M tests/test_safeguard_services.py 33 0
src/loader/runtime/safeguard_services.pymodified
@@ -804,13 +804,22 @@ class PreActionValidator:
804804
                 severity="error",
805805
             )
806806
 
807
-        html_index_result = self._validate_html_index_links(str(file_path), str(new_string))
807
+        prospective_content = self._prospective_edit_content(
808
+            str(file_path),
809
+            str(old_string),
810
+            str(new_string),
811
+        )
812
+
813
+        html_index_result = self._validate_html_index_links(
814
+            str(file_path),
815
+            prospective_content,
816
+        )
808817
         if not html_index_result.valid:
809818
             return html_index_result
810819
 
811820
         html_declared_target_result = self._validate_html_declared_target_set(
812821
             str(file_path),
813
-            str(new_string),
822
+            prospective_content,
814823
         )
815824
         if not html_declared_target_result.valid:
816825
             return html_declared_target_result
@@ -1014,6 +1023,8 @@ class PreActionValidator:
10141023
                     missing.append(href)
10151024
 
10161025
         if missing:
1026
+            if self._allows_root_html_graph_seed(str(file_path), str(content), missing):
1027
+                return ValidationResult(valid=True)
10171028
             preview = ", ".join(missing[:3])
10181029
             if len(missing) > 3:
10191030
                 preview += ", ..."
@@ -1029,6 +1040,71 @@ class PreActionValidator:
10291040
 
10301041
         return ValidationResult(valid=True)
10311042
 
1043
+    def _prospective_edit_content(
1044
+        self,
1045
+        file_path: str,
1046
+        old_string: str,
1047
+        new_string: str,
1048
+    ) -> str:
1049
+        if old_string == "":
1050
+            return new_string
1051
+
1052
+        normalized = Path(file_path).expanduser()
1053
+        try:
1054
+            current = normalized.read_text()
1055
+        except OSError:
1056
+            return new_string
1057
+
1058
+        if old_string not in current:
1059
+            return new_string
1060
+        return current.replace(old_string, new_string, 1)
1061
+
1062
+    def _allows_root_html_graph_seed(
1063
+        self,
1064
+        file_path: str,
1065
+        content: str,
1066
+        missing: list[str],
1067
+    ) -> bool:
1068
+        normalized = Path(file_path).expanduser()
1069
+        if normalized.suffix.lower() not in {".html", ".htm"}:
1070
+            return False
1071
+        if normalized.name.lower() != "index.html":
1072
+            return False
1073
+
1074
+        root = self._resolve_html_artifact_root(normalized)
1075
+        missing_after = self._collect_missing_local_html_targets(normalized, content)
1076
+        if not missing_after:
1077
+            return False
1078
+        if len(missing_after) > len(self._collect_existing_missing_local_html_targets(normalized)):
1079
+            return False
1080
+
1081
+        for href in missing:
1082
+            resolved = (normalized.parent / href).resolve(strict=False)
1083
+            relative = self._relative_html_target(root, resolved)
1084
+            if relative is None:
1085
+                return False
1086
+        return True
1087
+
1088
+    def _collect_existing_missing_local_html_targets(self, file_path: Path) -> list[str]:
1089
+        try:
1090
+            current = file_path.read_text()
1091
+        except OSError:
1092
+            return []
1093
+        return self._collect_missing_local_html_targets(file_path, current)
1094
+
1095
+    def _collect_missing_local_html_targets(
1096
+        self,
1097
+        file_path: Path,
1098
+        content: str,
1099
+    ) -> list[str]:
1100
+        missing: list[str] = []
1101
+        for href, resolved in self._collect_local_html_targets(file_path, content):
1102
+            if resolved.exists():
1103
+                continue
1104
+            if href not in missing:
1105
+                missing.append(href)
1106
+        return missing
1107
+
10321108
     def _validate_html_declared_target_set(
10331109
         self,
10341110
         file_path: str,
tests/test_safeguard_services.pymodified
@@ -408,6 +408,39 @@ def test_pre_action_validator_blocks_index_edit_with_missing_chapter_href(tmp_pa
408408
     assert "chapters/05-control-structures.html" in result.suggestion
409409
 
410410
 
411
+def test_pre_action_validator_allows_incomplete_root_index_to_reshape_missing_child_target(
412
+    tmp_path: Path,
413
+) -> None:
414
+    validator = PreActionValidator()
415
+    guide = tmp_path / "guide"
416
+    chapters = guide / "chapters"
417
+    chapters.mkdir(parents=True)
418
+    index = guide / "index.html"
419
+    index.write_text(
420
+        "\n".join(
421
+            [
422
+                '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a></li>',
423
+                '<li><a href="chapters/02-installation.html">Chapter 2: Installation on POSIX Systems</a></li>',
424
+                '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
425
+                "",
426
+            ]
427
+        )
428
+    )
429
+    (chapters / "01-introduction.html").write_text("<html></html>\n")
430
+    (chapters / "02-installation.html").write_text("<html></html>\n")
431
+
432
+    result = validator.validate(
433
+        "edit",
434
+        {
435
+            "file_path": str(index),
436
+            "old_string": '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
437
+            "new_string": '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration Basics</a></li>',
438
+        },
439
+    )
440
+
441
+    assert result.valid is True
442
+
443
+
411444
 def test_pre_action_validator_blocks_index_edit_with_title_mismatch(tmp_path) -> None:
412445
     validator = PreActionValidator()
413446
     index = tmp_path / "index.html"