diff --git a/src/skillspector/multi_skill.py b/src/skillspector/multi_skill.py index fea6d869d..a52cedf30 100644 --- a/src/skillspector/multi_skill.py +++ b/src/skillspector/multi_skill.py @@ -231,10 +231,10 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: A directory is considered multi-skill when it has no root ``SKILL.md`` and at least two immediate child directories contain a manifest or supported - structured skill bundle. Any discovery limit or filesystem ambiguity - discards all partial classifications and returns ``complete == False``. - Callers can then fall back to a bounded monolithic scan and propagate the - supplied limitation to their public completeness surfaces. + structured skill bundle. Discovery limits and filesystem ambiguities return + ``complete == False``. A symlinked immediate child is the narrow exception: + it is never traversed, but already classified non-link siblings remain + available for a bounded recursive scan with the supplied limitation. """ absolute_directory = Path(os.path.abspath(directory)) try: @@ -273,11 +273,18 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: return MultiSkillDetectionResult(is_multi_skill=False, has_root_skill=True) skills: list[SkillDirectory] = [] + limitations: list[MultiSkillDetectionLimitation] = [] for entry in _bounded_scandir(directory, budget=budget): budget.check_runtime() child = Path(entry.path) try: if entry.is_symlink() or _is_link_or_junction(child): + limitations.append( + MultiSkillDetectionLimitation( + reason_code="read_error", + resource="multi_skill_symlinked_entry", + ) + ) continue if not entry.is_dir(follow_symlinks=False): continue @@ -310,6 +317,7 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: is_multi_skill=len(skills) >= 2, skills=skills, has_root_skill=False, + limitations=tuple(limitations), entries_examined=budget.entries, structured_candidates_examined=budget.structured_candidates, structured_input_bytes_examined=budget.structured_bytes, diff --git a/tests/test_multi_skill.py b/tests/test_multi_skill.py index e13dfc611..d816830bc 100644 --- a/tests/test_multi_skill.py +++ b/tests/test_multi_skill.py @@ -294,8 +294,8 @@ def test_hidden_directories_skipped(self, tmp_path: Path) -> None: names = {s.name for s in result.skills} assert "hidden" not in names - def test_symlinked_skill_directory_is_skipped(self, tmp_path: Path) -> None: - """Detection must not read a skill manifest through a directory symlink.""" + def test_symlinked_skill_directory_marks_discovery_incomplete(self, tmp_path: Path) -> None: + """Detection must not silently claim complete coverage through a directory symlink.""" for name in ("skill-a", "skill-b"): sub = tmp_path / name sub.mkdir() @@ -312,6 +312,9 @@ def test_symlinked_skill_directory_is_skipped(self, tmp_path: Path) -> None: assert result.is_multi_skill is True assert {skill.name for skill in result.skills} == {"skill-a", "skill-b"} + assert result.complete is False + assert result.limitations[0].reason_code == "read_error" + assert result.limitations[0].resource == "multi_skill_symlinked_entry" def test_symlinked_root_is_not_detected(self, tmp_path: Path) -> None: """Direct callers cannot use detection to inspect a symlinked root."""