Allow next ordered root link seeds
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
b3febff98ddb626c6567c002f311afb2e5503e5d- Parents
-
67643a7 - Tree
b522f6f
b3febff
b3febff98ddb626c6567c002f311afb2e5503e5d67643a7
b522f6f| Status | File | + | - |
|---|---|---|---|
| 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( | ||
| 45 | 45 | def _html_target_tokens(target: str) -> set[str]: |
| 46 | 46 | stem = Path(target).stem.lower() |
| 47 | 47 | 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 | + | |
| 48 | 60 | TEXT_REWRITE_FILENAMES = frozenset( |
| 49 | 61 | { |
| 50 | 62 | "dockerfile", |
@@ -1075,7 +1087,25 @@ class PreActionValidator: | ||
| 1075 | 1087 | missing_after = self._collect_missing_local_html_targets(normalized, content) |
| 1076 | 1088 | if not missing_after: |
| 1077 | 1089 | 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 | + ): | |
| 1079 | 1109 | return False |
| 1080 | 1110 | |
| 1081 | 1111 | for href in missing: |
@@ -1085,6 +1115,37 @@ class PreActionValidator: | ||
| 1085 | 1115 | return False |
| 1086 | 1116 | return True |
| 1087 | 1117 | |
| 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 | + | |
| 1088 | 1149 | def _collect_existing_missing_local_html_targets(self, file_path: Path) -> list[str]: |
| 1089 | 1150 | try: |
| 1090 | 1151 | 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 | ||
| 441 | 441 | assert result.valid is True |
| 442 | 442 | |
| 443 | 443 | |
| 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 | + | |
| 444 | 540 | def test_pre_action_validator_blocks_index_edit_with_title_mismatch(tmp_path) -> None: |
| 445 | 541 | validator = PreActionValidator() |
| 446 | 542 | index = tmp_path / "index.html" |