generated from coulomb/repo-seed
usecase e2e tests
This commit is contained in:
16
README.md
16
README.md
@@ -169,6 +169,9 @@ DELETE /repos/{id}/evidence/{evidence_id}
|
||||
GET /abilities
|
||||
GET /capabilities
|
||||
GET /search?q=...
|
||||
GET /repository-comparisons?repository_ids=1&repository_ids=2
|
||||
POST /capability-gaps
|
||||
GET /repos/{id}/export
|
||||
```
|
||||
|
||||
## Agent API Loop
|
||||
@@ -205,3 +208,16 @@ Search results include `match_type`, `matched_field`, `confidence`,
|
||||
source/evidence context when the match comes from implementation evidence.
|
||||
The generated OpenAPI schema at `/openapi.json` and docs at `/docs` include
|
||||
typed response schemas and examples for the main agent-facing responses.
|
||||
|
||||
Discovery helpers are available for production-readiness workflows that compare
|
||||
approved profiles, find simple capability gaps, or export a registry entry:
|
||||
|
||||
```bash
|
||||
curl 'http://127.0.0.1:8000/repository-comparisons?repository_ids=1&repository_ids=2'
|
||||
|
||||
curl -X POST http://127.0.0.1:8000/capability-gaps \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"desired_ability":"Business Email Routing","desired_capabilities":["Classify Incoming Email","Route Email to Team"],"repository_ids":[1,2]}'
|
||||
|
||||
curl http://127.0.0.1:8000/repos/1/export
|
||||
```
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict
|
||||
|
||||
from repo_registry.core.models import (
|
||||
AbilitySummary,
|
||||
@@ -778,6 +779,210 @@ class RegistryService:
|
||||
def ability_map(self, repository_id: int) -> RepositoryAbilityMap:
|
||||
return self.store.get_ability_map(repository_id)
|
||||
|
||||
def compare_repositories(self, repository_ids: Sequence[int]) -> dict[str, object]:
|
||||
maps = [self.store.get_ability_map(repository_id) for repository_id in repository_ids]
|
||||
ability_groups: dict[str, list[dict[str, object]]] = {}
|
||||
capability_groups: dict[str, list[dict[str, object]]] = {}
|
||||
for ability_map in maps:
|
||||
repository = ability_map.repository
|
||||
for ability in ability_map.abilities:
|
||||
ability_groups.setdefault(ability.name.lower(), []).append(
|
||||
{
|
||||
"repository_id": repository.id,
|
||||
"repository_name": repository.name,
|
||||
"confidence": ability.confidence,
|
||||
"confidence_label": ability.confidence_label,
|
||||
"capabilities": [
|
||||
{
|
||||
"name": capability.name,
|
||||
"confidence": capability.confidence,
|
||||
"confidence_label": capability.confidence_label,
|
||||
"evidence_count": len(capability.evidence),
|
||||
}
|
||||
for capability in ability.capabilities
|
||||
],
|
||||
"_name": ability.name,
|
||||
}
|
||||
)
|
||||
for capability in ability.capabilities:
|
||||
capability_groups.setdefault(capability.name.lower(), []).append(
|
||||
{
|
||||
"repository_id": repository.id,
|
||||
"repository_name": repository.name,
|
||||
"ability_name": ability.name,
|
||||
"capability_name": capability.name,
|
||||
}
|
||||
)
|
||||
|
||||
abilities = [
|
||||
{
|
||||
"name": repositories[0]["_name"],
|
||||
"repositories": [
|
||||
{
|
||||
key: value
|
||||
for key, value in repository.items()
|
||||
if key != "_name"
|
||||
}
|
||||
for repository in repositories
|
||||
],
|
||||
}
|
||||
for repositories in ability_groups.values()
|
||||
]
|
||||
unique_capabilities = [
|
||||
entries[0]
|
||||
for entries in capability_groups.values()
|
||||
if len({entry["repository_id"] for entry in entries}) == 1
|
||||
]
|
||||
return {
|
||||
"repositories": [asdict(ability_map.repository) for ability_map in maps],
|
||||
"abilities": sorted(abilities, key=lambda item: item["name"]),
|
||||
"unique_capabilities": sorted(
|
||||
unique_capabilities,
|
||||
key=lambda item: (item["repository_name"], item["capability_name"]),
|
||||
),
|
||||
}
|
||||
|
||||
def detect_capability_gaps(
|
||||
self,
|
||||
*,
|
||||
desired_ability: str,
|
||||
desired_capabilities: Sequence[str],
|
||||
repository_ids: Sequence[int] | None = None,
|
||||
) -> dict[str, object]:
|
||||
repositories = (
|
||||
[self.store.get_repository(repository_id) for repository_id in repository_ids]
|
||||
if repository_ids is not None
|
||||
else self.store.list_repositories()
|
||||
)
|
||||
maps = [self.store.get_ability_map(repository.id) for repository in repositories]
|
||||
desired = [capability.strip() for capability in desired_capabilities if capability.strip()]
|
||||
capability_matches: dict[str, list[dict[str, object]]] = {name.lower(): [] for name in desired}
|
||||
duplicate_index: dict[str, set[str]] = {}
|
||||
weak: list[dict[str, object]] = []
|
||||
|
||||
for ability_map in maps:
|
||||
repository = ability_map.repository
|
||||
for ability in ability_map.abilities:
|
||||
for capability in ability.capabilities:
|
||||
key = capability.name.lower()
|
||||
duplicate_index.setdefault(key, set()).add(repository.name)
|
||||
if key in capability_matches:
|
||||
capability_matches[key].append(
|
||||
{
|
||||
"repository_id": repository.id,
|
||||
"repository_name": repository.name,
|
||||
"capability": capability,
|
||||
}
|
||||
)
|
||||
strengths = {evidence.strength for evidence in capability.evidence}
|
||||
if "strong" not in strengths:
|
||||
weak.append(
|
||||
{
|
||||
"capability": capability.name,
|
||||
"repository_id": repository.id,
|
||||
"repository_name": repository.name,
|
||||
"evidence_count": len(capability.evidence),
|
||||
"strongest_evidence": self._strongest_evidence(strengths),
|
||||
"confidence": capability.confidence,
|
||||
"confidence_label": capability.confidence_label,
|
||||
}
|
||||
)
|
||||
|
||||
matched = [
|
||||
{
|
||||
"capability": name,
|
||||
"repositories": [
|
||||
match["repository_name"]
|
||||
for match in capability_matches[name.lower()]
|
||||
],
|
||||
}
|
||||
for name in desired
|
||||
if capability_matches[name.lower()]
|
||||
]
|
||||
missing = [name for name in desired if not capability_matches[name.lower()]]
|
||||
duplicates = [
|
||||
{
|
||||
"capability": capability,
|
||||
"repositories": sorted(repositories),
|
||||
}
|
||||
for capability, repositories in duplicate_index.items()
|
||||
if len(repositories) > 1 and capability in capability_matches
|
||||
]
|
||||
return {
|
||||
"desired_ability": desired_ability,
|
||||
"matched_capabilities": matched,
|
||||
"missing_capabilities": missing,
|
||||
"weakly_evidenced_capabilities": weak,
|
||||
"duplicate_capabilities": duplicates,
|
||||
}
|
||||
|
||||
def export_registry_entry(self, repository_id: int) -> str:
|
||||
ability_map = self.store.get_ability_map(repository_id)
|
||||
lines = [
|
||||
"repository:",
|
||||
f" name: {self._yaml_scalar(ability_map.repository.name)}",
|
||||
f" url: {self._yaml_scalar(ability_map.repository.url)}",
|
||||
f" branch: {self._yaml_scalar(ability_map.repository.branch)}",
|
||||
f" status: {self._yaml_scalar(ability_map.repository.status)}",
|
||||
"abilities:",
|
||||
]
|
||||
for ability in ability_map.abilities:
|
||||
lines.extend(
|
||||
[
|
||||
f" - name: {self._yaml_scalar(ability.name)}",
|
||||
f" description: {self._yaml_scalar(ability.description)}",
|
||||
f" confidence: {ability.confidence}",
|
||||
f" confidence_label: {self._yaml_scalar(ability.confidence_label)}",
|
||||
" capabilities:",
|
||||
]
|
||||
)
|
||||
for capability in ability.capabilities:
|
||||
lines.extend(
|
||||
[
|
||||
f" - name: {self._yaml_scalar(capability.name)}",
|
||||
f" description: {self._yaml_scalar(capability.description)}",
|
||||
f" confidence: {capability.confidence}",
|
||||
f" confidence_label: {self._yaml_scalar(capability.confidence_label)}",
|
||||
f" inputs: {self._yaml_list(capability.inputs)}",
|
||||
f" outputs: {self._yaml_list(capability.outputs)}",
|
||||
" features:",
|
||||
]
|
||||
)
|
||||
for feature in capability.features:
|
||||
lines.extend(
|
||||
[
|
||||
f" - name: {self._yaml_scalar(feature.name)}",
|
||||
f" type: {self._yaml_scalar(feature.type)}",
|
||||
f" location: {self._yaml_scalar(feature.location)}",
|
||||
f" confidence: {feature.confidence}",
|
||||
f" confidence_label: {self._yaml_scalar(feature.confidence_label)}",
|
||||
]
|
||||
)
|
||||
lines.append(" evidence:")
|
||||
for evidence in capability.evidence:
|
||||
lines.extend(
|
||||
[
|
||||
f" - type: {self._yaml_scalar(evidence.type)}",
|
||||
f" reference: {self._yaml_scalar(evidence.reference)}",
|
||||
f" strength: {self._yaml_scalar(evidence.strength)}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def _strongest_evidence(self, strengths: set[str]) -> str | None:
|
||||
for strength in ("strong", "medium", "weak"):
|
||||
if strength in strengths:
|
||||
return strength
|
||||
return None
|
||||
|
||||
def _yaml_list(self, values: Sequence[str]) -> str:
|
||||
return "[" + ", ".join(self._yaml_scalar(value) for value in values) + "]"
|
||||
|
||||
def _yaml_scalar(self, value: object) -> str:
|
||||
text = "" if value is None else str(value)
|
||||
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
@@ -27,6 +28,8 @@ from repo_registry.web_api.schemas import (
|
||||
CandidateGraphResponse,
|
||||
CandidateLeafRelink,
|
||||
CandidateRejection,
|
||||
CapabilityGapRequest,
|
||||
CapabilityGapResponse,
|
||||
CapabilityCreate,
|
||||
CapabilitySummaryResponse,
|
||||
CapabilityUpdate,
|
||||
@@ -38,6 +41,7 @@ from repo_registry.web_api.schemas import (
|
||||
IdResponse,
|
||||
ObservedFactResponse,
|
||||
RepositoryAbilityMapResponse,
|
||||
RepositoryComparisonResponse,
|
||||
RepositoryCreate,
|
||||
RepositoryResponse,
|
||||
RepositoryUpdate,
|
||||
@@ -91,6 +95,7 @@ OPENAPI_TAGS = [
|
||||
{"name": "review", "description": "Candidate graph approval and correction workflow."},
|
||||
{"name": "registry", "description": "Approved ability maps and manual registry CRUD."},
|
||||
{"name": "search", "description": "Agent-facing discovery endpoints."},
|
||||
{"name": "discovery", "description": "Comparison, gap analysis, and export helpers."},
|
||||
]
|
||||
|
||||
app = FastAPI(
|
||||
@@ -951,6 +956,62 @@ def get_ability_map(
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repos/{repository_id}/export",
|
||||
tags=["discovery"],
|
||||
response_class=PlainTextResponse,
|
||||
responses={
|
||||
200: {
|
||||
"content": {"application/x-yaml": {}},
|
||||
"description": "YAML registry export suitable for repo-abilities.yaml.",
|
||||
}
|
||||
},
|
||||
)
|
||||
def export_repository_registry_entry(
|
||||
repository_id: int,
|
||||
service: RegistryService = Depends(get_service),
|
||||
) -> PlainTextResponse:
|
||||
try:
|
||||
content = service.export_registry_entry(repository_id)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return PlainTextResponse(content, media_type="application/x-yaml")
|
||||
|
||||
|
||||
@app.get(
|
||||
"/repository-comparisons",
|
||||
tags=["discovery"],
|
||||
response_model=RepositoryComparisonResponse,
|
||||
)
|
||||
def compare_repositories(
|
||||
repository_ids: list[int] = Query(
|
||||
...,
|
||||
description="Repository ids to compare by approved abilities and capabilities.",
|
||||
min_length=2,
|
||||
),
|
||||
service: RegistryService = Depends(get_service),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return service.compare_repositories(repository_ids)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post(
|
||||
"/capability-gaps",
|
||||
tags=["discovery"],
|
||||
response_model=CapabilityGapResponse,
|
||||
)
|
||||
def detect_capability_gaps(
|
||||
payload: CapabilityGapRequest,
|
||||
service: RegistryService = Depends(get_service),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return service.detect_capability_gaps(**payload.model_dump())
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/search", tags=["search"], response_model=list[SearchResultResponse])
|
||||
def search(
|
||||
q: str = Query(
|
||||
|
||||
@@ -664,3 +664,85 @@ class CapabilitySummaryResponse(BaseModel):
|
||||
description: str
|
||||
confidence: float
|
||||
confidence_label: str
|
||||
|
||||
|
||||
class CapabilityGapRequest(BaseModel):
|
||||
desired_ability: str
|
||||
desired_capabilities: list[str]
|
||||
repository_ids: list[int] | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"desired_ability": "Business Email Routing",
|
||||
"desired_capabilities": [
|
||||
"Classify Incoming Email",
|
||||
"Route Email to Team",
|
||||
],
|
||||
"repository_ids": [1, 2],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ComparedCapabilityResponse(BaseModel):
|
||||
name: str
|
||||
confidence: float
|
||||
confidence_label: str
|
||||
evidence_count: int
|
||||
|
||||
|
||||
class ComparedAbilityRepositoryResponse(BaseModel):
|
||||
repository_id: int
|
||||
repository_name: str
|
||||
confidence: float
|
||||
confidence_label: str
|
||||
capabilities: list[ComparedCapabilityResponse]
|
||||
|
||||
|
||||
class ComparedAbilityResponse(BaseModel):
|
||||
name: str
|
||||
repositories: list[ComparedAbilityRepositoryResponse]
|
||||
|
||||
|
||||
class UniqueCapabilityResponse(BaseModel):
|
||||
repository_id: int
|
||||
repository_name: str
|
||||
ability_name: str
|
||||
capability_name: str
|
||||
|
||||
|
||||
class RepositoryComparisonResponse(BaseModel):
|
||||
repositories: list[RepositoryResponse]
|
||||
abilities: list[ComparedAbilityResponse]
|
||||
unique_capabilities: list[UniqueCapabilityResponse]
|
||||
|
||||
|
||||
class CapabilityGapMatchResponse(BaseModel):
|
||||
capability: str
|
||||
repositories: list[str]
|
||||
|
||||
|
||||
class WeakCapabilityEvidenceResponse(BaseModel):
|
||||
capability: str
|
||||
repository_id: int
|
||||
repository_name: str
|
||||
evidence_count: int
|
||||
strongest_evidence: str | None = None
|
||||
confidence: float
|
||||
confidence_label: str
|
||||
|
||||
|
||||
class DuplicateCapabilityResponse(BaseModel):
|
||||
capability: str
|
||||
repositories: list[str]
|
||||
|
||||
|
||||
class CapabilityGapResponse(BaseModel):
|
||||
desired_ability: str
|
||||
matched_capabilities: list[CapabilityGapMatchResponse]
|
||||
missing_capabilities: list[str]
|
||||
weakly_evidenced_capabilities: list[WeakCapabilityEvidenceResponse]
|
||||
duplicate_capabilities: list[DuplicateCapabilityResponse]
|
||||
|
||||
@@ -19,6 +19,7 @@ def test_openapi_groups_agent_facing_endpoints():
|
||||
"review",
|
||||
"registry",
|
||||
"search",
|
||||
"discovery",
|
||||
}
|
||||
search_operation = schema["paths"]["/search"]["get"]
|
||||
assert search_operation["tags"] == ["search"]
|
||||
@@ -190,6 +191,163 @@ def test_api_manual_registry_loop(tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_api_compare_gap_and_export_use_cases(tmp_path):
|
||||
def override_settings():
|
||||
return Settings(
|
||||
database_path=str(tmp_path / "discovery.sqlite3"),
|
||||
checkout_root=str(tmp_path / "discovery-checkouts"),
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_settings] = override_settings
|
||||
client = TestClient(app)
|
||||
try:
|
||||
first = client.post(
|
||||
"/repos",
|
||||
json={
|
||||
"name": "MailRouter",
|
||||
"url": "https://example.com/mail-router.git",
|
||||
"description": "Routes customer email.",
|
||||
},
|
||||
).json()
|
||||
second = client.post(
|
||||
"/repos",
|
||||
json={
|
||||
"name": "SupportRouter",
|
||||
"url": "https://example.com/support-router.git",
|
||||
"description": "Routes support requests.",
|
||||
},
|
||||
).json()
|
||||
|
||||
first_ability = client.post(
|
||||
f"/repos/{first['id']}/abilities",
|
||||
json={
|
||||
"name": "Business Email Routing",
|
||||
"description": "Route inbound messages.",
|
||||
"confidence": 0.92,
|
||||
},
|
||||
).json()["id"]
|
||||
first_classify = client.post(
|
||||
f"/repos/{first['id']}/capabilities",
|
||||
json={
|
||||
"ability_id": first_ability,
|
||||
"name": "Classify Incoming Email",
|
||||
"description": "Classify messages by intent.",
|
||||
"confidence": 0.88,
|
||||
},
|
||||
).json()["id"]
|
||||
client.post(
|
||||
f"/repos/{first['id']}/evidence",
|
||||
json={
|
||||
"capability_id": first_classify,
|
||||
"type": "unit_test",
|
||||
"reference": "tests/test_classify.py",
|
||||
"strength": "strong",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
f"/repos/{first['id']}/capabilities",
|
||||
json={
|
||||
"ability_id": first_ability,
|
||||
"name": "Route Email to Team",
|
||||
"description": "Route messages to owning teams.",
|
||||
"confidence": 0.7,
|
||||
},
|
||||
)
|
||||
|
||||
second_ability = client.post(
|
||||
f"/repos/{second['id']}/abilities",
|
||||
json={
|
||||
"name": "Business Email Routing",
|
||||
"description": "Support routing workflows.",
|
||||
"confidence": 0.8,
|
||||
},
|
||||
).json()["id"]
|
||||
second_classify = client.post(
|
||||
f"/repos/{second['id']}/capabilities",
|
||||
json={
|
||||
"ability_id": second_ability,
|
||||
"name": "Classify Incoming Email",
|
||||
"description": "Classify support requests.",
|
||||
"confidence": 0.6,
|
||||
},
|
||||
).json()["id"]
|
||||
client.post(
|
||||
f"/repos/{second['id']}/evidence",
|
||||
json={
|
||||
"capability_id": second_classify,
|
||||
"type": "documentation",
|
||||
"reference": "README.md",
|
||||
"strength": "medium",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
f"/repos/{second['id']}/capabilities",
|
||||
json={
|
||||
"ability_id": second_ability,
|
||||
"name": "Archive Email",
|
||||
"description": "Archive resolved messages.",
|
||||
"confidence": 0.75,
|
||||
},
|
||||
)
|
||||
|
||||
comparison_response = client.get(
|
||||
"/repository-comparisons",
|
||||
params=[
|
||||
("repository_ids", first["id"]),
|
||||
("repository_ids", second["id"]),
|
||||
],
|
||||
)
|
||||
assert comparison_response.status_code == 200
|
||||
comparison = comparison_response.json()
|
||||
assert {repo["name"] for repo in comparison["repositories"]} == {
|
||||
"MailRouter",
|
||||
"SupportRouter",
|
||||
}
|
||||
ability_entry = comparison["abilities"][0]
|
||||
assert ability_entry["name"] == "Business Email Routing"
|
||||
assert {repo["repository_name"] for repo in ability_entry["repositories"]} == {
|
||||
"MailRouter",
|
||||
"SupportRouter",
|
||||
}
|
||||
assert {
|
||||
item["capability_name"] for item in comparison["unique_capabilities"]
|
||||
} >= {"Route Email to Team", "Archive Email"}
|
||||
|
||||
gaps_response = client.post(
|
||||
"/capability-gaps",
|
||||
json={
|
||||
"desired_ability": "Business Email Routing",
|
||||
"desired_capabilities": [
|
||||
"Classify Incoming Email",
|
||||
"Route Email to Team",
|
||||
"German Benchmark Evaluation",
|
||||
],
|
||||
"repository_ids": [first["id"], second["id"]],
|
||||
},
|
||||
)
|
||||
assert gaps_response.status_code == 200
|
||||
gaps = gaps_response.json()
|
||||
assert gaps["missing_capabilities"] == ["German Benchmark Evaluation"]
|
||||
assert gaps["matched_capabilities"][0]["capability"] == (
|
||||
"Classify Incoming Email"
|
||||
)
|
||||
assert {
|
||||
weak["capability"] for weak in gaps["weakly_evidenced_capabilities"]
|
||||
} >= {"Classify Incoming Email", "Route Email to Team"}
|
||||
assert gaps["duplicate_capabilities"][0]["capability"] == (
|
||||
"classify incoming email"
|
||||
)
|
||||
|
||||
export_response = client.get(f"/repos/{first['id']}/export")
|
||||
assert export_response.status_code == 200
|
||||
assert export_response.headers["content-type"].startswith("application/x-yaml")
|
||||
assert 'name: "MailRouter"' in export_response.text
|
||||
assert 'name: "Classify Incoming Email"' in export_response.text
|
||||
assert 'reference: "tests/test_classify.py"' in export_response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_api_registers_repository_from_url_metadata(tmp_path):
|
||||
source = tmp_path / "metadata-api"
|
||||
source.mkdir()
|
||||
@@ -439,6 +597,179 @@ def test_api_analysis_run_loop(tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_api_source_linked_candidate_and_repo_update_loop(tmp_path):
|
||||
source = tmp_path / "source-linked"
|
||||
source.mkdir()
|
||||
(source / "README.md").write_text(
|
||||
"# Source Linked\n\nProvides operational HTTP status checks.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source / "docs").mkdir()
|
||||
(source / "docs" / "usage.md").write_text(
|
||||
"# Usage\n\nCall the status endpoint before routing traffic.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source / "examples").mkdir()
|
||||
(source / "examples" / "status_client.py").write_text(
|
||||
"print('GET /status')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source / "tests").mkdir()
|
||||
(source / "tests" / "test_status.py").write_text(
|
||||
"def test_status(): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(source / "requirements.txt").write_text("fastapi\npytest\n", encoding="utf-8")
|
||||
app_file = source / "app.py"
|
||||
app_file.write_text(
|
||||
"from fastapi import FastAPI\n"
|
||||
"app = FastAPI()\n"
|
||||
'@app.get("/status")\n'
|
||||
"def status():\n"
|
||||
" return {'status': 'ok'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def override_settings():
|
||||
return Settings(
|
||||
database_path=str(tmp_path / "source-linked.sqlite3"),
|
||||
checkout_root=str(tmp_path / "source-linked-checkouts"),
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_settings] = override_settings
|
||||
client = TestClient(app)
|
||||
try:
|
||||
repository_response = client.post(
|
||||
"/repos",
|
||||
json={"name": "Source Linked", "url": str(source)},
|
||||
)
|
||||
assert repository_response.status_code == 201
|
||||
repository_id = repository_response.json()["id"]
|
||||
|
||||
first_run_response = client.post(
|
||||
f"/repos/{repository_id}/analysis-runs",
|
||||
json={},
|
||||
)
|
||||
assert first_run_response.status_code == 201
|
||||
first_run = first_run_response.json()
|
||||
first_run_id = first_run["analysis_run"]["id"]
|
||||
assert first_run["snapshot"]["file_count"] == 6
|
||||
|
||||
facts_response = client.get(f"/repos/{repository_id}/observed-facts")
|
||||
assert facts_response.status_code == 200
|
||||
facts = facts_response.json()
|
||||
fact_keys = {(fact["kind"], fact["name"], fact["path"]) for fact in facts}
|
||||
assert ("documentation", "README", "README.md") in fact_keys
|
||||
assert ("documentation", "usage.md", "docs/usage.md") in fact_keys
|
||||
assert ("example", "status_client.py", "examples/status_client.py") in fact_keys
|
||||
assert ("test", "test_status.py", "tests/test_status.py") in fact_keys
|
||||
assert ("framework", "FastAPI", "requirements.txt") in fact_keys
|
||||
assert ("interface", "python route decorator", "app.py") in fact_keys
|
||||
|
||||
candidate_response = client.get(
|
||||
f"/repos/{repository_id}/analysis-runs/{first_run_id}/candidate-graph"
|
||||
)
|
||||
assert candidate_response.status_code == 200
|
||||
candidate_graph = candidate_response.json()
|
||||
interface_capability = next(
|
||||
capability
|
||||
for capability in candidate_graph["abilities"][0]["capabilities"]
|
||||
if capability["name"] == "Expose Repository Interface"
|
||||
)
|
||||
feature = next(
|
||||
item
|
||||
for item in interface_capability["features"]
|
||||
if item["name"] == "GET /status"
|
||||
)
|
||||
assert feature["type"] == "API"
|
||||
assert feature["location"] == "app.py"
|
||||
assert feature["source_refs"][0]["path"] == "app.py"
|
||||
assert feature["source_refs"][0]["line"] == 3
|
||||
evidence_refs = {
|
||||
(evidence["type"], evidence["reference"], evidence["strength"])
|
||||
for evidence in interface_capability["evidence"]
|
||||
}
|
||||
assert ("test", "tests/test_status.py", "strong") in evidence_refs
|
||||
assert ("example", "examples/status_client.py", "strong") in evidence_refs
|
||||
assert ("documentation", "README.md", "medium") in evidence_refs
|
||||
|
||||
approve_response = client.post(
|
||||
f"/repos/{repository_id}/analysis-runs/{first_run_id}"
|
||||
"/candidate-graph/approve",
|
||||
json={"notes": "Approved source-linked e2e fixture"},
|
||||
)
|
||||
assert approve_response.status_code == 200
|
||||
approved_map = approve_response.json()
|
||||
approved_capability = next(
|
||||
capability
|
||||
for capability in approved_map["abilities"][0]["capabilities"]
|
||||
if capability["name"] == "Expose Repository Interface"
|
||||
)
|
||||
approved_feature = next(
|
||||
item
|
||||
for item in approved_capability["features"]
|
||||
if item["name"] == "GET /status"
|
||||
)
|
||||
assert approved_feature["source_refs"][0]["line"] == 3
|
||||
assert {
|
||||
evidence["reference"] for evidence in approved_capability["evidence"]
|
||||
} >= {"tests/test_status.py", "examples/status_client.py", "README.md"}
|
||||
|
||||
evidence_search = client.get(
|
||||
"/search",
|
||||
params={"q": "test_status.py", "status": "indexed"},
|
||||
)
|
||||
assert evidence_search.status_code == 200
|
||||
evidence_result = evidence_search.json()[0]
|
||||
assert evidence_result["match_type"] == "evidence"
|
||||
assert evidence_result["evidence_level"] == "strong"
|
||||
assert evidence_result["source_reference"] == "tests/test_status.py"
|
||||
|
||||
app_file.write_text(
|
||||
"from fastapi import FastAPI\n"
|
||||
"app = FastAPI()\n"
|
||||
'@app.get("/status")\n'
|
||||
"def status():\n"
|
||||
" return {'status': 'ok'}\n\n"
|
||||
'@app.get("/ready")\n'
|
||||
"def ready():\n"
|
||||
" return {'ready': True}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
second_run_response = client.post(
|
||||
f"/repos/{repository_id}/analysis-runs",
|
||||
json={},
|
||||
)
|
||||
assert second_run_response.status_code == 201
|
||||
second_run_id = second_run_response.json()["analysis_run"]["id"]
|
||||
assert second_run_id != first_run_id
|
||||
|
||||
second_candidate_response = client.get(
|
||||
f"/repos/{repository_id}/analysis-runs/{second_run_id}/candidate-graph"
|
||||
)
|
||||
assert second_candidate_response.status_code == 200
|
||||
second_features = {
|
||||
feature["name"]
|
||||
for ability in second_candidate_response.json()["abilities"]
|
||||
for capability in ability["capabilities"]
|
||||
for feature in capability["features"]
|
||||
}
|
||||
assert {"GET /status", "GET /ready"} <= second_features
|
||||
|
||||
approved_after_reanalysis = client.get(f"/repos/{repository_id}/ability-map")
|
||||
assert approved_after_reanalysis.status_code == 200
|
||||
approved_features = {
|
||||
feature["name"]
|
||||
for ability in approved_after_reanalysis.json()["abilities"]
|
||||
for capability in ability["capabilities"]
|
||||
for feature in capability["features"]
|
||||
}
|
||||
assert "GET /status" in approved_features
|
||||
assert "GET /ready" not in approved_features
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_ui_register_analyze_and_approve_loop(tmp_path):
|
||||
source = tmp_path / "repo"
|
||||
source.mkdir()
|
||||
|
||||
@@ -262,6 +262,47 @@ Acceptance criteria:
|
||||
- Responses are stable enough for agent/tooling integration
|
||||
- OpenAPI docs describe all MVP endpoints
|
||||
|
||||
## 6.1 Implemented Status Checkpoint
|
||||
|
||||
Status date: 2026-04-26
|
||||
|
||||
Current implementation baseline:
|
||||
|
||||
- Milestone 0: implemented. FastAPI app, SQLite migrations, settings, health endpoint, README development flow, and pytest harness are in place.
|
||||
- Milestone 1: implemented. Repository CRUD, manual ability/capability/feature/evidence CRUD, ability-map API, and server-rendered repository profile UI are in place.
|
||||
- Milestone 2: implemented for local paths and Git URLs. Registration can import metadata, analysis records snapshots and observed facts, and failures are captured on analysis runs.
|
||||
- Milestone 3: implemented for deterministic extraction plus optional LLM-assisted extraction. Analysis stores content chunks, source-linked candidates, candidate evidence, confidence scores, and confidence labels.
|
||||
- Milestone 4: implemented. Candidate approval, reject, edit, relink, merge, review decisions, and indexed repository publication are supported through API and UI paths.
|
||||
- Milestone 5: partially implemented. Text search, filters, search UI, ability-map drill-down, and evidence/source context are implemented. pgvector-backed semantic search remains future work.
|
||||
- Milestone 6: implemented for the MVP and review workflow. Agent-facing endpoints have typed OpenAPI response schemas, examples, tags, and docs smoke coverage.
|
||||
|
||||
Use case coverage status:
|
||||
|
||||
| ID | Use Case | Implementation Status | E2E Coverage Status |
|
||||
| --- | --- | --- | --- |
|
||||
| UC-01 | Register Git Repository | Implemented through API and UI. | Covered by API and UI registration loops. |
|
||||
| UC-02 | Import Repository Metadata | Implemented from repository files when name/description are omitted. | Covered by API and service metadata tests. |
|
||||
| UC-03 | Analyze Repository Structure | Implemented by deterministic scanner and analysis runs. | Covered by API, service, scanner, and UI analysis loops. |
|
||||
| UC-04 | Extract Candidate Abilities | Implemented by deterministic generator and optional LLM mapper. | Covered by API/service analysis loops and LLM extraction tests. |
|
||||
| UC-05 | Extract Candidate Capabilities | Implemented by deterministic generator and optional LLM mapper. | Covered by API/service analysis loops and LLM extraction tests. |
|
||||
| UC-06 | Extract Candidate Features | Implemented with detected interfaces, languages, frameworks, docs, tests, and manifests. | Covered by API/service analysis loops plus source-linked fixture e2e assertions. |
|
||||
| UC-07 | Link Features to Code Locations | Implemented through feature locations and source references. | Covered by service approval tests and API e2e assertions for source paths/lines. |
|
||||
| UC-08 | Attach Evidence to Capabilities | Implemented for candidate and approved evidence. | Covered by API/UI review, manual registry tests, and source-linked approved evidence e2e assertions. |
|
||||
| UC-09 | Review and Approve Analysis | Implemented through approve, edit, reject, relink, merge, and review decisions. | Covered by API/service/UI review tests. |
|
||||
| UC-10 | Search Repositories by Need | Implemented with text search and structured filters. | Covered by API/service/UI search tests. Semantic search remains future work. |
|
||||
| UC-11 | Inspect Repository Ability Map | Implemented through API and UI profile drill-down. | Covered by API/service/UI ability-map tests. |
|
||||
| UC-12 | Compare Repositories | Implemented as a read-only API comparison over approved ability maps. | Covered by API e2e comparison test. |
|
||||
| UC-13 | Detect Capability Gaps | Implemented as a read-only API gap report over desired capabilities and approved maps. | Covered by API e2e gap-analysis test. |
|
||||
| UC-14 | Expose Registry via API | Implemented for MVP plus review workflow. | Covered by API contract, OpenAPI, and docs smoke tests. |
|
||||
| UC-15 | Update Registry After Repo Change | Partially implemented by rerunning analysis; no explicit diff/change-review workflow yet. | Covered for rerun behavior by API e2e: second analysis records new candidates without corrupting approved profile. |
|
||||
| UC-16 | Export Registry Entry | Implemented as YAML export for approved registry entries. | Covered by API e2e export test. |
|
||||
|
||||
Immediate production-readiness test focus:
|
||||
|
||||
1. If UC-15 becomes a production priority, add an explicit diff/change-review model instead of relying only on rerun analysis.
|
||||
2. Broaden fixture coverage over time for README-only, Python CLI, FastAPI, JavaScript/TypeScript, tests/examples, and weak-doc repositories.
|
||||
3. Add richer UI affordances for comparison, gap analysis, and export if these discovery endpoints become curator-facing workflows.
|
||||
|
||||
## 7. Initial Database Shape
|
||||
|
||||
Start with tables for:
|
||||
|
||||
Reference in New Issue
Block a user