Sync ctx b1e7e1e (part 3)
Browse filesGitHub commit: b1e7e1ee28ac2147594e3d1f59009fbd0dd74647
- src/mcp_add.py +6 -4
- src/scan_repo.py +32 -12
- src/skill_add.py +3 -1
- src/tests/test_agent_add.py +19 -0
- src/tests/test_ctx_monitor.py +65 -0
- src/tests/test_harness_add.py +24 -0
- src/tests/test_harness_recommendations.py +15 -0
- src/tests/test_mcp_add.py +117 -0
- src/tests/test_scan_repo.py +27 -0
- src/tests/test_skill_add.py +21 -0
src/mcp_add.py
CHANGED
|
@@ -719,7 +719,7 @@ def main() -> None:
|
|
| 719 |
print(f"Error: {json_path} does not exist.", file=sys.stderr)
|
| 720 |
sys.exit(1)
|
| 721 |
try:
|
| 722 |
-
raw_records = [json.loads(json_path.read_text(encoding="utf-8"))]
|
| 723 |
except json.JSONDecodeError as exc:
|
| 724 |
print(f"Error: failed to parse JSON: {exc}", file=sys.stderr)
|
| 725 |
sys.exit(1)
|
|
@@ -730,9 +730,9 @@ def main() -> None:
|
|
| 730 |
print(f"Error: {jsonl_path} does not exist.", file=sys.stderr)
|
| 731 |
sys.exit(1)
|
| 732 |
for lineno, line in enumerate(
|
| 733 |
-
jsonl_path.read_text(encoding="utf-8").splitlines(), 1
|
| 734 |
):
|
| 735 |
-
line = line.strip()
|
| 736 |
if not line:
|
| 737 |
continue
|
| 738 |
try:
|
|
@@ -742,7 +742,7 @@ def main() -> None:
|
|
| 742 |
|
| 743 |
elif args.from_stdin:
|
| 744 |
for lineno, line in enumerate(sys.stdin, 1):
|
| 745 |
-
line = line.strip()
|
| 746 |
if not line:
|
| 747 |
continue
|
| 748 |
try:
|
|
@@ -767,6 +767,8 @@ def main() -> None:
|
|
| 767 |
f"\nDone{dry_label}: {added} added, {merged} updated, "
|
| 768 |
f"{reviewed} reviewed, {rejected} rejected, {errors} errors"
|
| 769 |
)
|
|
|
|
|
|
|
| 770 |
|
| 771 |
|
| 772 |
if __name__ == "__main__":
|
|
|
|
| 719 |
print(f"Error: {json_path} does not exist.", file=sys.stderr)
|
| 720 |
sys.exit(1)
|
| 721 |
try:
|
| 722 |
+
raw_records = [json.loads(json_path.read_text(encoding="utf-8-sig"))]
|
| 723 |
except json.JSONDecodeError as exc:
|
| 724 |
print(f"Error: failed to parse JSON: {exc}", file=sys.stderr)
|
| 725 |
sys.exit(1)
|
|
|
|
| 730 |
print(f"Error: {jsonl_path} does not exist.", file=sys.stderr)
|
| 731 |
sys.exit(1)
|
| 732 |
for lineno, line in enumerate(
|
| 733 |
+
jsonl_path.read_text(encoding="utf-8-sig").splitlines(), 1
|
| 734 |
):
|
| 735 |
+
line = line.lstrip("\ufeff").strip()
|
| 736 |
if not line:
|
| 737 |
continue
|
| 738 |
try:
|
|
|
|
| 742 |
|
| 743 |
elif args.from_stdin:
|
| 744 |
for lineno, line in enumerate(sys.stdin, 1):
|
| 745 |
+
line = line.lstrip("\ufeff").strip()
|
| 746 |
if not line:
|
| 747 |
continue
|
| 748 |
try:
|
|
|
|
| 767 |
f"\nDone{dry_label}: {added} added, {merged} updated, "
|
| 768 |
f"{reviewed} reviewed, {rejected} rejected, {errors} errors"
|
| 769 |
)
|
| 770 |
+
if rejected or errors:
|
| 771 |
+
sys.exit(1)
|
| 772 |
|
| 773 |
|
| 774 |
if __name__ == "__main__":
|
src/scan_repo.py
CHANGED
|
@@ -132,12 +132,15 @@ def read_json_safe(path: str) -> dict | None:
|
|
| 132 |
return None
|
| 133 |
|
| 134 |
|
| 135 |
-
def read_toml_deps(path: str) -> list[str]:
|
| 136 |
"""Extract dependency names from pyproject.toml.
|
| 137 |
|
| 138 |
Covers PEP 621 ``[project].dependencies`` / ``optional-dependencies`` and
|
| 139 |
Poetry-style ``[tool.poetry].dependencies`` / ``dev-dependencies``. Version
|
| 140 |
-
specifiers and extras are stripped via PEP 508 splitting.
|
|
|
|
|
|
|
|
|
|
| 141 |
"""
|
| 142 |
try:
|
| 143 |
data = tomllib.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
@@ -152,15 +155,17 @@ def read_toml_deps(path: str) -> list[str]:
|
|
| 152 |
deps = project.get("dependencies", [])
|
| 153 |
if isinstance(deps, list):
|
| 154 |
raw.extend(d for d in deps if isinstance(d, str))
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
| 160 |
|
| 161 |
poetry = data.get("tool", {}).get("poetry", {}) if isinstance(data.get("tool"), dict) else {}
|
| 162 |
if isinstance(poetry, dict):
|
| 163 |
-
|
|
|
|
| 164 |
deps = poetry.get(key, {})
|
| 165 |
if isinstance(deps, dict):
|
| 166 |
raw.extend(k for k in deps.keys() if isinstance(k, str) and k.lower() != "python")
|
|
@@ -222,6 +227,7 @@ def detect_stack(repo_path: str, signals: dict) -> dict:
|
|
| 222 |
|
| 223 |
# Collect all deps from Python and JS configs
|
| 224 |
all_py_deps: list[str] = []
|
|
|
|
| 225 |
all_js_deps: list[str] = []
|
| 226 |
pkg_json = None
|
| 227 |
|
|
@@ -229,10 +235,15 @@ def detect_stack(repo_path: str, signals: dict) -> dict:
|
|
| 229 |
base = os.path.basename(cfg)
|
| 230 |
if base == "pyproject.toml":
|
| 231 |
all_py_deps.extend(read_toml_deps(cfg))
|
|
|
|
| 232 |
elif base == "requirements.txt":
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
elif base == "Pipfile":
|
| 235 |
-
|
|
|
|
|
|
|
| 236 |
elif base == "package.json":
|
| 237 |
data = read_json_safe(cfg)
|
| 238 |
if data:
|
|
@@ -242,6 +253,7 @@ def detect_stack(repo_path: str, signals: dict) -> dict:
|
|
| 242 |
all_js_deps.extend(k.lower() for k in data[section])
|
| 243 |
|
| 244 |
py_dep_set = set(all_py_deps)
|
|
|
|
| 245 |
js_dep_set = set(all_js_deps)
|
| 246 |
|
| 247 |
# --- LANGUAGES ---
|
|
@@ -540,6 +552,14 @@ def detect_stack(repo_path: str, signals: dict) -> dict:
|
|
| 540 |
|
| 541 |
# --- PROJECT TYPE ---
|
| 542 |
fw_names = {f["name"] for f in profile["frameworks"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
if fw_names & {"react", "vue", "angular", "svelte", "nextjs", "nuxt"}:
|
| 544 |
if fw_names & {"fastapi", "django", "flask", "express", "nestjs"}:
|
| 545 |
profile["project_type"] = "fullstack"
|
|
@@ -547,9 +567,9 @@ def detect_stack(repo_path: str, signals: dict) -> dict:
|
|
| 547 |
profile["project_type"] = "frontend"
|
| 548 |
elif fw_names & {"fastapi", "django", "flask", "express", "nestjs", "gin", "actix"}:
|
| 549 |
profile["project_type"] = "api-service"
|
| 550 |
-
elif fw_names & {"pytorch", "tensorflow", "huggingface"}:
|
| 551 |
profile["project_type"] = "ml-project"
|
| 552 |
-
elif fw_names & {"langchain", "llamaindex", "crewai"}:
|
| 553 |
profile["project_type"] = "ai-agent"
|
| 554 |
elif profile["infrastructure"]:
|
| 555 |
profile["project_type"] = "infrastructure"
|
|
|
|
| 132 |
return None
|
| 133 |
|
| 134 |
|
| 135 |
+
def read_toml_deps(path: str, *, include_optional: bool = True) -> list[str]:
|
| 136 |
"""Extract dependency names from pyproject.toml.
|
| 137 |
|
| 138 |
Covers PEP 621 ``[project].dependencies`` / ``optional-dependencies`` and
|
| 139 |
Poetry-style ``[tool.poetry].dependencies`` / ``dev-dependencies``. Version
|
| 140 |
+
specifiers and extras are stripped via PEP 508 splitting. Callers can set
|
| 141 |
+
``include_optional=False`` when deciding the primary project type; optional
|
| 142 |
+
extras should remain searchable signals but should not make a docs/tooling
|
| 143 |
+
repo look like an ML project just because it offers an embeddings extra.
|
| 144 |
"""
|
| 145 |
try:
|
| 146 |
data = tomllib.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
|
|
| 155 |
deps = project.get("dependencies", [])
|
| 156 |
if isinstance(deps, list):
|
| 157 |
raw.extend(d for d in deps if isinstance(d, str))
|
| 158 |
+
if include_optional:
|
| 159 |
+
opt = project.get("optional-dependencies", {})
|
| 160 |
+
if isinstance(opt, dict):
|
| 161 |
+
for group in opt.values():
|
| 162 |
+
if isinstance(group, list):
|
| 163 |
+
raw.extend(d for d in group if isinstance(d, str))
|
| 164 |
|
| 165 |
poetry = data.get("tool", {}).get("poetry", {}) if isinstance(data.get("tool"), dict) else {}
|
| 166 |
if isinstance(poetry, dict):
|
| 167 |
+
keys = ("dependencies", "dev-dependencies") if include_optional else ("dependencies",)
|
| 168 |
+
for key in keys:
|
| 169 |
deps = poetry.get(key, {})
|
| 170 |
if isinstance(deps, dict):
|
| 171 |
raw.extend(k for k in deps.keys() if isinstance(k, str) and k.lower() != "python")
|
|
|
|
| 227 |
|
| 228 |
# Collect all deps from Python and JS configs
|
| 229 |
all_py_deps: list[str] = []
|
| 230 |
+
core_py_deps: list[str] = []
|
| 231 |
all_js_deps: list[str] = []
|
| 232 |
pkg_json = None
|
| 233 |
|
|
|
|
| 235 |
base = os.path.basename(cfg)
|
| 236 |
if base == "pyproject.toml":
|
| 237 |
all_py_deps.extend(read_toml_deps(cfg))
|
| 238 |
+
core_py_deps.extend(read_toml_deps(cfg, include_optional=False))
|
| 239 |
elif base == "requirements.txt":
|
| 240 |
+
deps = read_requirements(cfg)
|
| 241 |
+
all_py_deps.extend(deps)
|
| 242 |
+
core_py_deps.extend(deps)
|
| 243 |
elif base == "Pipfile":
|
| 244 |
+
deps = read_requirements(cfg)
|
| 245 |
+
all_py_deps.extend(deps)
|
| 246 |
+
core_py_deps.extend(deps)
|
| 247 |
elif base == "package.json":
|
| 248 |
data = read_json_safe(cfg)
|
| 249 |
if data:
|
|
|
|
| 253 |
all_js_deps.extend(k.lower() for k in data[section])
|
| 254 |
|
| 255 |
py_dep_set = set(all_py_deps)
|
| 256 |
+
py_core_dep_set = set(core_py_deps)
|
| 257 |
js_dep_set = set(all_js_deps)
|
| 258 |
|
| 259 |
# --- LANGUAGES ---
|
|
|
|
| 552 |
|
| 553 |
# --- PROJECT TYPE ---
|
| 554 |
fw_names = {f["name"] for f in profile["frameworks"]}
|
| 555 |
+
core_ml_deps = py_core_dep_set & {"torch", "pytorch", "tensorflow", "transformers"}
|
| 556 |
+
core_ai_deps = py_core_dep_set & {
|
| 557 |
+
"langchain",
|
| 558 |
+
"langchain-core",
|
| 559 |
+
"llama-index",
|
| 560 |
+
"crewai",
|
| 561 |
+
"dspy-ai",
|
| 562 |
+
}
|
| 563 |
if fw_names & {"react", "vue", "angular", "svelte", "nextjs", "nuxt"}:
|
| 564 |
if fw_names & {"fastapi", "django", "flask", "express", "nestjs"}:
|
| 565 |
profile["project_type"] = "fullstack"
|
|
|
|
| 567 |
profile["project_type"] = "frontend"
|
| 568 |
elif fw_names & {"fastapi", "django", "flask", "express", "nestjs", "gin", "actix"}:
|
| 569 |
profile["project_type"] = "api-service"
|
| 570 |
+
elif (fw_names & {"pytorch", "tensorflow", "huggingface"}) and core_ml_deps:
|
| 571 |
profile["project_type"] = "ml-project"
|
| 572 |
+
elif (fw_names & {"langchain", "llamaindex", "crewai"}) and core_ai_deps:
|
| 573 |
profile["project_type"] = "ai-agent"
|
| 574 |
elif profile["infrastructure"]:
|
| 575 |
profile["project_type"] = "infrastructure"
|
src/skill_add.py
CHANGED
|
@@ -394,7 +394,7 @@ def add_skill(
|
|
| 394 |
f"Split the skill or trim content before ingestion."
|
| 395 |
)
|
| 396 |
|
| 397 |
-
content = source_path.read_text(encoding="utf-8", errors="replace")
|
| 398 |
line_count = len(content.splitlines())
|
| 399 |
|
| 400 |
installed_path = skills_dir / name / "SKILL.md"
|
|
@@ -711,6 +711,8 @@ def main() -> None:
|
|
| 711 |
f"\nDone: {added} added, {updated} updated, {converted} converted, "
|
| 712 |
f"{skipped} skipped, {errors} errors"
|
| 713 |
)
|
|
|
|
|
|
|
| 714 |
|
| 715 |
|
| 716 |
if __name__ == "__main__":
|
|
|
|
| 394 |
f"Split the skill or trim content before ingestion."
|
| 395 |
)
|
| 396 |
|
| 397 |
+
content = source_path.read_text(encoding="utf-8-sig", errors="replace")
|
| 398 |
line_count = len(content.splitlines())
|
| 399 |
|
| 400 |
installed_path = skills_dir / name / "SKILL.md"
|
|
|
|
| 711 |
f"\nDone: {added} added, {updated} updated, {converted} converted, "
|
| 712 |
f"{skipped} skipped, {errors} errors"
|
| 713 |
)
|
| 714 |
+
if errors:
|
| 715 |
+
sys.exit(1)
|
| 716 |
|
| 717 |
|
| 718 |
if __name__ == "__main__":
|
src/tests/test_agent_add.py
CHANGED
|
@@ -206,6 +206,25 @@ def test_new_agent_add_writes_converted_agent_mirror(
|
|
| 206 |
assert mirror.read_text(encoding="utf-8") == source_text
|
| 207 |
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
def test_existing_agent_update_refreshes_converted_agent_mirror(
|
| 210 |
tmp_path: Path,
|
| 211 |
monkeypatch: Any,
|
|
|
|
| 206 |
assert mirror.read_text(encoding="utf-8") == source_text
|
| 207 |
|
| 208 |
|
| 209 |
+
def test_bom_prefixed_agent_frontmatter_is_accepted(
|
| 210 |
+
tmp_path: Path,
|
| 211 |
+
monkeypatch: Any,
|
| 212 |
+
) -> None:
|
| 213 |
+
wiki, agents_dir, source = _setup_paths(tmp_path)
|
| 214 |
+
source.write_text("\ufeff" + _agent_text(), encoding="utf-8")
|
| 215 |
+
_patch_side_effects(monkeypatch)
|
| 216 |
+
|
| 217 |
+
result = agent_add.add_agent(
|
| 218 |
+
source_path=source,
|
| 219 |
+
name="reviewer-agent",
|
| 220 |
+
wiki_path=wiki,
|
| 221 |
+
agents_dir=agents_dir,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
assert result["is_new_page"] is True
|
| 225 |
+
assert (wiki / "entities" / "agents" / "reviewer-agent.md").exists()
|
| 226 |
+
|
| 227 |
+
|
| 228 |
def test_existing_agent_update_refreshes_converted_agent_mirror(
|
| 229 |
tmp_path: Path,
|
| 230 |
monkeypatch: Any,
|
src/tests/test_ctx_monitor.py
CHANGED
|
@@ -3918,6 +3918,43 @@ def test_render_home_defers_sidecar_grade_scan(
|
|
| 3918 |
assert "data-home-grade='A'" in html_out
|
| 3919 |
|
| 3920 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3921 |
def test_render_skills_emits_sidebar_filters(fake_claude: Path) -> None:
|
| 3922 |
_write_sidecar(fake_claude, "a", {"slug": "a", "grade": "A", "raw_score": 0.9,
|
| 3923 |
"subject_type": "skill"})
|
|
@@ -4239,6 +4276,7 @@ def test_render_wiki_index_wrapper_matches_extracted_orchestration(
|
|
| 4239 |
write_html_disk_cache=mt.cache_service.write_html_disk_cache,
|
| 4240 |
wiki_render_disk_cache_path=mt.wiki_render_disk_cache_path,
|
| 4241 |
wiki_index_entries=mt.wiki_index_entries,
|
|
|
|
| 4242 |
wiki_stats=mt.wiki_stats,
|
| 4243 |
load_sidecar=mt.load_sidecar,
|
| 4244 |
dashboard_entity_types=mt.DASHBOARD_ENTITY_TYPES,
|
|
@@ -4354,6 +4392,33 @@ def test_wiki_index_entries_use_dashboard_index_without_markdown_pages(
|
|
| 4354 |
assert mt.render_wiki_index(query="python") == html_out
|
| 4355 |
|
| 4356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4357 |
def test_render_wiki_index_supports_type_query_and_autocomplete(fake_claude: Path) -> None:
|
| 4358 |
skills_dir = fake_claude / "skill-wiki" / "entities" / "skills"
|
| 4359 |
agents_dir = fake_claude / "skill-wiki" / "entities" / "agents"
|
|
|
|
| 3918 |
assert "data-home-grade='A'" in html_out
|
| 3919 |
|
| 3920 |
|
| 3921 |
+
def test_api_grades_reuses_kpi_summary_without_sidecar_scan() -> None:
|
| 3922 |
+
class Summary:
|
| 3923 |
+
def to_dict(self) -> dict[str, object]:
|
| 3924 |
+
return {"grade_counts": {"A": 2, "B": 1, "F": 4}}
|
| 3925 |
+
|
| 3926 |
+
def fail_scan() -> dict[str, object]:
|
| 3927 |
+
raise AssertionError("grades API should reuse the cached KPI summary")
|
| 3928 |
+
|
| 3929 |
+
response = readonly_api.handle_readonly_route(
|
| 3930 |
+
"api_grades",
|
| 3931 |
+
{},
|
| 3932 |
+
{},
|
| 3933 |
+
readonly_api.ReadOnlyApiDeps(
|
| 3934 |
+
summarize_sessions=lambda: {},
|
| 3935 |
+
read_manifest=lambda: {},
|
| 3936 |
+
status_payload=lambda: {},
|
| 3937 |
+
kpi_summary=lambda: Summary(),
|
| 3938 |
+
grade_distribution_payload=fail_scan,
|
| 3939 |
+
sidecar_page_payload=lambda _qs: {},
|
| 3940 |
+
runtime_lifecycle_summary=lambda: {},
|
| 3941 |
+
skillspector_audit_payload=lambda _qs: {},
|
| 3942 |
+
effective_config_payload=lambda: {},
|
| 3943 |
+
search_wiki_entities=lambda _q, _type, _limit: [],
|
| 3944 |
+
wiki_entity_detail=lambda _slug, _type: None,
|
| 3945 |
+
load_sidecar=lambda _slug, _type: None,
|
| 3946 |
+
graph_neighborhood=lambda _slug, _hops, _limit, _type: {},
|
| 3947 |
+
normalize_dashboard_entity_type=lambda raw: raw,
|
| 3948 |
+
),
|
| 3949 |
+
)
|
| 3950 |
+
|
| 3951 |
+
assert response is not None
|
| 3952 |
+
assert response.payload == {
|
| 3953 |
+
"grades": {"A": 2, "B": 1, "C": 0, "D": 0, "F": 4},
|
| 3954 |
+
"total": 7,
|
| 3955 |
+
}
|
| 3956 |
+
|
| 3957 |
+
|
| 3958 |
def test_render_skills_emits_sidebar_filters(fake_claude: Path) -> None:
|
| 3959 |
_write_sidecar(fake_claude, "a", {"slug": "a", "grade": "A", "raw_score": 0.9,
|
| 3960 |
"subject_type": "skill"})
|
|
|
|
| 4276 |
write_html_disk_cache=mt.cache_service.write_html_disk_cache,
|
| 4277 |
wiki_render_disk_cache_path=mt.wiki_render_disk_cache_path,
|
| 4278 |
wiki_index_entries=mt.wiki_index_entries,
|
| 4279 |
+
search_wiki_entities=mt.search_wiki_entities,
|
| 4280 |
wiki_stats=mt.wiki_stats,
|
| 4281 |
load_sidecar=mt.load_sidecar,
|
| 4282 |
dashboard_entity_types=mt.DASHBOARD_ENTITY_TYPES,
|
|
|
|
| 4392 |
assert mt.render_wiki_index(query="python") == html_out
|
| 4393 |
|
| 4394 |
|
| 4395 |
+
def test_render_wiki_index_query_uses_bounded_search(
|
| 4396 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 4397 |
+
) -> None:
|
| 4398 |
+
def fail_catalog_rebuild(*args: object, **kwargs: object) -> object:
|
| 4399 |
+
raise AssertionError("queried catalog should use bounded search results")
|
| 4400 |
+
|
| 4401 |
+
monkeypatch.setattr(mt, "wiki_index_entries", fail_catalog_rebuild)
|
| 4402 |
+
monkeypatch.setattr(
|
| 4403 |
+
mt,
|
| 4404 |
+
"search_wiki_entities",
|
| 4405 |
+
lambda query, entity_type=None, limit=80: [{
|
| 4406 |
+
"slug": "code-reviewer",
|
| 4407 |
+
"display_slug": "code-reviewer",
|
| 4408 |
+
"type": entity_type or "agent",
|
| 4409 |
+
"description": f"{query} result",
|
| 4410 |
+
"tags": ["review"],
|
| 4411 |
+
"search_tags": ["review"],
|
| 4412 |
+
}],
|
| 4413 |
+
)
|
| 4414 |
+
|
| 4415 |
+
html_out = mt.render_wiki_index(entity_type="agent", query="review")
|
| 4416 |
+
|
| 4417 |
+
assert "code-reviewer" in html_out
|
| 4418 |
+
assert "review result" in html_out
|
| 4419 |
+
assert "href='/wiki/code-reviewer?type=agent'" in html_out
|
| 4420 |
+
|
| 4421 |
+
|
| 4422 |
def test_render_wiki_index_supports_type_query_and_autocomplete(fake_claude: Path) -> None:
|
| 4423 |
skills_dir = fake_claude / "skill-wiki" / "entities" / "skills"
|
| 4424 |
agents_dir = fake_claude / "skill-wiki" / "entities" / "agents"
|
src/tests/test_harness_add.py
CHANGED
|
@@ -230,3 +230,27 @@ def test_cli_from_json_adds_harness(
|
|
| 230 |
|
| 231 |
assert (wiki / "entities" / "harnesses" / "text-to-cad.md").exists()
|
| 232 |
assert "added: text-to-cad" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
assert (wiki / "entities" / "harnesses" / "text-to-cad.md").exists()
|
| 232 |
assert "added: text-to-cad" in capsys.readouterr().out
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_cli_from_bom_json_adds_harness(
|
| 236 |
+
tmp_path: Path,
|
| 237 |
+
capsys: Any,
|
| 238 |
+
) -> None:
|
| 239 |
+
record_path = tmp_path / "harness.json"
|
| 240 |
+
record_path.write_text(
|
| 241 |
+
"\ufeff"
|
| 242 |
+
+ json.dumps(
|
| 243 |
+
{
|
| 244 |
+
"repo_url": "https://github.com/example/bom-harness",
|
| 245 |
+
"description": "Harness loaded from a Windows UTF-8 BOM JSON file.",
|
| 246 |
+
"tags": ["llm", "testing"],
|
| 247 |
+
}
|
| 248 |
+
),
|
| 249 |
+
encoding="utf-8",
|
| 250 |
+
)
|
| 251 |
+
wiki = tmp_path / "wiki"
|
| 252 |
+
|
| 253 |
+
harness_add.main(["--from-json", str(record_path), "--wiki", str(wiki)])
|
| 254 |
+
|
| 255 |
+
assert (wiki / "entities" / "harnesses" / "bom-harness.md").exists()
|
| 256 |
+
assert "added: bom-harness" in capsys.readouterr().out
|
src/tests/test_harness_recommendations.py
CHANGED
|
@@ -273,6 +273,21 @@ def test_ctx_init_keeps_domain_goal_strong_with_install_requirements(
|
|
| 273 |
assert "gpt-5" not in results[0]["fit_signals"]
|
| 274 |
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
def test_ctx_init_recommends_harness_from_wiki_frontmatter_only(
|
| 277 |
monkeypatch: pytest.MonkeyPatch,
|
| 278 |
) -> None:
|
|
|
|
| 273 |
assert "gpt-5" not in results[0]["fit_signals"]
|
| 274 |
|
| 275 |
|
| 276 |
+
def test_ctx_init_rejects_narrow_domain_harness_for_generic_coding_agent(
|
| 277 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 278 |
+
) -> None:
|
| 279 |
+
monkeypatch.setattr(ctx_init, "_load_recommendation_graph", _harness_graph)
|
| 280 |
+
|
| 281 |
+
results = ctx_init.recommend_harnesses(
|
| 282 |
+
"build an openai python coding agent with filesystem and test verification",
|
| 283 |
+
top_k=5,
|
| 284 |
+
model_provider="openai",
|
| 285 |
+
model="openai/gpt-4.1",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
assert "text-to-cad" not in [row["name"] for row in results]
|
| 289 |
+
|
| 290 |
+
|
| 291 |
def test_ctx_init_recommends_harness_from_wiki_frontmatter_only(
|
| 292 |
monkeypatch: pytest.MonkeyPatch,
|
| 293 |
) -> None:
|
src/tests/test_mcp_add.py
CHANGED
|
@@ -832,3 +832,120 @@ class TestAddMcpFromFixtures:
|
|
| 832 |
record = McpRecord.from_dict(data)
|
| 833 |
result = patched_mcp_add.add_mcp(record=record, wiki_path=wiki_dir)
|
| 834 |
assert result["is_new_page"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
record = McpRecord.from_dict(data)
|
| 833 |
result = patched_mcp_add.add_mcp(record=record, wiki_path=wiki_dir)
|
| 834 |
assert result["is_new_page"] is True
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
class TestAddMcpCliInput:
|
| 838 |
+
def test_from_json_accepts_utf8_bom(
|
| 839 |
+
self,
|
| 840 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 841 |
+
tmp_path: Path,
|
| 842 |
+
) -> None:
|
| 843 |
+
import mcp_add # noqa: PLC0415
|
| 844 |
+
|
| 845 |
+
record_path = tmp_path / "mcp.json"
|
| 846 |
+
record_path.write_text(
|
| 847 |
+
"\ufeff"
|
| 848 |
+
+ json.dumps(
|
| 849 |
+
{
|
| 850 |
+
"name": "bom-json-mcp",
|
| 851 |
+
"description": "MCP loaded from a Windows UTF-8 BOM JSON file.",
|
| 852 |
+
"sources": ["test"],
|
| 853 |
+
"github_url": "https://github.com/example/bom-json-mcp",
|
| 854 |
+
"tags": ["testing"],
|
| 855 |
+
}
|
| 856 |
+
),
|
| 857 |
+
encoding="utf-8",
|
| 858 |
+
)
|
| 859 |
+
wiki = tmp_path / "wiki"
|
| 860 |
+
monkeypatch.setattr(sys, "argv", [
|
| 861 |
+
"mcp_add.py",
|
| 862 |
+
"--from-json", str(record_path),
|
| 863 |
+
"--wiki", str(wiki),
|
| 864 |
+
])
|
| 865 |
+
monkeypatch.setattr("mcp_add.check_intake", _fake_allow)
|
| 866 |
+
monkeypatch.setattr("mcp_add.record_embedding", _fake_record_embedding)
|
| 867 |
+
monkeypatch.setattr("mcp_add.update_index", lambda *a, **k: None)
|
| 868 |
+
monkeypatch.setattr("mcp_add.append_log", lambda *a, **k: None)
|
| 869 |
+
|
| 870 |
+
mcp_add.main()
|
| 871 |
+
|
| 872 |
+
assert (
|
| 873 |
+
wiki / "entities" / "mcp-servers" / "b" / "bom-json-mcp.md"
|
| 874 |
+
).exists()
|
| 875 |
+
|
| 876 |
+
def test_from_stdin_accepts_utf8_bom(
|
| 877 |
+
self,
|
| 878 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 879 |
+
tmp_path: Path,
|
| 880 |
+
) -> None:
|
| 881 |
+
import io
|
| 882 |
+
import mcp_add # noqa: PLC0415
|
| 883 |
+
|
| 884 |
+
wiki = tmp_path / "wiki"
|
| 885 |
+
monkeypatch.setattr(sys, "argv", [
|
| 886 |
+
"mcp_add.py",
|
| 887 |
+
"--from-stdin",
|
| 888 |
+
"--wiki", str(wiki),
|
| 889 |
+
])
|
| 890 |
+
monkeypatch.setattr(
|
| 891 |
+
sys,
|
| 892 |
+
"stdin",
|
| 893 |
+
io.StringIO(
|
| 894 |
+
"\ufeff"
|
| 895 |
+
+ json.dumps(
|
| 896 |
+
{
|
| 897 |
+
"name": "bom-stdin-mcp",
|
| 898 |
+
"description": (
|
| 899 |
+
"MCP loaded from a Windows UTF-8 BOM stdin stream."
|
| 900 |
+
),
|
| 901 |
+
"sources": ["test"],
|
| 902 |
+
"github_url": "https://github.com/example/bom-stdin-mcp",
|
| 903 |
+
"tags": ["testing"],
|
| 904 |
+
}
|
| 905 |
+
)
|
| 906 |
+
+ "\n"
|
| 907 |
+
),
|
| 908 |
+
)
|
| 909 |
+
monkeypatch.setattr("mcp_add.check_intake", _fake_allow)
|
| 910 |
+
monkeypatch.setattr("mcp_add.record_embedding", _fake_record_embedding)
|
| 911 |
+
monkeypatch.setattr("mcp_add.update_index", lambda *a, **k: None)
|
| 912 |
+
monkeypatch.setattr("mcp_add.append_log", lambda *a, **k: None)
|
| 913 |
+
|
| 914 |
+
mcp_add.main()
|
| 915 |
+
|
| 916 |
+
assert (
|
| 917 |
+
wiki / "entities" / "mcp-servers" / "b" / "bom-stdin-mcp.md"
|
| 918 |
+
).exists()
|
| 919 |
+
|
| 920 |
+
def test_rejected_batch_exits_nonzero(
|
| 921 |
+
self,
|
| 922 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 923 |
+
tmp_path: Path,
|
| 924 |
+
) -> None:
|
| 925 |
+
import mcp_add # noqa: PLC0415
|
| 926 |
+
|
| 927 |
+
record_path = tmp_path / "mcp.json"
|
| 928 |
+
record_path.write_text(
|
| 929 |
+
json.dumps(
|
| 930 |
+
{
|
| 931 |
+
"name": "rejected-cli-mcp",
|
| 932 |
+
"description": "Rejected MCP candidate for exit-code regression.",
|
| 933 |
+
"sources": ["test"],
|
| 934 |
+
"github_url": "https://github.com/example/rejected-cli-mcp",
|
| 935 |
+
"tags": ["testing"],
|
| 936 |
+
}
|
| 937 |
+
),
|
| 938 |
+
encoding="utf-8",
|
| 939 |
+
)
|
| 940 |
+
monkeypatch.setattr(sys, "argv", [
|
| 941 |
+
"mcp_add.py",
|
| 942 |
+
"--from-json", str(record_path),
|
| 943 |
+
"--wiki", str(tmp_path / "wiki"),
|
| 944 |
+
])
|
| 945 |
+
monkeypatch.setattr("mcp_add.check_intake", _fake_reject)
|
| 946 |
+
monkeypatch.setattr("mcp_add.record_embedding", _fake_record_embedding)
|
| 947 |
+
|
| 948 |
+
with pytest.raises(SystemExit) as exc:
|
| 949 |
+
mcp_add.main()
|
| 950 |
+
|
| 951 |
+
assert exc.value.code == 1
|
src/tests/test_scan_repo.py
CHANGED
|
@@ -303,6 +303,19 @@ dev = ["black"]
|
|
| 303 |
assert "pytest-cov" in deps
|
| 304 |
assert "black" in deps
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
def test_extracts_poetry_deps(self, tmp_path: Path) -> None:
|
| 307 |
p = _write(tmp_path / "pyproject.toml", """
|
| 308 |
[tool.poetry]
|
|
@@ -671,6 +684,20 @@ class TestDetectStackProjectType:
|
|
| 671 |
profile = sr.detect_stack(str(tmp_path), signals)
|
| 672 |
assert profile["project_type"] == "ml-project"
|
| 673 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
def test_ai_agent_when_langchain_present(self, tmp_path: Path) -> None:
|
| 675 |
py = _write(tmp_path / "requirements.txt", "langchain\n")
|
| 676 |
signals = _make_signals(config_files=[str(py)])
|
|
|
|
| 303 |
assert "pytest-cov" in deps
|
| 304 |
assert "black" in deps
|
| 305 |
|
| 306 |
+
def test_can_ignore_optional_deps_for_primary_stack(self, tmp_path: Path) -> None:
|
| 307 |
+
p = _write(tmp_path / "pyproject.toml", """
|
| 308 |
+
[project]
|
| 309 |
+
dependencies = ["core-dep"]
|
| 310 |
+
|
| 311 |
+
[project.optional-dependencies]
|
| 312 |
+
embeddings = ["torch>=2"]
|
| 313 |
+
""")
|
| 314 |
+
|
| 315 |
+
deps = sr.read_toml_deps(str(p), include_optional=False)
|
| 316 |
+
|
| 317 |
+
assert deps == ["core-dep"]
|
| 318 |
+
|
| 319 |
def test_extracts_poetry_deps(self, tmp_path: Path) -> None:
|
| 320 |
p = _write(tmp_path / "pyproject.toml", """
|
| 321 |
[tool.poetry]
|
|
|
|
| 684 |
profile = sr.detect_stack(str(tmp_path), signals)
|
| 685 |
assert profile["project_type"] == "ml-project"
|
| 686 |
|
| 687 |
+
def test_optional_torch_does_not_make_project_ml(self, tmp_path: Path) -> None:
|
| 688 |
+
py = _write(tmp_path / "pyproject.toml", """
|
| 689 |
+
[project]
|
| 690 |
+
dependencies = ["networkx"]
|
| 691 |
+
|
| 692 |
+
[project.optional-dependencies]
|
| 693 |
+
embeddings = ["torch>=2"]
|
| 694 |
+
""")
|
| 695 |
+
signals = _make_signals(config_files=[str(py)])
|
| 696 |
+
profile = sr.detect_stack(str(tmp_path), signals)
|
| 697 |
+
|
| 698 |
+
assert any(f["name"] == "pytorch" for f in profile["frameworks"])
|
| 699 |
+
assert profile["project_type"] == "unknown"
|
| 700 |
+
|
| 701 |
def test_ai_agent_when_langchain_present(self, tmp_path: Path) -> None:
|
| 702 |
py = _write(tmp_path / "requirements.txt", "langchain\n")
|
| 703 |
signals = _make_signals(config_files=[str(py)])
|
src/tests/test_skill_add.py
CHANGED
|
@@ -633,6 +633,27 @@ class TestAddSkill:
|
|
| 633 |
assert result["converted"] is False
|
| 634 |
assert result["is_new_page"] is True
|
| 635 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
def test_required_security_scan_blocks_before_install(
|
| 637 |
self, tmp_path, monkeypatch
|
| 638 |
):
|
|
|
|
| 633 |
assert result["converted"] is False
|
| 634 |
assert result["is_new_page"] is True
|
| 635 |
|
| 636 |
+
def test_bom_prefixed_skill_frontmatter_is_accepted(self, tmp_path, monkeypatch):
|
| 637 |
+
wiki = self._setup_wiki(tmp_path)
|
| 638 |
+
skills_dir = tmp_path / "skills"
|
| 639 |
+
self._setup_intake_allow()
|
| 640 |
+
_fake_batch_convert.convert_skill.return_value = {"status": "error"}
|
| 641 |
+
monkeypatch.setattr(_sa, "update_index", MagicMock())
|
| 642 |
+
monkeypatch.setattr(_sa, "append_log", MagicMock())
|
| 643 |
+
|
| 644 |
+
source = tmp_path / "SKILL.md"
|
| 645 |
+
source.write_text("\ufeff" + self._skill_text(), encoding="utf-8")
|
| 646 |
+
|
| 647 |
+
result = add_skill(
|
| 648 |
+
source_path=source,
|
| 649 |
+
name="myskill",
|
| 650 |
+
wiki_path=wiki,
|
| 651 |
+
skills_dir=skills_dir,
|
| 652 |
+
)
|
| 653 |
+
|
| 654 |
+
assert result["name"] == "myskill"
|
| 655 |
+
assert result["is_new_page"] is True
|
| 656 |
+
|
| 657 |
def test_required_security_scan_blocks_before_install(
|
| 658 |
self, tmp_path, monkeypatch
|
| 659 |
):
|