From eba8e7302d04f776e4fb8b880b726ab66ca306a0 Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:31:12 +0530 Subject: [PATCH 1/2] Report symlinked recursive skill skips in completeness (GH-495) Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- src/skillspector/cli.py | 34 ++++++++++++++++++++++++++++---- src/skillspector/multi_skill.py | 4 ++++ tests/test_multi_skill.py | 2 ++ tests/unit/test_cli.py | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 3a4d1b520..14515e014 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -2843,6 +2843,19 @@ def _scan_multi_skill( len(skills) - scanned_skill_count, ) output_omitted_skill_count = max(0, scanned_skill_count - len(processed_skills)) + omitted_symlink_entry_count = detection.omitted_symlink_entries + if omitted_symlink_entry_count: + analysis_incomplete = True + aggregate_limitations.append( + f"{omitted_symlink_entry_count} symlinked recursive skill(s) omitted " + "(directory symlinks are not followed)" + ) + progress_console.print( + f"[yellow]Warning:[/yellow] {omitted_symlink_entry_count} symlinked skill " + "directories were skipped during recursive discovery and are not " + "included in this scan." + ) + skills_omitted_total = unscanned_skill_count + omitted_symlink_entry_count if output_omitted_skill_count: analysis_incomplete = True aggregate_limitations.append( @@ -2860,7 +2873,7 @@ def _scan_multi_skill( complete_skills=complete_skill_count, partial_skills=partial_skill_count, failed_skills=failed_skill_count, - omitted_skills=unscanned_skill_count, + omitted_skills=skills_omitted_total, limitations=aggregate_limitations, ) analysis_incomplete = not bool(aggregate_completeness["is_complete"]) @@ -2898,10 +2911,15 @@ def _scan_multi_skill( progress_console.print( f" {'':<30} {'—':<8} {'—':<12} {unscanned_skill_count:<10} {'partial':<10}" ) - if output_omitted_skill_count or unscanned_skill_count: + if omitted_symlink_entry_count: + progress_console.print( + f" {'':<30} {'—':<8} {'—':<12} " + f"{omitted_symlink_entry_count:<10} {'skipped':<10}" + ) + if output_omitted_skill_count or unscanned_skill_count or omitted_symlink_entry_count: progress_console.print( "[yellow]Recursive scan incomplete:[/yellow] one or more skills were omitted " - "after an aggregate safety limit." + "after an aggregate safety limit or skipped as symlinks." ) if format == FormatChoice.json: @@ -2914,7 +2932,7 @@ def _scan_multi_skill( "risk_recommendation": aggregate_risk_assessment["recommendation"], "analysis_completeness": aggregate_completeness, "skills_scanned": scanned_skill_count, - "skills_omitted": unscanned_skill_count, + "skills_omitted": skills_omitted_total, "skills_output_omitted": output_omitted_skill_count, "public_finding_records": retained_public_records, "report_characters": retained_report_characters, @@ -2965,6 +2983,14 @@ def _scan_multi_skill( "reason": "aggregate_scan_limit", } ) + if omitted_symlink_entry_count: + combined_skills.append( + { + "omitted": True, + "omitted_count": omitted_symlink_entry_count, + "reason": "symlink_not_followed", + } + ) rendered = json.dumps(combined, indent=2) if len(rendered) > _MULTI_SKILL_MAX_REPORT_CHARACTERS: analysis_incomplete = True diff --git a/src/skillspector/multi_skill.py b/src/skillspector/multi_skill.py index 0081bc740..8125c1659 100644 --- a/src/skillspector/multi_skill.py +++ b/src/skillspector/multi_skill.py @@ -132,6 +132,7 @@ class MultiSkillDetectionResult: entries_examined: int = 0 structured_candidates_examined: int = 0 structured_input_bytes_examined: int = 0 + omitted_symlink_entries: int = 0 @property def complete(self) -> bool: @@ -276,11 +277,13 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: return MultiSkillDetectionResult(is_multi_skill=False, has_root_skill=True) skills: list[SkillDirectory] = [] + omitted_symlink_entries = 0 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): + omitted_symlink_entries += 1 continue if not entry.is_dir(follow_symlinks=False): continue @@ -317,6 +320,7 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: entries_examined=budget.entries, structured_candidates_examined=budget.structured_candidates, structured_input_bytes_examined=budget.structured_bytes, + omitted_symlink_entries=omitted_symlink_entries, ) diff --git a/tests/test_multi_skill.py b/tests/test_multi_skill.py index 183ed3173..0ea53201c 100644 --- a/tests/test_multi_skill.py +++ b/tests/test_multi_skill.py @@ -315,6 +315,7 @@ def test_dot_prefixed_child_skill_is_discovered_with_explicit_skips( "skill-b", } assert [skill.local_only for skill in result.skills] == [True, False, False] + assert result.omitted_symlink_entries == 1 def test_symlinked_skill_directory_is_skipped(self, tmp_path: Path) -> None: """Detection must not read a skill manifest through a directory symlink.""" @@ -334,6 +335,7 @@ 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.omitted_symlink_entries == 1 def test_symlinked_root_is_not_detected(self, tmp_path: Path) -> None: """Direct callers cannot use detection to inspect a symlinked root.""" diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 96dd9203a..48a073f46 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1857,6 +1857,41 @@ def test_recursive_markdown_report_character_limit_is_explicit( assert len(body) <= 1_024 +def test_recursive_symlinked_skills_are_reported_as_omitted( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Symlinked skill directories surface as omitted, not complete coverage.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + detection = MultiSkillDetectionResult( + is_multi_skill=True, + skills=[skill], + omitted_symlink_entries=1, + ) + output = tmp_path / "combined.json" + monkeypatch.setattr( + cli.graph, + "invoke", + lambda *_args, **_kwargs: _bounded_recursive_result("one", finding_count=0), + ) + + _scan_multi_skill(detection, FormatChoice.json, output, no_llm=True) + + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["skills_scanned"] == 1 + assert payload["skills_omitted"] == 1 + assert payload["analysis_completeness"]["is_complete"] is False + assert payload["risk_recommendation"] == "CAUTION" + assert any( + "symlinked recursive skill(s) omitted" in limitation + for limitation in payload["analysis_completeness"]["limitations"] + ) + assert payload["skills"][-1] == { + "omitted": True, + "omitted_count": 1, + "reason": "symlink_not_followed", + } + + def test_recursive_json_bounds_the_final_serialized_document( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: From 1fcdb7890149c24832ee542dd48f379381a536d4 Mon Sep 17 00:00:00 2001 From: Niladri Das <125604915+bniladridas@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:16:37 +0530 Subject: [PATCH 2/2] Skip ignored-name symlinks before counting omissions (GH-495) Signed-off-by: Niladri Das <125604915+bniladridas@users.noreply.github.com> --- src/skillspector/multi_skill.py | 4 ++-- tests/test_multi_skill.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/skillspector/multi_skill.py b/src/skillspector/multi_skill.py index 8125c1659..db4c7bdf9 100644 --- a/src/skillspector/multi_skill.py +++ b/src/skillspector/multi_skill.py @@ -281,6 +281,8 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: for entry in _bounded_scandir(directory, budget=budget): budget.check_runtime() child = Path(entry.path) + if entry.name in _SKIP_DIRS: + continue try: if entry.is_symlink() or _is_link_or_junction(child): omitted_symlink_entries += 1 @@ -289,8 +291,6 @@ def detect_skills(directory: Path) -> MultiSkillDetectionResult: continue except OSError as exc: raise _read_error("multi_skill_directory_entry") from exc - if entry.name in _SKIP_DIRS: - continue has_manifest = _has_skill_md(child, budget=budget) is_structured = False diff --git a/tests/test_multi_skill.py b/tests/test_multi_skill.py index 0ea53201c..066b122bd 100644 --- a/tests/test_multi_skill.py +++ b/tests/test_multi_skill.py @@ -337,6 +337,30 @@ def test_symlinked_skill_directory_is_skipped(self, tmp_path: Path) -> None: assert {skill.name for skill in result.skills} == {"skill-a", "skill-b"} assert result.omitted_symlink_entries == 1 + def test_ignored_name_symlink_is_not_counted_as_omitted(self, tmp_path: Path) -> None: + """An ignored-name symlink does not inflate the omission count.""" + for name in ("skill-a", "skill-b"): + sub = tmp_path / name + sub.mkdir() + (sub / "SKILL.md").write_text(f"---\nname: {name}\n---\n", encoding="utf-8") + ignored_target = tmp_path.parent / f"{tmp_path.name}-ignored-target" + ignored_target.mkdir() + (ignored_target / "SKILL.md").write_text("---\nname: mod\n---\n", encoding="utf-8") + linked_target = tmp_path.parent / f"{tmp_path.name}-linked-target" + linked_target.mkdir() + (linked_target / "SKILL.md").write_text("---\nname: linked\n---\n", encoding="utf-8") + try: + (tmp_path / "node_modules").symlink_to(ignored_target, target_is_directory=True) + (tmp_path / "linked-skill").symlink_to(linked_target, target_is_directory=True) + except OSError: + pytest.skip("symlinks are not supported on this filesystem") + + result = detect_skills(tmp_path) + + assert result.is_multi_skill is True + assert {skill.name for skill in result.skills} == {"skill-a", "skill-b"} + assert result.omitted_symlink_entries == 1 + def test_symlinked_root_is_not_detected(self, tmp_path: Path) -> None: """Direct callers cannot use detection to inspect a symlinked root.""" external = tmp_path / "external"