tenseleyflow/loader / 13a16fb

Browse files

Anchor child HTML links to root graph

Authored by espadonne
SHA
13a16fbcc6a3b750fe6ec678d91bb2d0ed2dece6
Parents
a5d8a76
Tree
07233fd

2 changed files

StatusFile+-
M src/loader/runtime/safeguard_services.py 104 22
M tests/test_safeguard_services.py 75 0
src/loader/runtime/safeguard_services.pymodified
@@ -962,38 +962,54 @@ class PreActionValidator:
962962
             return ValidationResult(valid=True)
963963
 
964964
         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:
971968
             return ValidationResult(valid=True)
972969
 
973
-        declared_targets = self._collect_declared_html_targets(root, existing_html_files)
974
-        undeclared_missing: list[str] = []
970
+        undeclared_targets: list[str] = []
975971
         for href, resolved in local_targets:
976
-            if resolved.exists():
977
-                continue
978972
             relative_target = self._relative_html_target(root, resolved)
979973
             if relative_target is None:
980974
                 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)
983983
 
984
-        if not undeclared_missing:
984
+        if not undeclared_targets:
985985
             return ValidationResult(valid=True)
986986
 
987
-        preview = ", ".join(undeclared_missing[:3])
988
-        if len(undeclared_missing) > 3:
987
+        preview = ", ".join(undeclared_targets[:3])
988
+        if len(undeclared_targets) > 3:
989989
             preview += ", ..."
990990
         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
+            )
9951002
         if declared_preview:
9961003
             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
+            )
9971013
         return ValidationResult(
9981014
             valid=False,
9991015
             reason="HTML page introduces new local targets outside the current declared artifact set",
@@ -1024,8 +1040,27 @@ class PreActionValidator:
10241040
     def _collect_declared_html_targets(
10251041
         self,
10261042
         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
+        ]
10291064
         declared: set[str] = set()
10301065
         for html_file in html_files:
10311066
             try:
@@ -1036,7 +1071,7 @@ class PreActionValidator:
10361071
                 relative_target = self._relative_html_target(root, resolved)
10371072
                 if relative_target is not None:
10381073
                     declared.add(relative_target)
1039
-        return declared
1074
+        return declared, False
10401075
 
10411076
     def _resolve_html_artifact_root(self, file_path: Path) -> Path:
10421077
         for candidate in [file_path.parent, *file_path.parents]:
@@ -1114,6 +1149,53 @@ class PreActionValidator:
11141149
 
11151150
         return suggestions
11161151
 
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
+
11171199
     def _validate_path(self, file_path: str) -> ValidationResult:
11181200
         if '\x00' in file_path:
11191201
             return ValidationResult(
tests/test_safeguard_services.pymodified
@@ -496,6 +496,81 @@ def test_pre_action_validator_blocks_chapter_write_with_undeclared_missing_sibli
496496
     assert "advanced.html" in result.suggestion
497497
 
498498
 
499
+def test_pre_action_validator_blocks_chapter_write_with_existing_but_undeclared_sibling(
500
+    tmp_path: Path,
501
+) -> None:
502
+    validator = PreActionValidator()
503
+    guide = tmp_path / "guide"
504
+    chapters = guide / "chapters"
505
+    chapters.mkdir(parents=True)
506
+    (guide / "index.html").write_text(
507
+        "\n".join(
508
+            [
509
+                '<a href="chapters/01-introduction.html">Introduction</a>',
510
+                '<a href="chapters/02-installation.html">Installation</a>',
511
+                '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
512
+                '<a href="chapters/04-advanced-configuration.html">Advanced Configuration</a>',
513
+                "",
514
+            ]
515
+        )
516
+    )
517
+    (chapters / "01-introduction.html").write_text('<a href="02-installation.html">Next</a>\n')
518
+    (chapters / "02-installation.html").write_text(
519
+        '<a href="03-basic-configuration.html">Next</a>\n'
520
+    )
521
+    (chapters / "04-locations-and-servers.html").write_text(
522
+        '<a href="05-static-content.html">Next</a>\n'
523
+    )
524
+
525
+    result = validator.validate(
526
+        "write",
527
+        {
528
+            "file_path": str(chapters / "03-basic-configuration.html"),
529
+            "content": '<a href="04-locations-and-servers.html">Next</a>\n',
530
+        },
531
+    )
532
+
533
+    assert result.valid is False
534
+    assert (
535
+        result.reason
536
+        == "HTML page introduces new local targets outside the current declared artifact set"
537
+    )
538
+    assert "04-locations-and-servers.html" in result.suggestion
539
+    assert "04-advanced-configuration.html" in result.suggestion
540
+
541
+
542
+def test_pre_action_validator_allows_chapter_write_with_root_declared_sibling_and_index_link(
543
+    tmp_path: Path,
544
+) -> None:
545
+    validator = PreActionValidator()
546
+    guide = tmp_path / "guide"
547
+    chapters = guide / "chapters"
548
+    chapters.mkdir(parents=True)
549
+    (guide / "index.html").write_text(
550
+        "\n".join(
551
+            [
552
+                '<a href="chapters/01-introduction.html">Introduction</a>',
553
+                '<a href="chapters/02-installation.html">Installation</a>',
554
+                '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
555
+                "",
556
+            ]
557
+        )
558
+    )
559
+
560
+    result = validator.validate(
561
+        "write",
562
+        {
563
+            "file_path": str(chapters / "02-installation.html"),
564
+            "content": (
565
+                '<a href="../index.html">Back to guide</a>\n'
566
+                '<a href="03-basic-configuration.html">Next</a>\n'
567
+            ),
568
+        },
569
+    )
570
+
571
+    assert result.valid is True
572
+
573
+
499574
 def test_pre_action_validator_blocks_missing_numbered_read_with_existing_sibling(
500575
     tmp_path: Path,
501576
 ) -> None: