@@ -962,38 +962,54 @@ class PreActionValidator: |
| 962 | 962 | return ValidationResult(valid=True) |
| 963 | 963 | |
| 964 | 964 | root = self._resolve_html_artifact_root(normalized) |
| 965 | | - existing_html_files = [ |
| 966 | | - path |
| 967 | | - for path in root.rglob("*.html") |
| 968 | | - if path.is_file() and path != normalized |
| 969 | | - ] |
| 970 | | - if not existing_html_files: |
| 965 | + current_relative = self._relative_html_target(root, normalized) |
| 966 | + declared_targets, authoritative_root_graph = self._collect_declared_html_targets(root, normalized) |
| 967 | + if not declared_targets and not authoritative_root_graph: |
| 971 | 968 | return ValidationResult(valid=True) |
| 972 | 969 | |
| 973 | | - declared_targets = self._collect_declared_html_targets(root, existing_html_files) |
| 974 | | - undeclared_missing: list[str] = [] |
| 970 | + undeclared_targets: list[str] = [] |
| 975 | 971 | for href, resolved in local_targets: |
| 976 | | - if resolved.exists(): |
| 977 | | - continue |
| 978 | 972 | relative_target = self._relative_html_target(root, resolved) |
| 979 | 973 | if relative_target is None: |
| 980 | 974 | continue |
| 981 | | - if relative_target not in declared_targets and href not in undeclared_missing: |
| 982 | | - undeclared_missing.append(href) |
| 975 | + if relative_target == "index.html" or relative_target == current_relative: |
| 976 | + continue |
| 977 | + if relative_target in declared_targets: |
| 978 | + continue |
| 979 | + if not authoritative_root_graph and resolved.exists(): |
| 980 | + continue |
| 981 | + if href not in undeclared_targets: |
| 982 | + undeclared_targets.append(href) |
| 983 | 983 | |
| 984 | | - if not undeclared_missing: |
| 984 | + if not undeclared_targets: |
| 985 | 985 | return ValidationResult(valid=True) |
| 986 | 986 | |
| 987 | | - preview = ", ".join(undeclared_missing[:3]) |
| 988 | | - if len(undeclared_missing) > 3: |
| 987 | + preview = ", ".join(undeclared_targets[:3]) |
| 988 | + if len(undeclared_targets) > 3: |
| 989 | 989 | preview += ", ..." |
| 990 | 990 | declared_preview = ", ".join(sorted(declared_targets)[:3]) |
| 991 | | - suggestion = ( |
| 992 | | - "Keep non-root HTML pages within the current declared local-link set and " |
| 993 | | - f"avoid introducing new missing sibling targets, for example fix: {preview}" |
| 994 | | - ) |
| 991 | + if authoritative_root_graph: |
| 992 | + suggestion = ( |
| 993 | + "Keep non-root HTML pages within the root-declared local-link set and " |
| 994 | + f"avoid introducing new sibling targets that the guide root does not declare, " |
| 995 | + f"for example fix: {preview}" |
| 996 | + ) |
| 997 | + else: |
| 998 | + suggestion = ( |
| 999 | + "Keep non-root HTML pages within the current declared local-link set and " |
| 1000 | + f"avoid introducing new missing sibling targets, for example fix: {preview}" |
| 1001 | + ) |
| 995 | 1002 | if declared_preview: |
| 996 | 1003 | suggestion += f". Already-declared local targets include: {declared_preview}" |
| 1004 | + declared_suggestions = self._suggest_declared_html_targets( |
| 1005 | + declared_targets, |
| 1006 | + undeclared_targets, |
| 1007 | + ) |
| 1008 | + if declared_suggestions: |
| 1009 | + suggestion += ( |
| 1010 | + ". Closest declared local targets include: " |
| 1011 | + + ", ".join(declared_suggestions[:3]) |
| 1012 | + ) |
| 997 | 1013 | return ValidationResult( |
| 998 | 1014 | valid=False, |
| 999 | 1015 | reason="HTML page introduces new local targets outside the current declared artifact set", |
@@ -1024,8 +1040,27 @@ class PreActionValidator: |
| 1024 | 1040 | def _collect_declared_html_targets( |
| 1025 | 1041 | self, |
| 1026 | 1042 | root: Path, |
| 1027 | | - html_files: list[Path], |
| 1028 | | - ) -> set[str]: |
| 1043 | + current_file: Path, |
| 1044 | + ) -> tuple[set[str], bool]: |
| 1045 | + root_index = root / "index.html" |
| 1046 | + if root_index.exists(): |
| 1047 | + try: |
| 1048 | + root_text = root_index.read_text() |
| 1049 | + except OSError: |
| 1050 | + root_text = "" |
| 1051 | + declared_from_root = { |
| 1052 | + relative_target |
| 1053 | + for _href, resolved in self._collect_local_html_targets(root_index, root_text) |
| 1054 | + if (relative_target := self._relative_html_target(root, resolved)) is not None |
| 1055 | + } |
| 1056 | + if declared_from_root: |
| 1057 | + return declared_from_root, True |
| 1058 | + |
| 1059 | + html_files = [ |
| 1060 | + path |
| 1061 | + for path in root.rglob("*.html") |
| 1062 | + if path.is_file() and path != current_file |
| 1063 | + ] |
| 1029 | 1064 | declared: set[str] = set() |
| 1030 | 1065 | for html_file in html_files: |
| 1031 | 1066 | try: |
@@ -1036,7 +1071,7 @@ class PreActionValidator: |
| 1036 | 1071 | relative_target = self._relative_html_target(root, resolved) |
| 1037 | 1072 | if relative_target is not None: |
| 1038 | 1073 | declared.add(relative_target) |
| 1039 | | - return declared |
| 1074 | + return declared, False |
| 1040 | 1075 | |
| 1041 | 1076 | def _resolve_html_artifact_root(self, file_path: Path) -> Path: |
| 1042 | 1077 | for candidate in [file_path.parent, *file_path.parents]: |
@@ -1114,6 +1149,53 @@ class PreActionValidator: |
| 1114 | 1149 | |
| 1115 | 1150 | return suggestions |
| 1116 | 1151 | |
| 1152 | + def _suggest_declared_html_targets( |
| 1153 | + self, |
| 1154 | + declared_targets: set[str], |
| 1155 | + undeclared_targets: list[str], |
| 1156 | + ) -> list[str]: |
| 1157 | + suggestions: list[str] = [] |
| 1158 | + available = sorted(declared_targets) |
| 1159 | + available_names = [Path(candidate).name for candidate in available] |
| 1160 | + |
| 1161 | + for href in undeclared_targets: |
| 1162 | + href_name = Path(href).name |
| 1163 | + chapter_match = re.match(r"(\d+)[-_]", href_name) |
| 1164 | + preferred = available |
| 1165 | + preferred_names = available_names |
| 1166 | + if chapter_match is not None: |
| 1167 | + prefix = f"{chapter_match.group(1)}-" |
| 1168 | + filtered = [ |
| 1169 | + candidate |
| 1170 | + for candidate in available |
| 1171 | + if Path(candidate).name.startswith(prefix) |
| 1172 | + ] |
| 1173 | + if filtered: |
| 1174 | + preferred = filtered |
| 1175 | + preferred_names = [Path(candidate).name for candidate in filtered] |
| 1176 | + |
| 1177 | + matched_names = get_close_matches( |
| 1178 | + href_name, |
| 1179 | + preferred_names, |
| 1180 | + n=1, |
| 1181 | + cutoff=0.0, |
| 1182 | + ) |
| 1183 | + if not matched_names: |
| 1184 | + continue |
| 1185 | + |
| 1186 | + candidate = next( |
| 1187 | + ( |
| 1188 | + declared |
| 1189 | + for declared in preferred |
| 1190 | + if Path(declared).name == matched_names[0] |
| 1191 | + ), |
| 1192 | + None, |
| 1193 | + ) |
| 1194 | + if candidate is not None and candidate not in suggestions: |
| 1195 | + suggestions.append(candidate) |
| 1196 | + |
| 1197 | + return suggestions |
| 1198 | + |
| 1117 | 1199 | def _validate_path(self, file_path: str) -> ValidationResult: |
| 1118 | 1200 | if '\x00' in file_path: |
| 1119 | 1201 | return ValidationResult( |