Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Comment thread
bniladridas marked this conversation as resolved.
"(directory symlinks are not followed)"
)
progress_console.print(
f"[yellow]Warning:[/yellow] {omitted_symlink_entry_count} symlinked skill "
Comment thread
bniladridas marked this conversation as resolved.
"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(
Expand All @@ -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"])
Expand Down Expand Up @@ -2898,10 +2911,15 @@ def _scan_multi_skill(
progress_console.print(
f" {'<unscanned>':<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" {'<symlink omitted>':<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:
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/skillspector/multi_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -276,18 +277,20 @@ 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)
if entry.name in _SKIP_DIRS:
continue
try:
if entry.is_symlink() or _is_link_or_junction(child):
omitted_symlink_entries += 1
Comment thread
bniladridas marked this conversation as resolved.
Comment thread
bniladridas marked this conversation as resolved.
continue
if not entry.is_dir(follow_symlinks=False):
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
Expand Down Expand Up @@ -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,
)


Expand Down
26 changes: 26 additions & 0 deletions tests/test_multi_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -334,6 +335,31 @@ 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_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."""
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading