@@ -804,13 +804,22 @@ class PreActionValidator: |
| 804 | 804 | severity="error", |
| 805 | 805 | ) |
| 806 | 806 | |
| 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 | + ) |
| 808 | 817 | if not html_index_result.valid: |
| 809 | 818 | return html_index_result |
| 810 | 819 | |
| 811 | 820 | html_declared_target_result = self._validate_html_declared_target_set( |
| 812 | 821 | str(file_path), |
| 813 | | - str(new_string), |
| 822 | + prospective_content, |
| 814 | 823 | ) |
| 815 | 824 | if not html_declared_target_result.valid: |
| 816 | 825 | return html_declared_target_result |
@@ -1014,6 +1023,8 @@ class PreActionValidator: |
| 1014 | 1023 | missing.append(href) |
| 1015 | 1024 | |
| 1016 | 1025 | if missing: |
| 1026 | + if self._allows_root_html_graph_seed(str(file_path), str(content), missing): |
| 1027 | + return ValidationResult(valid=True) |
| 1017 | 1028 | preview = ", ".join(missing[:3]) |
| 1018 | 1029 | if len(missing) > 3: |
| 1019 | 1030 | preview += ", ..." |
@@ -1029,6 +1040,71 @@ class PreActionValidator: |
| 1029 | 1040 | |
| 1030 | 1041 | return ValidationResult(valid=True) |
| 1031 | 1042 | |
| 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 | + |
| 1032 | 1108 | def _validate_html_declared_target_set( |
| 1033 | 1109 | self, |
| 1034 | 1110 | file_path: str, |