2cab53bc94
Docker Publish / Build and Push Docker Images (map[description:Skill Seekers CLI - Convert documentation to AI skills dockerfile:Dockerfile name:skill-seekers]) (push) Waiting to run
Docker Publish / Build and Push Docker Images (map[description:Skill Seekers MCP Server - 25 tools for AI assistants dockerfile:Dockerfile.mcp name:skill-seekers-mcp]) (push) Waiting to run
Docker Publish / Test Docker Images (push) Blocked by required conditions
Test Vector Database Adaptors / Test chroma Adaptor (push) Waiting to run
Test Vector Database Adaptors / Test faiss Adaptor (push) Waiting to run
Test Vector Database Adaptors / Test qdrant Adaptor (push) Waiting to run
Test Vector Database Adaptors / Test weaviate Adaptor (push) Waiting to run
Test Vector Database Adaptors / Test MCP Vector DB Tools (push) Waiting to run
Tests / Code Quality (Ruff & Mypy) (push) Waiting to run
Tests / Fast Unit Tests (parallel) (macos-latest, 3.11) (push) Waiting to run
Tests / Fast Unit Tests (parallel) (macos-latest, 3.12) (push) Waiting to run
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.10) (push) Waiting to run
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.11) (push) Waiting to run
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.12) (push) Waiting to run
Tests / Tests (push) Blocked by required conditions
Tests / Serial / Integration / E2E Tests (push) Blocked by required conditions
Tests / MCP Server Tests (push) Blocked by required conditions
1895 lines
68 KiB
Python
1895 lines
68 KiB
Python
"""Tests for the `skill-seekers scan` command (issue #327).
|
|
|
|
The scan command orchestrates: signal collection → AI detection → config
|
|
resolution / AI generation → codebase config emission → optional publish.
|
|
This file groups tests by the layer being exercised. All AI and network
|
|
calls are stubbed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
from skill_seekers.cli.scan_command import (
|
|
Detection,
|
|
ScanResult,
|
|
_canonical_name_candidates,
|
|
_exit_code_for,
|
|
detect_with_ai,
|
|
diff_against_existing,
|
|
emit_codebase_config,
|
|
generate_config_with_ai,
|
|
maybe_publish,
|
|
resolve_or_generate,
|
|
run_scan,
|
|
stamp_version,
|
|
)
|
|
from skill_seekers.cli.signal_collectors import Signal, SignalBundle
|
|
|
|
|
|
class TestStampVersion:
|
|
def test_writes_to_metadata_detected_version(self, tmp_path: Path):
|
|
cfg = tmp_path / "x.json"
|
|
cfg.write_text(json.dumps({"name": "x", "description": "d"}))
|
|
stamp_version(cfg, "1.2.3")
|
|
data = json.loads(cfg.read_text())
|
|
assert data["metadata"]["detected_version"] == "1.2.3"
|
|
# NOT at top level (WS2 — schema cleanup).
|
|
assert "detected_version" not in data
|
|
assert data["name"] == "x"
|
|
assert data["description"] == "d"
|
|
|
|
def test_overwrites_existing_detected_version(self, tmp_path: Path):
|
|
cfg = tmp_path / "x.json"
|
|
cfg.write_text(json.dumps({"name": "x", "metadata": {"detected_version": "1.0.0"}}))
|
|
stamp_version(cfg, "2.0.0")
|
|
assert json.loads(cfg.read_text())["metadata"]["detected_version"] == "2.0.0"
|
|
|
|
def test_migrates_legacy_top_level_field_to_metadata(self, tmp_path: Path):
|
|
"""WS2 backwards-compat: a config previously stamped at top level should
|
|
get migrated to metadata on the next stamp."""
|
|
cfg = tmp_path / "x.json"
|
|
cfg.write_text(json.dumps({"name": "x", "detected_version": "1.0.0"}))
|
|
stamp_version(cfg, "2.0.0")
|
|
data = json.loads(cfg.read_text())
|
|
assert "detected_version" not in data # top-level cleared
|
|
assert data["metadata"]["detected_version"] == "2.0.0"
|
|
|
|
def test_none_version_clears_field(self, tmp_path: Path):
|
|
cfg = tmp_path / "x.json"
|
|
cfg.write_text(json.dumps({"name": "x", "metadata": {"detected_version": "1.0.0"}}))
|
|
stamp_version(cfg, None)
|
|
data = json.loads(cfg.read_text())
|
|
assert "detected_version" not in data
|
|
assert "detected_version" not in data.get("metadata", {})
|
|
|
|
def test_preserves_unrelated_keys(self, tmp_path: Path):
|
|
cfg = tmp_path / "x.json"
|
|
original = {
|
|
"name": "x",
|
|
"base_url": "https://x.io",
|
|
"sources": [{"type": "documentation", "base_url": "https://x.io"}],
|
|
"categories": {"a": ["foo"]},
|
|
}
|
|
cfg.write_text(json.dumps(original))
|
|
stamp_version(cfg, "9.9.9")
|
|
data = json.loads(cfg.read_text())
|
|
assert data["base_url"] == "https://x.io"
|
|
assert data["sources"] == original["sources"]
|
|
assert data["categories"] == original["categories"]
|
|
|
|
|
|
class TestEmitCodebaseConfig:
|
|
def test_emits_expected_shape(self, tmp_path: Path):
|
|
proj = tmp_path / "myproj"
|
|
proj.mkdir()
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
|
|
cfg_path = emit_codebase_config(proj, out)
|
|
|
|
assert cfg_path.name == "myproj-codebase.json"
|
|
data = json.loads(cfg_path.read_text())
|
|
assert data["name"] == "myproj-codebase"
|
|
assert "sources" in data
|
|
assert len(data["sources"]) == 1
|
|
source = data["sources"][0]
|
|
assert source["type"] == "local"
|
|
assert source["path"] == str(proj.resolve())
|
|
assert source.get("include_code") is True
|
|
assert "file_patterns" in source
|
|
assert "skip_patterns" in source
|
|
|
|
def test_overwrites_existing(self, tmp_path: Path):
|
|
proj = tmp_path / "p"
|
|
proj.mkdir()
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
|
|
first = emit_codebase_config(proj, out)
|
|
first.write_text(json.dumps({"name": "stale"}))
|
|
|
|
second = emit_codebase_config(proj, out)
|
|
data = json.loads(second.read_text())
|
|
assert data["name"] == "p-codebase"
|
|
|
|
|
|
class TestDiffAgainstExisting:
|
|
def _write(self, out: Path, name: str, version: str | None) -> Path:
|
|
path = out / f"{name}.json"
|
|
payload = {"name": name}
|
|
if version is not None:
|
|
payload["detected_version"] = version
|
|
path.write_text(json.dumps(payload))
|
|
return path
|
|
|
|
def test_all_added_when_out_empty(self, tmp_path: Path):
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
detections = [
|
|
Detection(
|
|
name="react",
|
|
ecosystem="npm",
|
|
version="18.3.0",
|
|
kind="framework",
|
|
confidence=0.9,
|
|
evidence="",
|
|
),
|
|
]
|
|
diff = diff_against_existing(out, detections)
|
|
assert diff.added == ["react"]
|
|
assert diff.updated == []
|
|
assert diff.removed == []
|
|
|
|
def test_no_change_when_versions_match(self, tmp_path: Path):
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
self._write(out, "react", "18.3.0")
|
|
detections = [
|
|
Detection(
|
|
name="react",
|
|
ecosystem="npm",
|
|
version="18.3.0",
|
|
kind="framework",
|
|
confidence=0.9,
|
|
evidence="",
|
|
),
|
|
]
|
|
diff = diff_against_existing(out, detections)
|
|
assert diff.added == []
|
|
assert diff.updated == []
|
|
assert diff.removed == []
|
|
|
|
def test_updated_on_version_bump(self, tmp_path: Path):
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
self._write(out, "react", "18.2.0")
|
|
detections = [
|
|
Detection(
|
|
name="react",
|
|
ecosystem="npm",
|
|
version="18.3.1",
|
|
kind="framework",
|
|
confidence=0.9,
|
|
evidence="",
|
|
),
|
|
]
|
|
diff = diff_against_existing(out, detections)
|
|
assert diff.added == []
|
|
assert diff.updated == [("react", "18.2.0", "18.3.1")]
|
|
assert diff.removed == []
|
|
|
|
def test_removed_when_detection_gone(self, tmp_path: Path):
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
self._write(out, "react", "18.2.0")
|
|
self._write(out, "vue", "3.4.0")
|
|
detections = [
|
|
Detection(
|
|
name="react",
|
|
ecosystem="npm",
|
|
version="18.2.0",
|
|
kind="framework",
|
|
confidence=0.9,
|
|
evidence="",
|
|
),
|
|
]
|
|
diff = diff_against_existing(out, detections)
|
|
assert diff.added == []
|
|
assert diff.updated == []
|
|
assert diff.removed == ["vue"]
|
|
|
|
def test_ignores_codebase_config(self, tmp_path: Path):
|
|
"""The <project>-codebase.json should never appear in diffs."""
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
(out / "myproj-codebase.json").write_text(
|
|
json.dumps({"name": "myproj-codebase", "sources": [{"type": "local"}]})
|
|
)
|
|
detections: list[Detection] = []
|
|
diff = diff_against_existing(out, detections)
|
|
assert diff.added == []
|
|
assert diff.updated == []
|
|
assert diff.removed == []
|
|
|
|
def test_no_churn_when_internal_name_differs_from_detection(self, tmp_path: Path):
|
|
"""Regression: diff used to key existing off `data['name']` (canonical
|
|
preset name) but current off `Detection.name` (AI display name). Even
|
|
with the same package across scans, the keys mismatched and the diff
|
|
spammed phantom 'added X / removed Y'. Now both sides key by filename
|
|
slug.
|
|
"""
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
# Simulate the file that scan would have written: filename is the slug
|
|
# of the AI's name ("Godot Engine" → "godot-engine.json"), but the
|
|
# *resolved* canonical config inside has internal name = "godot".
|
|
(out_dir / "godot-engine.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "godot", # canonical from registry, NOT matching the slug
|
|
"detected_version": "4.7",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Second-scan detection: AI returns same display name again.
|
|
detections = [
|
|
Detection(
|
|
name="Godot Engine",
|
|
ecosystem="other",
|
|
version="4.7",
|
|
kind="framework",
|
|
confidence=1.0,
|
|
evidence="",
|
|
),
|
|
]
|
|
diff = diff_against_existing(out_dir, detections)
|
|
assert diff.added == []
|
|
assert diff.updated == []
|
|
assert diff.removed == []
|
|
|
|
def test_handles_config_without_detected_version(self, tmp_path: Path):
|
|
"""Configs without detected_version should diff as 'updated' when one arrives."""
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
self._write(out, "react", None) # no detected_version
|
|
detections = [
|
|
Detection(
|
|
name="react",
|
|
ecosystem="npm",
|
|
version="18.3.0",
|
|
kind="framework",
|
|
confidence=0.9,
|
|
evidence="",
|
|
),
|
|
]
|
|
diff = diff_against_existing(out, detections)
|
|
# name still present → not added, not removed
|
|
assert diff.added == []
|
|
assert diff.removed == []
|
|
# missing → new version is an update from None → "18.3.0"
|
|
assert diff.updated == [("react", None, "18.3.0")]
|
|
|
|
|
|
def _bundle(project_name: str = "myproj") -> SignalBundle:
|
|
"""Minimal SignalBundle for AI-layer tests."""
|
|
return SignalBundle(
|
|
signals=[
|
|
Signal(
|
|
kind="manifest",
|
|
path=Path("package.json"),
|
|
content='{"dependencies": {"react": "^18.3.0"}}',
|
|
),
|
|
],
|
|
git_remote=None,
|
|
project_name=project_name,
|
|
)
|
|
|
|
|
|
class TestDetectWithAI:
|
|
def test_parses_clean_json_array(self):
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.95,
|
|
"evidence": "package.json",
|
|
},
|
|
]
|
|
)
|
|
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert len(detections) == 1
|
|
d = detections[0]
|
|
assert isinstance(d, Detection)
|
|
assert d.name == "react"
|
|
assert d.version == "18.3.0"
|
|
assert d.kind == "framework"
|
|
|
|
def test_extracts_json_from_markdown_fence(self):
|
|
client = MagicMock()
|
|
client.call.return_value = (
|
|
"Here are the detections:\n\n"
|
|
"```json\n"
|
|
'[{"name": "fastapi", "ecosystem": "pypi", "version": "0.110.0", '
|
|
'"kind": "framework", "confidence": 0.9, "evidence": "pyproject.toml"}]\n'
|
|
"```\n\nLet me know if you want more."
|
|
)
|
|
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert len(detections) == 1
|
|
assert detections[0].name == "fastapi"
|
|
|
|
def test_malformed_json_returns_empty_list(self):
|
|
client = MagicMock()
|
|
client.call.return_value = "totally not json"
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert detections == []
|
|
|
|
def test_none_response_returns_empty_list(self):
|
|
client = MagicMock()
|
|
client.call.return_value = None
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert detections == []
|
|
|
|
def test_filters_below_confidence_threshold(self):
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.95,
|
|
"evidence": "x",
|
|
},
|
|
{
|
|
"name": "lodash",
|
|
"ecosystem": "npm",
|
|
"version": "4.17.0",
|
|
"kind": "library",
|
|
"confidence": 0.2,
|
|
"evidence": "transitive",
|
|
},
|
|
]
|
|
)
|
|
|
|
detections = detect_with_ai(_bundle(), client, min_confidence=0.5)
|
|
assert [d.name for d in detections] == ["react"]
|
|
|
|
def test_non_numeric_confidence_is_dropped_not_crash(self):
|
|
"""Regression for SCAN-02: a non-numeric confidence must drop that one
|
|
entry, not crash the whole scan with ValueError/TypeError."""
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": "high", # non-numeric — would crash float()
|
|
"evidence": "x",
|
|
},
|
|
{
|
|
"name": "vue",
|
|
"ecosystem": "npm",
|
|
"version": "3.4.0",
|
|
"kind": "framework",
|
|
"confidence": 0.9,
|
|
"evidence": "y",
|
|
},
|
|
]
|
|
)
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert [d.name for d in detections] == ["vue"]
|
|
|
|
def test_handles_agentclient_exception_gracefully(self):
|
|
"""AgentClient auth/network errors must not crash the scan."""
|
|
client = MagicMock()
|
|
client.call.side_effect = RuntimeError("API key invalid")
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert detections == []
|
|
|
|
def test_drops_entries_missing_required_fields(self):
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.9,
|
|
"evidence": "x",
|
|
},
|
|
{"ecosystem": "npm", "version": "1.0.0"}, # no name → invalid
|
|
]
|
|
)
|
|
|
|
detections = detect_with_ai(_bundle(), client)
|
|
assert [d.name for d in detections] == ["react"]
|
|
|
|
|
|
class TestGenerateConfigWithAI:
|
|
def _det(self, name="newlib", version="1.0.0"):
|
|
return Detection(
|
|
name=name,
|
|
ecosystem="npm",
|
|
version=version,
|
|
kind="library",
|
|
confidence=0.9,
|
|
evidence="package.json",
|
|
)
|
|
|
|
def test_returns_valid_config_dict(self):
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
{
|
|
"name": "newlib",
|
|
"description": "Some new library.",
|
|
"sources": [{"type": "documentation", "base_url": "https://newlib.dev/"}],
|
|
}
|
|
)
|
|
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg["name"] == "newlib"
|
|
assert cfg["sources"][0]["type"] == "documentation"
|
|
# detected_version stamped from the Detection (WS2: now under metadata)
|
|
assert cfg["metadata"]["detected_version"] == "1.0.0"
|
|
|
|
def test_extracts_from_markdown_fence(self):
|
|
client = MagicMock()
|
|
client.call.return_value = (
|
|
"```json\n"
|
|
'{"name": "newlib", "description": "x", '
|
|
'"sources": [{"type": "documentation", "base_url": "https://x.io"}]}\n'
|
|
"```"
|
|
)
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg["name"] == "newlib"
|
|
|
|
def test_retries_once_on_invalid_then_succeeds(self):
|
|
client = MagicMock()
|
|
client.call.side_effect = [
|
|
"garbage not json",
|
|
json.dumps(
|
|
{
|
|
"name": "newlib",
|
|
"description": "ok",
|
|
"sources": [{"type": "documentation", "base_url": "https://x.io"}],
|
|
}
|
|
),
|
|
]
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg is not None
|
|
assert cfg["name"] == "newlib"
|
|
assert client.call.call_count == 2
|
|
|
|
def test_returns_none_after_repeated_failures(self):
|
|
client = MagicMock()
|
|
client.call.return_value = "garbage"
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg is None
|
|
|
|
def test_returns_none_when_validator_rejects(self):
|
|
"""AI returns JSON but missing required fields → validator fails → None."""
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps({"name": "x"}) # no description, no sources
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg is None
|
|
|
|
def test_handles_agentclient_exception_gracefully(self):
|
|
"""Generator call raising → retries; if all retries raise → None, no crash."""
|
|
client = MagicMock()
|
|
client.call.side_effect = RuntimeError("connection refused")
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg is None
|
|
# Both attempts were made — exception didn't break the retry loop.
|
|
assert client.call.call_count == 2
|
|
|
|
def test_rejects_name_that_breaks_registry_submission(self):
|
|
"""submit_config_tool requires name ^[a-zA-Z0-9_-]+$. Reject up front
|
|
so we don't write configs that silently fail to publish later."""
|
|
client = MagicMock()
|
|
bad = json.dumps(
|
|
{
|
|
"name": "@scope/pkg", # / and @ both forbidden by the registry regex
|
|
"description": "valid otherwise",
|
|
"sources": [{"type": "documentation", "base_url": "https://x.io"}],
|
|
}
|
|
)
|
|
good = json.dumps(
|
|
{
|
|
"name": "scope-pkg", # acceptable
|
|
"description": "valid otherwise",
|
|
"sources": [{"type": "documentation", "base_url": "https://x.io"}],
|
|
}
|
|
)
|
|
client.call.side_effect = [bad, good]
|
|
cfg = generate_config_with_ai(self._det(), client)
|
|
assert cfg is not None
|
|
assert cfg["name"] == "scope-pkg"
|
|
assert client.call.call_count == 2
|
|
|
|
|
|
class TestResolveOrGenerate:
|
|
def _det(self, name="react", version="18.3.0"):
|
|
return Detection(
|
|
name=name,
|
|
ecosystem="npm",
|
|
version=version,
|
|
kind="framework",
|
|
confidence=0.9,
|
|
evidence="package.json",
|
|
)
|
|
|
|
def _existing_config(self, tmp_path: Path, name: str) -> Path:
|
|
path = tmp_path / f"{name}.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": name,
|
|
"description": f"{name} skill",
|
|
"sources": [{"type": "documentation", "base_url": f"https://{name}.io"}],
|
|
}
|
|
)
|
|
)
|
|
return path
|
|
|
|
def test_local_hit_returns_stamped_copy(self, tmp_path: Path):
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
existing = self._existing_config(tmp_path, "react")
|
|
|
|
with patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=existing,
|
|
):
|
|
result_path = resolve_or_generate(
|
|
self._det(),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
)
|
|
|
|
assert result_path is not None
|
|
assert result_path.parent == out_dir
|
|
data = json.loads(result_path.read_text())
|
|
# WS2: detected_version now lives under metadata.
|
|
assert data["metadata"]["detected_version"] == "18.3.0"
|
|
assert data["name"] == "react"
|
|
|
|
def test_no_fetch_passes_auto_fetch_false(self, tmp_path: Path):
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
|
|
with patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=None,
|
|
) as resolve_mock:
|
|
resolve_or_generate(
|
|
self._det(),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=False,
|
|
allow_generate=False,
|
|
)
|
|
|
|
# resolve_config_path is called with auto_fetch=False when allow_network=False
|
|
_, kwargs = resolve_mock.call_args
|
|
assert kwargs.get("auto_fetch") is False
|
|
|
|
def test_falls_back_to_ai_generation_on_miss(self, tmp_path: Path):
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
|
|
generated = {
|
|
"name": "react",
|
|
"description": "react",
|
|
"sources": [{"type": "documentation", "base_url": "https://react.dev"}],
|
|
"detected_version": "18.3.0",
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"skill_seekers.cli.scan_command.generate_config_with_ai",
|
|
return_value=generated,
|
|
) as gen_mock,
|
|
):
|
|
result_path = resolve_or_generate(
|
|
self._det(),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
)
|
|
|
|
assert result_path is not None
|
|
assert gen_mock.called
|
|
data = json.loads(result_path.read_text())
|
|
assert data["name"] == "react"
|
|
|
|
def test_no_generate_returns_none_on_miss(self, tmp_path: Path):
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
|
|
with (
|
|
patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"skill_seekers.cli.scan_command.generate_config_with_ai",
|
|
) as gen_mock,
|
|
):
|
|
result_path = resolve_or_generate(
|
|
self._det(),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
)
|
|
|
|
assert result_path is None
|
|
gen_mock.assert_not_called()
|
|
|
|
def test_marks_generated_path_as_freshly_generated(self, tmp_path: Path):
|
|
"""Resolver returns a tuple (path, was_generated) so caller can prompt to publish."""
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
existing = self._existing_config(tmp_path, "react")
|
|
|
|
with patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=existing,
|
|
):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
path, was_generated = resolve_or_generate_with_status(
|
|
self._det(),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
)
|
|
assert was_generated is False
|
|
|
|
# Generated case — fresh out_dir so the cache shortcut doesn't fire.
|
|
out_dir2 = tmp_path / "out2"
|
|
out_dir2.mkdir()
|
|
with (
|
|
patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"skill_seekers.cli.scan_command.generate_config_with_ai",
|
|
return_value={
|
|
"name": "react",
|
|
"description": "x",
|
|
"sources": [{"type": "documentation", "base_url": "https://x"}],
|
|
"detected_version": "18.3.0",
|
|
},
|
|
),
|
|
):
|
|
path2, was_generated2 = resolve_or_generate_with_status(
|
|
self._det(),
|
|
out_dir=out_dir2,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
)
|
|
assert was_generated2 is True
|
|
|
|
|
|
class TestMaybePublish:
|
|
"""WS11: maybe_publish is now async. Tests drive it with asyncio.run.
|
|
Patches target the async helpers (_submit_config, _prompt_async,
|
|
_find_existing_issue) rather than the old _submit_config_sync."""
|
|
|
|
def _write_cfg(self, tmp_path: Path, name: str) -> Path:
|
|
path = tmp_path / f"{name}.json"
|
|
path.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": name,
|
|
"description": "x",
|
|
"sources": [{"type": "documentation", "base_url": "https://x.io"}],
|
|
}
|
|
)
|
|
)
|
|
return path
|
|
|
|
def _async_mock(self, return_value=None, side_effect=None):
|
|
"""MagicMock that returns an awaitable resolving to return_value."""
|
|
|
|
mock = MagicMock()
|
|
|
|
async def _coro(*args, **kwargs):
|
|
if side_effect is not None:
|
|
if isinstance(side_effect, BaseException) or (
|
|
isinstance(side_effect, type) and issubclass(side_effect, BaseException)
|
|
):
|
|
raise side_effect
|
|
return side_effect(*args, **kwargs)
|
|
return return_value
|
|
|
|
mock.side_effect = lambda *a, **kw: _coro(*a, **kw)
|
|
return mock
|
|
|
|
def _run(self, coro):
|
|
import asyncio
|
|
|
|
return asyncio.run(coro)
|
|
|
|
def test_no_prompt_skips_entirely(self, tmp_path: Path):
|
|
cfg = self._write_cfg(tmp_path, "newlib")
|
|
submit = self._async_mock()
|
|
|
|
with patch("skill_seekers.cli.scan_command._submit_config", submit):
|
|
self._run(maybe_publish([cfg], skip_prompt=True))
|
|
|
|
assert submit.call_count == 0
|
|
|
|
def test_no_generated_configs_no_prompt(self, tmp_path: Path):
|
|
submit = self._async_mock()
|
|
prompt = self._async_mock()
|
|
with (
|
|
patch("skill_seekers.cli.scan_command._submit_config", submit),
|
|
patch("skill_seekers.cli.scan_command._prompt_async", prompt),
|
|
):
|
|
self._run(maybe_publish([], skip_prompt=False))
|
|
assert prompt.call_count == 0
|
|
assert submit.call_count == 0
|
|
|
|
def test_yes_calls_submit_with_path(self, tmp_path: Path):
|
|
cfg = self._write_cfg(tmp_path, "newlib")
|
|
submit = self._async_mock(
|
|
return_value={"ok": True, "message": "https://github.com/x/y/issues/1"}
|
|
)
|
|
prompt = self._async_mock(return_value="y")
|
|
find = self._async_mock(return_value=None) # no existing issue
|
|
|
|
with (
|
|
patch.dict("os.environ", {"GITHUB_TOKEN": "fake"}),
|
|
patch("skill_seekers.cli.scan_command._submit_config", submit),
|
|
patch("skill_seekers.cli.scan_command._prompt_async", prompt),
|
|
patch("skill_seekers.cli.scan_command._find_existing_issue", find),
|
|
):
|
|
self._run(maybe_publish([cfg], skip_prompt=False))
|
|
|
|
assert submit.call_count == 1
|
|
# Path passed as first positional arg
|
|
args, _ = submit.call_args
|
|
assert args[0] == cfg
|
|
|
|
def test_no_does_not_call_submit(self, tmp_path: Path):
|
|
cfg = self._write_cfg(tmp_path, "newlib")
|
|
submit = self._async_mock()
|
|
prompt = self._async_mock(return_value="n")
|
|
find = self._async_mock(return_value=None)
|
|
with (
|
|
patch.dict("os.environ", {"GITHUB_TOKEN": "fake"}),
|
|
patch("skill_seekers.cli.scan_command._submit_config", submit),
|
|
patch("skill_seekers.cli.scan_command._prompt_async", prompt),
|
|
patch("skill_seekers.cli.scan_command._find_existing_issue", find),
|
|
):
|
|
self._run(maybe_publish([cfg], skip_prompt=False))
|
|
assert submit.call_count == 0
|
|
|
|
def test_missing_github_token_skips_prompt(self, tmp_path: Path, capsys):
|
|
"""Without GITHUB_TOKEN, don't ask 5 questions and fail 5 times."""
|
|
cfg = self._write_cfg(tmp_path, "newlib")
|
|
submit = self._async_mock()
|
|
prompt = self._async_mock()
|
|
with (
|
|
patch("skill_seekers.cli.scan_command._submit_config", submit),
|
|
patch.dict("os.environ", {}, clear=True),
|
|
patch("skill_seekers.cli.scan_command._prompt_async", prompt),
|
|
):
|
|
self._run(maybe_publish([cfg], skip_prompt=False))
|
|
assert prompt.call_count == 0
|
|
assert submit.call_count == 0
|
|
captured = capsys.readouterr()
|
|
assert "GITHUB_TOKEN" in captured.out
|
|
|
|
def test_handles_submit_failure_gracefully(self, tmp_path: Path, capsys):
|
|
cfg = self._write_cfg(tmp_path, "newlib")
|
|
submit = self._async_mock(side_effect=RuntimeError("boom"))
|
|
prompt = self._async_mock(return_value="y")
|
|
find = self._async_mock(return_value=None)
|
|
with (
|
|
patch.dict("os.environ", {"GITHUB_TOKEN": "fake"}),
|
|
patch("skill_seekers.cli.scan_command._submit_config", submit),
|
|
patch("skill_seekers.cli.scan_command._prompt_async", prompt),
|
|
patch("skill_seekers.cli.scan_command._find_existing_issue", find),
|
|
):
|
|
self._run(maybe_publish([cfg], skip_prompt=False))
|
|
captured = capsys.readouterr()
|
|
assert "boom" in captured.out
|
|
|
|
def test_idempotency_existing_issue_skips_submission(self, tmp_path: Path, capsys):
|
|
"""WS11: if an issue already exists, skip the prompt + submission."""
|
|
cfg = self._write_cfg(tmp_path, "newlib")
|
|
submit = self._async_mock()
|
|
prompt = self._async_mock()
|
|
find = self._async_mock(return_value="https://github.com/y/z/issues/42")
|
|
|
|
with (
|
|
patch.dict("os.environ", {"GITHUB_TOKEN": "fake"}),
|
|
patch("skill_seekers.cli.scan_command._submit_config", submit),
|
|
patch("skill_seekers.cli.scan_command._prompt_async", prompt),
|
|
patch("skill_seekers.cli.scan_command._find_existing_issue", find),
|
|
):
|
|
self._run(maybe_publish([cfg], skip_prompt=False))
|
|
|
|
assert submit.call_count == 0 # no duplicate submission
|
|
assert prompt.call_count == 0 # no prompt either
|
|
captured = capsys.readouterr()
|
|
assert "issues/42" in captured.out
|
|
|
|
|
|
class TestRunScan:
|
|
"""End-to-end scan against a fixture project. AI and network stubbed."""
|
|
|
|
def _make_project(self, tmp_path: Path) -> Path:
|
|
proj = tmp_path / "fakeproj"
|
|
proj.mkdir()
|
|
(proj / "package.json").write_text(
|
|
json.dumps({"name": "fakeproj", "dependencies": {"react": "^18.3.0"}})
|
|
)
|
|
(proj / "README.md").write_text("# fakeproj\n\nUses React.")
|
|
return proj
|
|
|
|
def test_resolved_path_emits_config_and_codebase(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
# Stub AI client to return one react detection.
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.95,
|
|
"evidence": "package.json",
|
|
}
|
|
]
|
|
)
|
|
|
|
# Stub resolve_config_path so it returns a pre-existing react.json.
|
|
canned = tmp_path / "react.json"
|
|
canned.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "react",
|
|
"description": "react",
|
|
"sources": [{"type": "documentation", "base_url": "https://react.dev"}],
|
|
}
|
|
)
|
|
)
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=canned):
|
|
result = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
|
|
assert len(result.detections) == 1
|
|
assert result.detections[0].name == "react"
|
|
# 1 framework + codebase
|
|
assert result.codebase_config is not None
|
|
assert result.codebase_config.name == "fakeproj-codebase.json"
|
|
assert len(result.emitted) == 1
|
|
assert result.emitted[0].name == "react.json"
|
|
# Resolved (not generated)
|
|
assert result.generated == []
|
|
# On first scan, react is "added"
|
|
assert result.diff is not None
|
|
assert "react" in result.diff.added
|
|
|
|
def test_unmapped_path_triggers_ai_generation(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
client = MagicMock()
|
|
# First call → detection. Second call → AI-generated config.
|
|
client.call.side_effect = [
|
|
json.dumps(
|
|
[
|
|
{
|
|
"name": "supernovafw",
|
|
"ecosystem": "npm",
|
|
"version": "0.1.0",
|
|
"kind": "framework",
|
|
"confidence": 0.9,
|
|
"evidence": "package.json",
|
|
}
|
|
]
|
|
),
|
|
json.dumps(
|
|
{
|
|
"name": "supernovafw",
|
|
"description": "Made up framework.",
|
|
"sources": [{"type": "documentation", "base_url": "https://supernovafw.dev"}],
|
|
}
|
|
),
|
|
]
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
result = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
|
|
assert len(result.generated) == 1
|
|
assert result.generated[0].name == "supernovafw.json"
|
|
data = json.loads(result.generated[0].read_text())
|
|
# WS2: detected_version now lives under metadata.
|
|
assert data["metadata"]["detected_version"] == "0.1.0"
|
|
|
|
def test_re_scan_reports_no_changes(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.95,
|
|
"evidence": "x",
|
|
}
|
|
]
|
|
)
|
|
|
|
canned = tmp_path / "react.json"
|
|
canned.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "react",
|
|
"description": "react",
|
|
"sources": [{"type": "documentation", "base_url": "https://react.dev"}],
|
|
}
|
|
)
|
|
)
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=canned):
|
|
run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
# Second pass — same detection, same version → no diff.
|
|
result2 = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
|
|
assert result2.diff is not None
|
|
assert result2.diff.added == []
|
|
assert result2.diff.updated == []
|
|
assert result2.diff.removed == []
|
|
|
|
def test_no_generate_skips_unmapped_silently(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "obscurelib",
|
|
"ecosystem": "npm",
|
|
"version": "1.0.0",
|
|
"kind": "library",
|
|
"confidence": 0.9,
|
|
"evidence": "x",
|
|
}
|
|
]
|
|
)
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
result = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=False,
|
|
allow_generate=False,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
|
|
# Detection happened but no config could be produced.
|
|
assert len(result.detections) == 1
|
|
assert result.emitted == []
|
|
assert result.generated == []
|
|
# Codebase config is still emitted.
|
|
assert result.codebase_config is not None
|
|
|
|
|
|
class TestArchiveRemovedConfigs:
|
|
"""WS10: stale configs move to .archived/<timestamp>/, not deleted."""
|
|
|
|
def _setup_project(self, tmp_path: Path):
|
|
proj = tmp_path / "p"
|
|
proj.mkdir()
|
|
(proj / "package.json").write_text('{"name": "p"}')
|
|
out = tmp_path / "out"
|
|
out.mkdir()
|
|
return proj, out
|
|
|
|
def _client_returning(self, names):
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": n,
|
|
"ecosystem": "npm",
|
|
"version": "1.0.0",
|
|
"kind": "library",
|
|
"confidence": 0.9,
|
|
"evidence": "x",
|
|
}
|
|
for n in names
|
|
]
|
|
)
|
|
return client
|
|
|
|
def test_removed_config_is_archived_not_deleted(self, tmp_path: Path):
|
|
proj, out = self._setup_project(tmp_path)
|
|
# Simulate a prior scan that emitted moment.json + react.json
|
|
stale = out / "moment.json"
|
|
stale.write_text(json.dumps({"name": "moment", "metadata": {"detected_version": "2.0.0"}}))
|
|
keep = out / "react.json"
|
|
keep.write_text(json.dumps({"name": "react", "metadata": {"detected_version": "18.0.0"}}))
|
|
|
|
# New scan detects only react → moment should be archived.
|
|
client = self._client_returning(["react"])
|
|
with patch(
|
|
"skill_seekers.cli.scan_command.resolve_config_path",
|
|
return_value=keep,
|
|
):
|
|
result = run_scan(
|
|
proj,
|
|
out,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
|
|
# moment.json no longer in out/, but exists in .archived/<ts>/
|
|
assert not stale.exists()
|
|
archived_root = out / ".archived"
|
|
assert archived_root.is_dir()
|
|
# Exactly one timestamped subdir, containing moment.json
|
|
ts_dirs = list(archived_root.iterdir())
|
|
assert len(ts_dirs) == 1
|
|
archived_files = list(ts_dirs[0].iterdir())
|
|
assert any(f.name == "moment.json" for f in archived_files)
|
|
# ScanResult.archived reflects the move
|
|
assert len(result.archived) == 1
|
|
assert result.archived[0].name == "moment.json"
|
|
|
|
def test_no_removed_means_no_archive_dir(self, tmp_path: Path):
|
|
proj, out = self._setup_project(tmp_path)
|
|
keep = out / "react.json"
|
|
keep.write_text(json.dumps({"name": "react", "metadata": {"detected_version": "18.0.0"}}))
|
|
|
|
client = self._client_returning(["react"])
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=keep):
|
|
result = run_scan(
|
|
proj,
|
|
out,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
)
|
|
|
|
assert result.archived == []
|
|
assert not (out / ".archived").exists()
|
|
|
|
def test_dry_run_does_not_archive(self, tmp_path: Path):
|
|
proj, out = self._setup_project(tmp_path)
|
|
stale = out / "moment.json"
|
|
stale.write_text(json.dumps({"name": "moment", "metadata": {"detected_version": "2.0.0"}}))
|
|
|
|
client = self._client_returning(["react"])
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
run_scan(
|
|
proj,
|
|
out,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
dry_run=True,
|
|
)
|
|
|
|
# Stale file still in place; nothing was actually moved.
|
|
assert stale.exists()
|
|
assert not (out / ".archived").exists()
|
|
|
|
|
|
class TestProbeUrls:
|
|
"""WS9: URL reachability probe for AI-generated configs."""
|
|
|
|
def _det(self, name="newlib"):
|
|
return Detection(
|
|
name=name,
|
|
ecosystem="npm",
|
|
version="1.0.0",
|
|
kind="library",
|
|
confidence=0.9,
|
|
evidence="x",
|
|
)
|
|
|
|
def _good_response(self):
|
|
return json.dumps(
|
|
{
|
|
"name": "newlib",
|
|
"description": "x",
|
|
"base_url": "https://newlib.dev",
|
|
"sources": [
|
|
{"type": "documentation", "base_url": "https://newlib.dev"},
|
|
{"type": "github", "repo": "owner/newlib"},
|
|
],
|
|
}
|
|
)
|
|
|
|
def test_no_probe_skips_url_check(self):
|
|
client = MagicMock()
|
|
client.call.return_value = self._good_response()
|
|
# No probe call expected — would normally fire httpx.
|
|
with patch("httpx.Client") as httpx_mock:
|
|
cfg = generate_config_with_ai(self._det(), client, probe_urls=False)
|
|
assert cfg is not None
|
|
httpx_mock.assert_not_called()
|
|
|
|
def test_probe_passes_when_all_urls_ok(self):
|
|
client = MagicMock()
|
|
client.call.return_value = self._good_response()
|
|
|
|
# Stub httpx so all HEADs return 200.
|
|
good_resp = MagicMock()
|
|
good_resp.status_code = 200
|
|
with patch("httpx.Client") as httpx_cls:
|
|
httpx_cls.return_value.__enter__.return_value.head.return_value = good_resp
|
|
cfg = generate_config_with_ai(self._det(), client, probe_urls=True)
|
|
assert cfg is not None
|
|
# Should NOT have _url_unverified stamp when all URLs are good.
|
|
assert "_url_unverified" not in cfg.get("metadata", {})
|
|
|
|
def test_probe_falls_back_to_get_on_405(self):
|
|
"""Regression: some servers return 405 Method Not Allowed on HEAD even
|
|
for valid URLs (older PyPI mirrors, Cloudflare-fronted sites). Probe
|
|
must fall back to GET so real URLs aren't flagged as unreachable."""
|
|
client = MagicMock()
|
|
client.call.return_value = self._good_response()
|
|
|
|
head_405 = MagicMock()
|
|
head_405.status_code = 405
|
|
get_200 = MagicMock()
|
|
get_200.status_code = 200
|
|
|
|
with patch("httpx.Client") as httpx_cls:
|
|
instance = httpx_cls.return_value.__enter__.return_value
|
|
instance.head.return_value = head_405
|
|
instance.get.return_value = get_200
|
|
cfg = generate_config_with_ai(self._det(), client, probe_urls=True)
|
|
|
|
assert cfg is not None
|
|
# Server returned 405 on HEAD, 200 on GET → URL is valid → no unverified stamp.
|
|
assert "_url_unverified" not in cfg.get("metadata", {})
|
|
# And GET WAS called (fallback fired).
|
|
assert instance.get.called
|
|
|
|
def test_probe_retries_on_bad_urls_then_succeeds(self):
|
|
client = MagicMock()
|
|
# First call returns config with bad URL, second returns config with good URL
|
|
bad_response = json.dumps(
|
|
{
|
|
"name": "newlib",
|
|
"description": "x",
|
|
"base_url": "https://badurl.invalid",
|
|
"sources": [{"type": "documentation", "base_url": "https://badurl.invalid"}],
|
|
}
|
|
)
|
|
good_response = json.dumps(
|
|
{
|
|
"name": "newlib",
|
|
"description": "x",
|
|
"base_url": "https://newlib.dev",
|
|
"sources": [{"type": "documentation", "base_url": "https://newlib.dev"}],
|
|
}
|
|
)
|
|
client.call.side_effect = [bad_response, good_response]
|
|
|
|
good_resp = MagicMock()
|
|
good_resp.status_code = 200
|
|
bad_resp = MagicMock()
|
|
bad_resp.status_code = 404
|
|
|
|
# First probe: 404. Second probe: 200.
|
|
with patch("httpx.Client") as httpx_cls:
|
|
instance = httpx_cls.return_value.__enter__.return_value
|
|
instance.head.side_effect = [bad_resp, good_resp]
|
|
cfg = generate_config_with_ai(self._det(), client, probe_urls=True)
|
|
|
|
assert cfg is not None
|
|
assert cfg["base_url"] == "https://newlib.dev"
|
|
assert "_url_unverified" not in cfg.get("metadata", {})
|
|
# Both attempts were made.
|
|
assert client.call.call_count == 2
|
|
|
|
def test_probe_stamps_unverified_when_retries_exhausted(self):
|
|
client = MagicMock()
|
|
# Both attempts return configs with bad URLs.
|
|
bad_response = json.dumps(
|
|
{
|
|
"name": "newlib",
|
|
"description": "x",
|
|
"base_url": "https://badurl.invalid",
|
|
"sources": [{"type": "documentation", "base_url": "https://badurl.invalid"}],
|
|
}
|
|
)
|
|
client.call.side_effect = [bad_response, bad_response]
|
|
|
|
bad_resp = MagicMock()
|
|
bad_resp.status_code = 404
|
|
with patch("httpx.Client") as httpx_cls:
|
|
instance = httpx_cls.return_value.__enter__.return_value
|
|
instance.head.return_value = bad_resp
|
|
cfg = generate_config_with_ai(self._det(), client, probe_urls=True)
|
|
|
|
# Returns config with _url_unverified stamped so user sees what to fix.
|
|
assert cfg is not None
|
|
assert "_url_unverified" in cfg["metadata"]
|
|
assert "https://badurl.invalid" in cfg["metadata"]["_url_unverified"]
|
|
|
|
|
|
class TestMaxAiGenerationsCap:
|
|
"""WS8: cap unbounded AI generation so monorepos can't surprise-bill users."""
|
|
|
|
def _make_project(self, tmp_path: Path) -> Path:
|
|
proj = tmp_path / "p"
|
|
proj.mkdir()
|
|
(proj / "package.json").write_text('{"name": "p"}')
|
|
return proj
|
|
|
|
def test_cap_stops_ai_generation_after_n_calls(self, tmp_path: Path):
|
|
"""5 unmapped detections, cap=2 → only 2 generated, 3 listed as failed."""
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
# AI returns 5 detections, all with low canonical match → 5 unmapped.
|
|
detections_json = json.dumps(
|
|
[
|
|
{
|
|
"name": f"unmapped{i}",
|
|
"ecosystem": "npm",
|
|
"version": "1.0.0",
|
|
"kind": "library",
|
|
"confidence": 0.9,
|
|
"evidence": "x",
|
|
}
|
|
for i in range(5)
|
|
]
|
|
)
|
|
|
|
gen_responses = [
|
|
json.dumps(
|
|
{
|
|
"name": f"unmapped{i}",
|
|
"description": "x",
|
|
"sources": [{"type": "documentation", "base_url": f"https://u{i}.dev"}],
|
|
}
|
|
)
|
|
for i in range(5)
|
|
]
|
|
|
|
client = MagicMock()
|
|
client.call.side_effect = [detections_json] + gen_responses
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
result = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
max_ai_generations=2,
|
|
)
|
|
|
|
assert len(result.generated) == 2
|
|
assert len(result.failed) == 3
|
|
|
|
def test_cap_zero_acts_as_no_generate(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "x",
|
|
"ecosystem": "npm",
|
|
"version": "1.0.0",
|
|
"kind": "library",
|
|
"confidence": 0.9,
|
|
"evidence": "x",
|
|
}
|
|
]
|
|
)
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
result = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
max_ai_generations=0,
|
|
)
|
|
|
|
assert len(result.generated) == 0
|
|
assert result.failed == ["x"]
|
|
|
|
|
|
class TestDryRun:
|
|
"""WS8: dry-run previews scan output without writing or invoking AI generation."""
|
|
|
|
def _make_project(self, tmp_path: Path) -> Path:
|
|
proj = tmp_path / "p"
|
|
proj.mkdir()
|
|
(proj / "package.json").write_text('{"name": "p"}')
|
|
return proj
|
|
|
|
def test_dry_run_writes_nothing(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.95,
|
|
"evidence": "x",
|
|
}
|
|
]
|
|
)
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
result = run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
dry_run=True,
|
|
)
|
|
|
|
# Nothing on disk
|
|
assert not out_dir.exists() or list(out_dir.iterdir()) == []
|
|
# But preview paths populated for the report
|
|
assert result.codebase_config is not None
|
|
assert result.codebase_config.name == "p-codebase.json"
|
|
# The 1 detection went into either generated or failed (preview)
|
|
assert len(result.generated) + len(result.failed) == 1
|
|
|
|
def test_dry_run_does_not_call_ai_generator(self, tmp_path: Path):
|
|
proj = self._make_project(tmp_path)
|
|
out_dir = tmp_path / "out"
|
|
|
|
client = MagicMock()
|
|
client.call.return_value = json.dumps(
|
|
[
|
|
{
|
|
"name": "react",
|
|
"ecosystem": "npm",
|
|
"version": "18.3.0",
|
|
"kind": "framework",
|
|
"confidence": 0.95,
|
|
"evidence": "x",
|
|
}
|
|
]
|
|
)
|
|
|
|
gen_mock = MagicMock()
|
|
with (
|
|
patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None),
|
|
patch("skill_seekers.cli.scan_command.generate_config_with_ai", gen_mock),
|
|
):
|
|
run_scan(
|
|
proj,
|
|
out_dir,
|
|
agent_client=client,
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
skip_publish=True,
|
|
min_confidence=0.4,
|
|
dry_run=True,
|
|
)
|
|
|
|
gen_mock.assert_not_called()
|
|
|
|
|
|
class TestExitCodeFor:
|
|
"""Non-zero exit code on complete failure — for CI shell pipelines."""
|
|
|
|
def test_zero_when_codebase_emitted(self, tmp_path: Path):
|
|
r = ScanResult(codebase_config=tmp_path / "x-codebase.json")
|
|
assert _exit_code_for(r) == 0
|
|
|
|
def test_zero_when_any_framework_emitted(self, tmp_path: Path):
|
|
r = ScanResult(emitted=[tmp_path / "react.json"])
|
|
assert _exit_code_for(r) == 0
|
|
|
|
def test_one_when_nothing_emitted(self):
|
|
r = ScanResult()
|
|
assert _exit_code_for(r) == 1
|
|
|
|
def test_one_when_only_failures(self):
|
|
r = ScanResult(
|
|
detections=[Detection("x", "npm", None, "library", 0.9, "")],
|
|
failed=["x"],
|
|
)
|
|
assert _exit_code_for(r) == 1
|
|
|
|
|
|
class TestCliRegistration:
|
|
def test_scan_is_registered_in_command_classes(self):
|
|
"""Scan uses the class-dispatch table (WS1) — no _reconstruct_argv hack."""
|
|
from skill_seekers.cli.main import COMMAND_CLASSES
|
|
|
|
assert COMMAND_CLASSES["scan"] == (
|
|
"skill_seekers.cli.scan_command",
|
|
"ScanCommand",
|
|
)
|
|
|
|
def test_scan_not_in_legacy_command_modules(self):
|
|
"""Scan should NOT appear in COMMAND_MODULES — that's the legacy table."""
|
|
from skill_seekers.cli.main import COMMAND_MODULES
|
|
|
|
assert "scan" not in COMMAND_MODULES
|
|
|
|
def test_scan_command_class_consumes_args_namespace(self):
|
|
"""ScanCommand(args).execute() — no internal argparse re-parsing."""
|
|
import argparse
|
|
|
|
from skill_seekers.cli.scan_command import ScanCommand
|
|
|
|
# Build a minimal namespace as the dispatcher would
|
|
args = argparse.Namespace(
|
|
directory="/does/not/exist",
|
|
out="/tmp/whatever",
|
|
no_fetch=True,
|
|
no_generate=True,
|
|
no_publish_prompt=True,
|
|
agent=None,
|
|
min_confidence=0.4,
|
|
verbose=False,
|
|
)
|
|
cmd = ScanCommand(args)
|
|
# Bad directory → returns 1 without raising
|
|
assert cmd.execute() == 1
|
|
|
|
def test_scan_parser_registered(self):
|
|
from skill_seekers.cli.parsers import get_parser_names
|
|
|
|
assert "scan" in get_parser_names()
|
|
|
|
def test_scan_parser_has_expected_arguments(self):
|
|
import argparse
|
|
from skill_seekers.cli.parsers.scan_parser import ScanParser
|
|
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers()
|
|
ScanParser().create_parser(subparsers)
|
|
# Smoke parse: a minimal valid invocation
|
|
args = parser.parse_args(["scan", ".", "--no-fetch", "--no-generate"])
|
|
assert args.directory == "."
|
|
assert args.no_fetch is True
|
|
assert args.no_generate is True
|
|
|
|
|
|
class TestCanonicalNameCandidates:
|
|
"""Real-world hit-rate: AI returns 'Godot Engine', preset is 'godot'."""
|
|
|
|
def test_includes_original(self):
|
|
cands = _canonical_name_candidates("Godot Engine")
|
|
assert "Godot Engine" in cands
|
|
|
|
def test_includes_lowercase(self):
|
|
cands = _canonical_name_candidates("React")
|
|
assert "react" in cands
|
|
|
|
def test_includes_hyphenated_lowercase(self):
|
|
cands = _canonical_name_candidates("Spring Boot")
|
|
assert "spring-boot" in cands
|
|
|
|
def test_strips_common_suffixes(self):
|
|
# "Godot Engine" should produce "godot" as a fallback candidate.
|
|
cands = _canonical_name_candidates("Godot Engine")
|
|
assert "godot" in cands
|
|
|
|
cands = _canonical_name_candidates("Tailwind CSS")
|
|
assert "tailwind" in cands
|
|
|
|
cands = _canonical_name_candidates("Vue.js")
|
|
assert "vue" in cands
|
|
|
|
def test_strips_chinese_suffixes(self):
|
|
"""WS7: half the user base is CJK; suffixes must work there too."""
|
|
# Godot in Chinese — should still resolve to "godot" after stripping.
|
|
cands = _canonical_name_candidates("Godot 引擎")
|
|
assert "godot" in cands
|
|
|
|
cands = _canonical_name_candidates("React 框架")
|
|
assert "react" in cands
|
|
|
|
# Traditional Chinese variant
|
|
cands = _canonical_name_candidates("Vue 函式庫")
|
|
assert "vue" in cands
|
|
|
|
def test_strips_korean_suffixes(self):
|
|
cands = _canonical_name_candidates("Godot 엔진")
|
|
assert "godot" in cands
|
|
|
|
cands = _canonical_name_candidates("React 프레임워크")
|
|
assert "react" in cands
|
|
|
|
def test_strips_japanese_suffixes(self):
|
|
cands = _canonical_name_candidates("Godot エンジン")
|
|
assert "godot" in cands
|
|
|
|
cands = _canonical_name_candidates("React フレームワーク")
|
|
assert "react" in cands
|
|
|
|
cands = _canonical_name_candidates("Lodash ライブラリ")
|
|
assert "lodash" in cands
|
|
|
|
def test_strips_european_language_suffixes(self):
|
|
"""Spanish/French/Portuguese/German — common in EU/LATAM user base."""
|
|
# Spanish "motor" (engine)
|
|
cands = _canonical_name_candidates("Godot motor")
|
|
assert "godot" in cands
|
|
|
|
# French "cadre" (framework)
|
|
cands = _canonical_name_candidates("React cadre")
|
|
assert "react" in cands
|
|
|
|
# German "bibliothek" (library) — lowercased before matching
|
|
cands = _canonical_name_candidates("Lodash Bibliothek")
|
|
assert "lodash" in cands
|
|
|
|
def test_npm_scoped_package_unscoped_form(self):
|
|
cands = _canonical_name_candidates("@anthropic-ai/sdk")
|
|
# Both the full scoped name and a slugified unscoped form should appear.
|
|
assert "@anthropic-ai/sdk" in cands
|
|
# The slash gets normalized to a hyphen for filename-safe lookups.
|
|
assert any("anthropic-ai-sdk" in c or "anthropic-sdk" in c for c in cands)
|
|
|
|
def test_ordered_so_exact_match_wins(self):
|
|
"""Original input must come first so an existing exact-match preset
|
|
beats any normalized fallback."""
|
|
cands = _canonical_name_candidates("ReactQuery")
|
|
assert cands[0] == "ReactQuery"
|
|
|
|
def test_no_duplicates(self):
|
|
cands = _canonical_name_candidates("react")
|
|
assert len(cands) == len(set(cands))
|
|
|
|
def test_whitespace_input_returns_unchanged_singleton(self):
|
|
"""Pure-whitespace input is returned as a one-element list (no crash,
|
|
no candidate generation since there's nothing to canonicalize)."""
|
|
assert _canonical_name_candidates(" ") == [" "]
|
|
|
|
def test_empty_string_returns_empty_list(self):
|
|
"""An empty string has no candidates — returns []."""
|
|
assert _canonical_name_candidates("") == []
|
|
|
|
|
|
class TestResolverUsesCanonicalCandidates:
|
|
"""The resolver should try each canonical candidate against resolve_config_path."""
|
|
|
|
def _det(self, name: str):
|
|
return Detection(
|
|
name=name,
|
|
ecosystem="other",
|
|
version="4.7",
|
|
kind="framework",
|
|
confidence=1.0,
|
|
evidence="",
|
|
)
|
|
|
|
def test_resolver_tries_canonical_form_when_exact_misses(self, tmp_path: Path):
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
|
|
canonical_match = tmp_path / "godot.json"
|
|
canonical_match.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "godot",
|
|
"description": "godot",
|
|
"sources": [
|
|
{"type": "documentation", "base_url": "https://docs.godotengine.org"}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
# First call (exact "Godot Engine.json") returns None; eventually the
|
|
# canonical candidate "godot.json" hits. Resolver appends .json to
|
|
# every candidate so local repo / user dir lookups actually find files.
|
|
def fake_resolve(name, auto_fetch=True, fetch_destination="configs"): # noqa: ARG001 — mirrors real signature
|
|
return canonical_match if name == "godot.json" else None
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", side_effect=fake_resolve):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
path, was_generated = resolve_or_generate_with_status(
|
|
self._det("Godot Engine"),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
)
|
|
|
|
assert path is not None
|
|
assert was_generated is False
|
|
# Emitted config name should reflect the *detection* name (slugified for
|
|
# filesystem), not the canonical name — so it stays stable on re-scan.
|
|
# The content includes the resolved canonical config plus detected_version
|
|
# (WS2: nested under metadata).
|
|
data = json.loads(path.read_text())
|
|
assert data["metadata"]["detected_version"] == "4.7"
|
|
|
|
def test_resolver_appends_json_suffix_for_local_lookup(self, tmp_path: Path):
|
|
"""Regression: resolve_config_path checks for files literally — without
|
|
'.json' appended, local repo configs/ and user-dir configs/ are missed.
|
|
"""
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
seen: list[str] = []
|
|
|
|
def fake_resolve(name, auto_fetch=True, fetch_destination="configs"): # noqa: ARG001 — mirrors real signature
|
|
seen.append(name)
|
|
return None
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", side_effect=fake_resolve):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
resolve_or_generate_with_status(
|
|
self._det("react"),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
)
|
|
|
|
assert seen, "resolve_config_path was never called"
|
|
assert all(name.endswith(".json") for name in seen), (
|
|
f"Resolver should append .json to every lookup; got {seen}"
|
|
)
|
|
|
|
def test_resolver_reuses_existing_out_dir_config(self, tmp_path: Path):
|
|
"""Re-scan should re-use the prior-emitted config (just re-stamp version),
|
|
not waste an AI/API call and not blow away user edits."""
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
existing = out_dir / "react.json"
|
|
existing.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "react",
|
|
"description": "User-edited description",
|
|
"sources": [{"type": "documentation", "base_url": "https://react.dev"}],
|
|
"detected_version": "18.2.0",
|
|
"_user_notes": "Don't overwrite me", # simulates a manual edit
|
|
}
|
|
)
|
|
)
|
|
|
|
resolve_mock = MagicMock(return_value=None)
|
|
gen_mock = MagicMock(return_value={})
|
|
with (
|
|
patch("skill_seekers.cli.scan_command.resolve_config_path", resolve_mock),
|
|
patch("skill_seekers.cli.scan_command.generate_config_with_ai", gen_mock),
|
|
):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
path, was_generated = resolve_or_generate_with_status(
|
|
self._det("react"), # version="4.7" — different from existing 18.2.0
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=True,
|
|
)
|
|
|
|
assert path == existing
|
|
assert was_generated is False
|
|
# Resolver and generator should NOT have been called when cache hit.
|
|
resolve_mock.assert_not_called()
|
|
gen_mock.assert_not_called()
|
|
# User edits preserved; version re-stamped (WS2: under metadata).
|
|
data = json.loads(existing.read_text())
|
|
assert data["_user_notes"] == "Don't overwrite me"
|
|
assert data["description"] == "User-edited description"
|
|
assert data["metadata"]["detected_version"] == "4.7"
|
|
|
|
def test_resolver_returns_none_when_no_candidate_hits(self, tmp_path: Path):
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", return_value=None):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
path, _ = resolve_or_generate_with_status(
|
|
self._det("SomeNonsenseLib"),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
)
|
|
assert path is None
|
|
|
|
def test_resolver_preserves_preexisting_user_config_in_out_dir(self, tmp_path: Path):
|
|
"""Regression: with out_dir == ./configs, resolve_config_path's
|
|
CWD-relative 'configs/' lookup can return a user-managed file that
|
|
already lives inside out_dir under a different slug (configs/godot.json
|
|
for the godot-engine.json target). The intermediate cleanup must not
|
|
delete a file this run did not create — copy, never move."""
|
|
out_dir = tmp_path / "configs"
|
|
out_dir.mkdir()
|
|
user_config = out_dir / "godot.json"
|
|
user_config.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "godot",
|
|
"description": "Hand-maintained by the user",
|
|
"sources": [
|
|
{"type": "documentation", "base_url": "https://docs.godotengine.org"}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
|
|
def fake_resolve(name, auto_fetch=True, fetch_destination="configs"): # noqa: ARG001 — mirrors real signature
|
|
# Simulates step 2 of resolve_config_path: the CWD-relative
|
|
# configs/ lookup returns the user's pre-existing file (no fetch).
|
|
return user_config.resolve() if name == "godot.json" else None
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", side_effect=fake_resolve):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
path, was_generated = resolve_or_generate_with_status(
|
|
self._det("Godot Engine"),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
)
|
|
|
|
assert path == out_dir / "godot-engine.json"
|
|
assert was_generated is False
|
|
# The user's hand-maintained file must survive untouched.
|
|
assert user_config.exists(), "pre-existing user config was deleted by the scan"
|
|
data = json.loads(user_config.read_text())
|
|
assert data["description"] == "Hand-maintained by the user"
|
|
|
|
def test_resolver_still_removes_fetched_intermediate_in_out_dir(self, tmp_path: Path):
|
|
"""The intermediate cleanup still applies to canonical-named files the
|
|
resolve step itself fetched into out_dir during this run — otherwise
|
|
they show up as phantom 'removed' configs on the next scan."""
|
|
out_dir = tmp_path / "out"
|
|
out_dir.mkdir()
|
|
|
|
def fake_resolve(name, auto_fetch=True, fetch_destination="configs"): # noqa: ARG001 — mirrors real signature
|
|
if name != "godot.json":
|
|
return None
|
|
# Simulates the API fetch landing godot.json inside out_dir.
|
|
fetched = Path(fetch_destination) / "godot.json"
|
|
fetched.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "godot",
|
|
"description": "fetched",
|
|
"sources": [
|
|
{"type": "documentation", "base_url": "https://docs.godotengine.org"}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
return fetched.resolve()
|
|
|
|
with patch("skill_seekers.cli.scan_command.resolve_config_path", side_effect=fake_resolve):
|
|
from skill_seekers.cli.scan_command import resolve_or_generate_with_status
|
|
|
|
path, _ = resolve_or_generate_with_status(
|
|
self._det("Godot Engine"),
|
|
out_dir=out_dir,
|
|
client=MagicMock(),
|
|
allow_network=True,
|
|
allow_generate=False,
|
|
)
|
|
|
|
assert path == out_dir / "godot-engine.json"
|
|
assert path.exists()
|
|
assert not (out_dir / "godot.json").exists(), (
|
|
"fetched intermediate should be removed to avoid phantom-removed churn"
|
|
)
|
|
|
|
|
|
class TestGenerateSchemaHintDepth:
|
|
"""Regression for SCAN-01: the AI schema hint's ``code_analysis_depth`` must
|
|
be a value the validator accepts. Otherwise every github config the AI
|
|
faithfully generates from the hint is rejected by UniSkillConfigValidator
|
|
and the scan never produces an AI-generated config."""
|
|
|
|
def test_schema_hint_depth_is_a_valid_level(self):
|
|
import re
|
|
|
|
from skill_seekers.cli.config_validator import UniSkillConfigValidator
|
|
from skill_seekers.cli.scan_command import _GENERATE_SCHEMA_HINT
|
|
|
|
match = re.search(r'"code_analysis_depth":\s*"([^"]+)"', _GENERATE_SCHEMA_HINT)
|
|
assert match, "schema hint no longer advertises code_analysis_depth"
|
|
assert match.group(1) in UniSkillConfigValidator.VALID_DEPTH_LEVELS
|