diff --git a/README.md b/README.md index 4097d2d..f89482c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,14 @@ GET /repos/{id}/analysis-runs/{run_id} GET /repos/{id}/analysis-runs/{run_id}/candidate-graph POST /repos/{id}/analysis-runs/{run_id}/candidate-graph/approve GET /repos/{id}/ability-map +PATCH /repos/{id}/abilities/{ability_id} +DELETE /repos/{id}/abilities/{ability_id} +PATCH /repos/{id}/capabilities/{capability_id} +DELETE /repos/{id}/capabilities/{capability_id} +PATCH /repos/{id}/features/{feature_id} +DELETE /repos/{id}/features/{feature_id} +PATCH /repos/{id}/evidence/{evidence_id} +DELETE /repos/{id}/evidence/{evidence_id} GET /abilities GET /capabilities GET /search?q=... diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index b56eefb..10ffd8e 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -545,6 +545,32 @@ class RegistryService: confidence=confidence, ) + def update_ability( + self, + repository_id: int, + ability_id: int, + *, + name: str | None = None, + description: str | None = None, + confidence: float | None = None, + ) -> RepositoryAbilityMap: + self.store.update_ability( + repository_id, + ability_id, + name=name, + description=description, + confidence=confidence, + ) + return self.store.get_ability_map(repository_id) + + def delete_ability( + self, + repository_id: int, + ability_id: int, + ) -> RepositoryAbilityMap: + self.store.delete_ability(repository_id, ability_id) + return self.store.get_ability_map(repository_id) + def add_capability( self, repository_id: int, @@ -567,6 +593,36 @@ class RegistryService: confidence=confidence, ) + def update_capability( + self, + repository_id: int, + capability_id: int, + *, + name: str | None = None, + description: str | None = None, + inputs: Sequence[str] | None = None, + outputs: Sequence[str] | None = None, + confidence: float | None = None, + ) -> RepositoryAbilityMap: + self.store.update_capability( + repository_id, + capability_id, + name=name, + description=description, + inputs=list(inputs) if inputs is not None else None, + outputs=list(outputs) if outputs is not None else None, + confidence=confidence, + ) + return self.store.get_ability_map(repository_id) + + def delete_capability( + self, + repository_id: int, + capability_id: int, + ) -> RepositoryAbilityMap: + self.store.delete_capability(repository_id, capability_id) + return self.store.get_ability_map(repository_id) + def add_feature( self, repository_id: int, @@ -587,6 +643,34 @@ class RegistryService: confidence=confidence, ) + def update_feature( + self, + repository_id: int, + feature_id: int, + *, + name: str | None = None, + type: str | None = None, + location: str | None = None, + confidence: float | None = None, + ) -> RepositoryAbilityMap: + self.store.update_feature( + repository_id, + feature_id, + name=name, + type=type, + location=location, + confidence=confidence, + ) + return self.store.get_ability_map(repository_id) + + def delete_feature( + self, + repository_id: int, + feature_id: int, + ) -> RepositoryAbilityMap: + self.store.delete_feature(repository_id, feature_id) + return self.store.get_ability_map(repository_id) + def add_evidence( self, repository_id: int, @@ -605,6 +689,32 @@ class RegistryService: strength=strength, ) + def update_evidence( + self, + repository_id: int, + evidence_id: int, + *, + type: str | None = None, + reference: str | None = None, + strength: str | None = None, + ) -> RepositoryAbilityMap: + self.store.update_evidence( + repository_id, + evidence_id, + type=type, + reference=reference, + strength=strength, + ) + return self.store.get_ability_map(repository_id) + + def delete_evidence( + self, + repository_id: int, + evidence_id: int, + ) -> RepositoryAbilityMap: + self.store.delete_evidence(repository_id, evidence_id) + return self.store.get_ability_map(repository_id) + def ability_map(self, repository_id: int) -> RepositoryAbilityMap: return self.store.get_ability_map(repository_id) diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 1ea9f40..cbfd694 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -1196,6 +1196,35 @@ class RegistryStore: f"ability {ability_id} was not found for repository {repository_id}" ) + def update_ability( + self, + repository_id: int, + ability_id: int, + *, + name: str | None = None, + description: str | None = None, + confidence: float | None = None, + ) -> None: + self._update_approved_row( + table="approved_abilities", + label="ability", + repository_id=repository_id, + row_id=ability_id, + values={ + "name": name, + "description": description, + "confidence": confidence, + }, + ) + + def delete_ability(self, repository_id: int, ability_id: int) -> None: + self._delete_approved_row( + table="approved_abilities", + label="ability", + repository_id=repository_id, + row_id=ability_id, + ) + def create_capability( self, repository_id: int, @@ -1240,6 +1269,39 @@ class RegistryStore: f"capability {capability_id} was not found for repository {repository_id}" ) + def update_capability( + self, + repository_id: int, + capability_id: int, + *, + name: str | None = None, + description: str | None = None, + inputs: list[str] | None = None, + outputs: list[str] | None = None, + confidence: float | None = None, + ) -> None: + self._update_approved_row( + table="approved_capabilities", + label="capability", + repository_id=repository_id, + row_id=capability_id, + values={ + "name": name, + "description": description, + "inputs": json.dumps(inputs) if inputs is not None else None, + "outputs": json.dumps(outputs) if outputs is not None else None, + "confidence": confidence, + }, + ) + + def delete_capability(self, repository_id: int, capability_id: int) -> None: + self._delete_approved_row( + table="approved_capabilities", + label="capability", + repository_id=repository_id, + row_id=capability_id, + ) + def create_feature( self, repository_id: int, @@ -1270,6 +1332,37 @@ class RegistryStore: ) return int(cursor.lastrowid) + def update_feature( + self, + repository_id: int, + feature_id: int, + *, + name: str | None = None, + type: str | None = None, + location: str | None = None, + confidence: float | None = None, + ) -> None: + self._update_approved_row( + table="approved_features", + label="feature", + repository_id=repository_id, + row_id=feature_id, + values={ + "name": name, + "type": type, + "location": location, + "confidence": confidence, + }, + ) + + def delete_feature(self, repository_id: int, feature_id: int) -> None: + self._delete_approved_row( + table="approved_features", + label="feature", + repository_id=repository_id, + row_id=feature_id, + ) + def create_evidence( self, repository_id: int, @@ -1298,6 +1391,35 @@ class RegistryStore: ) return int(cursor.lastrowid) + def update_evidence( + self, + repository_id: int, + evidence_id: int, + *, + type: str | None = None, + reference: str | None = None, + strength: str | None = None, + ) -> None: + self._update_approved_row( + table="approved_evidence", + label="evidence", + repository_id=repository_id, + row_id=evidence_id, + values={ + "type": type, + "reference": reference, + "strength": strength, + }, + ) + + def delete_evidence(self, repository_id: int, evidence_id: int) -> None: + self._delete_approved_row( + table="approved_evidence", + label="evidence", + repository_id=repository_id, + row_id=evidence_id, + ) + def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap: repository = self.get_repository(repository_id) with self.connect() as connection: @@ -1715,6 +1837,81 @@ class RegistryStore: ], ) + def _update_approved_row( + self, + *, + table: str, + label: str, + repository_id: int, + row_id: int, + values: dict[str, str | float | None], + ) -> None: + assignments: list[str] = [] + params: list[str | float | int] = [] + for column, value in values.items(): + if value is None: + continue + assignments.append(f"{column} = ?") + params.append(value) + if not assignments: + self._ensure_approved_row( + table=table, + label=label, + repository_id=repository_id, + row_id=row_id, + ) + return + params.extend([row_id, repository_id]) + with self.connect() as connection: + cursor = connection.execute( + f""" + UPDATE {table} + SET {", ".join(assignments)} + WHERE id = ? AND repository_id = ? + """, + params, + ) + if cursor.rowcount == 0: + raise NotFoundError( + f"{label} {row_id} was not found for repository {repository_id}" + ) + + def _delete_approved_row( + self, + *, + table: str, + label: str, + repository_id: int, + row_id: int, + ) -> None: + with self.connect() as connection: + cursor = connection.execute( + f"DELETE FROM {table} WHERE id = ? AND repository_id = ?", + (row_id, repository_id), + ) + if cursor.rowcount == 0: + raise NotFoundError( + f"{label} {row_id} was not found for repository {repository_id}" + ) + + def _ensure_approved_row( + self, + *, + table: str, + label: str, + repository_id: int, + row_id: int, + ) -> None: + with self.connect() as connection: + row = connection.execute( + f"SELECT id FROM {table} WHERE id = ? AND repository_id = ?", + (row_id, repository_id), + ).fetchone() + if row is None: + raise NotFoundError( + f"{label} {row_id} was not found for repository {repository_id}" + ) + def _source_refs_to_json(self, source_refs: list[SourceReference]) -> str: return json.dumps( [ diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 93663cd..84e1c49 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -84,6 +84,12 @@ class AbilityCreate(BaseModel): } +class AbilityUpdate(BaseModel): + name: str | None = None + description: str | None = None + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + + class CapabilityCreate(BaseModel): ability_id: int name: str @@ -108,6 +114,14 @@ class CapabilityCreate(BaseModel): } +class CapabilityUpdate(BaseModel): + name: str | None = None + description: str | None = None + inputs: list[str] | None = None + outputs: list[str] | None = None + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + + class FeatureCreate(BaseModel): capability_id: int name: str @@ -130,6 +144,13 @@ class FeatureCreate(BaseModel): } +class FeatureUpdate(BaseModel): + name: str | None = None + type: str | None = None + location: str | None = None + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + + class EvidenceCreate(BaseModel): capability_id: int type: str @@ -150,6 +171,12 @@ class EvidenceCreate(BaseModel): } +class EvidenceUpdate(BaseModel): + type: str | None = None + reference: str | None = None + strength: str | None = None + + class AnalysisRunCreate(BaseModel): source_path: str | None = None @@ -793,6 +820,37 @@ def create_ability( return {"id": ability_id} +@app.patch("/repos/{repository_id}/abilities/{ability_id}") +def update_ability( + repository_id: int, + ability_id: int, + payload: AbilityUpdate, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.update_ability( + repository_id, + ability_id, + **payload.model_dump(exclude_unset=True), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.delete("/repos/{repository_id}/abilities/{ability_id}") +def delete_ability( + repository_id: int, + ability_id: int, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict(service.delete_ability(repository_id, ability_id)) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/repos/{repository_id}/capabilities", status_code=201) def create_capability( repository_id: int, @@ -806,6 +864,37 @@ def create_capability( return {"id": capability_id} +@app.patch("/repos/{repository_id}/capabilities/{capability_id}") +def update_capability( + repository_id: int, + capability_id: int, + payload: CapabilityUpdate, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.update_capability( + repository_id, + capability_id, + **payload.model_dump(exclude_unset=True), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.delete("/repos/{repository_id}/capabilities/{capability_id}") +def delete_capability( + repository_id: int, + capability_id: int, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict(service.delete_capability(repository_id, capability_id)) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/repos/{repository_id}/features", status_code=201) def create_feature( repository_id: int, @@ -819,6 +908,37 @@ def create_feature( return {"id": feature_id} +@app.patch("/repos/{repository_id}/features/{feature_id}") +def update_feature( + repository_id: int, + feature_id: int, + payload: FeatureUpdate, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.update_feature( + repository_id, + feature_id, + **payload.model_dump(exclude_unset=True), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.delete("/repos/{repository_id}/features/{feature_id}") +def delete_feature( + repository_id: int, + feature_id: int, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict(service.delete_feature(repository_id, feature_id)) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/repos/{repository_id}/evidence", status_code=201) def create_evidence( repository_id: int, @@ -832,6 +952,37 @@ def create_evidence( return {"id": evidence_id} +@app.patch("/repos/{repository_id}/evidence/{evidence_id}") +def update_evidence( + repository_id: int, + evidence_id: int, + payload: EvidenceUpdate, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.update_evidence( + repository_id, + evidence_id, + **payload.model_dump(exclude_unset=True), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.delete("/repos/{repository_id}/evidence/{evidence_id}") +def delete_evidence( + repository_id: int, + evidence_id: int, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict(service.delete_evidence(repository_id, evidence_id)) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.get("/repos/{repository_id}/ability-map") def get_ability_map( repository_id: int, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 84ecb14..6f94e9c 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -308,6 +308,45 @@ def repository_detail( {render_ability_map(asdict(ability_map))} +
+

Manual Registry Entry

+
+
+

Add Ability

+ + + + +
+
+

Add Capability

+ + + + + + + +
+
+

Add Feature

+ + + + + + +
+
+

Add Evidence

+ + + + + +
+
+

Review Decisions

{render_review_decisions(decisions)} @@ -316,6 +355,86 @@ def repository_detail( return page(repository.name, body) +@router.post("/ui/repos/{repository_id}/abilities") +def create_ability_from_form( + repository_id: int, + name: str = Form(...), + description: str = Form(""), + confidence: float = Form(1.0), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.add_ability( + repository_id, + name=name, + description=description, + confidence=confidence, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + +@router.post("/ui/repos/{repository_id}/capabilities") +def create_capability_from_form( + repository_id: int, + ability_id: int = Form(...), + name: str = Form(...), + description: str = Form(""), + inputs: str = Form(""), + outputs: str = Form(""), + confidence: float = Form(1.0), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.add_capability( + repository_id, + ability_id, + name=name, + description=description, + inputs=split_csv(inputs), + outputs=split_csv(outputs), + confidence=confidence, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + +@router.post("/ui/repos/{repository_id}/features") +def create_feature_from_form( + repository_id: int, + capability_id: int = Form(...), + name: str = Form(...), + type: str = Form(...), + location: str = Form(""), + confidence: float = Form(1.0), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.add_feature( + repository_id, + capability_id, + name=name, + type=type, + location=location, + confidence=confidence, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + +@router.post("/ui/repos/{repository_id}/evidence") +def create_evidence_from_form( + repository_id: int, + capability_id: int = Form(...), + type: str = Form(...), + reference: str = Form(...), + strength: str = Form("medium"), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.add_evidence( + repository_id, + capability_id, + type=type, + reference=reference, + strength=strength, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + @router.post("/ui/repos/{repository_id}/analysis-runs") def create_analysis_run_from_form( repository_id: int, @@ -751,6 +870,10 @@ def render_repository_facts(languages: list[str], frameworks: list[str]) -> str: return f'

{language_pills}{framework_pills}

' +def split_csv(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + def render_review_decisions(decisions: list) -> str: if not decisions: return '

No review decisions yet.

' @@ -967,6 +1090,7 @@ def render_ability_map(ability_map: dict) -> str: f"""
  • {escape(capability['name'])} + ID {capability['id']}

    {escape(capability['description'])}

  • @@ -976,6 +1100,7 @@ def render_ability_map(ability_map: dict) -> str: f"""
  • {escape(ability['name'])} + ID {ability['id']}

    {escape(ability['description'])}

  • diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 8b0ef2a..90963e9 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -61,6 +61,65 @@ def test_manual_registry_builds_ability_map(tmp_path): assert capability.evidence[0].strength == "strong" +def test_manual_registry_updates_and_deletes_approved_entries(tmp_path): + service = make_service(tmp_path) + repository = service.register_repository( + name="Manual", + url="https://example.com/manual.git", + description="Manual registry fixture.", + ) + ability_id = service.add_ability(repository.id, name="Original Ability") + capability_id = service.add_capability( + repository.id, + ability_id, + name="Original Capability", + ) + feature_id = service.add_feature( + repository.id, + capability_id, + name="Original Feature", + type="API", + ) + evidence_id = service.add_evidence( + repository.id, + capability_id, + type="test", + reference="tests/test_original.py", + ) + + service.update_ability(repository.id, ability_id, name="Updated Ability") + service.update_capability( + repository.id, + capability_id, + name="Updated Capability", + inputs=["request"], + outputs=["response"], + ) + service.update_feature(repository.id, feature_id, location="src/api.py") + ability_map = service.update_evidence( + repository.id, + evidence_id, + strength="strong", + ) + + ability = ability_map.abilities[0] + capability = ability.capabilities[0] + assert ability.name == "Updated Ability" + assert capability.name == "Updated Capability" + assert capability.inputs == ["request"] + assert capability.outputs == ["response"] + assert capability.features[0].location == "src/api.py" + assert capability.evidence[0].strength == "strong" + + service.delete_feature(repository.id, feature_id) + service.delete_evidence(repository.id, evidence_id) + ability_map = service.delete_capability(repository.id, capability_id) + assert ability_map.abilities[0].capabilities == [] + + ability_map = service.delete_ability(repository.id, ability_id) + assert ability_map.abilities == [] + + def test_repository_update_and_delete(tmp_path): service = make_service(tmp_path) repository = service.register_repository( diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 08fc9e2..806e2c4 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -66,13 +66,63 @@ def test_api_manual_registry_loop(tmp_path): }, ) assert feature_response.status_code == 201 + feature_id = feature_response.json()["id"] + + evidence_response = client.post( + f"/repos/{repository_id}/evidence", + json={ + "capability_id": capability_id, + "type": "unit_test", + "reference": "tests/test_email_classification.py", + }, + ) + assert evidence_response.status_code == 201 + evidence_id = evidence_response.json()["id"] + + ability_update_response = client.patch( + f"/repos/{repository_id}/abilities/{ability_id}", + json={"name": "Business Email Routing Updated"}, + ) + assert ability_update_response.status_code == 200 + assert ability_update_response.json()["abilities"][0]["name"] == ( + "Business Email Routing Updated" + ) + + capability_update_response = client.patch( + f"/repos/{repository_id}/capabilities/{capability_id}", + json={"name": "Classify Incoming Email Updated"}, + ) + assert capability_update_response.status_code == 200 + assert capability_update_response.json()["abilities"][0]["capabilities"][0][ + "name" + ] == "Classify Incoming Email Updated" + + feature_update_response = client.patch( + f"/repos/{repository_id}/features/{feature_id}", + json={"location": "src/routes/updated.py"}, + ) + assert feature_update_response.status_code == 200 + evidence_update_response = client.patch( + f"/repos/{repository_id}/evidence/{evidence_id}", + json={"strength": "strong"}, + ) + assert evidence_update_response.status_code == 200 map_response = client.get(f"/repos/{repository_id}/ability-map") assert map_response.status_code == 200 ability_map = map_response.json() assert ability_map["repository"]["name"] == "MailRouter Updated" assert ability_map["abilities"][0]["capabilities"][0]["name"] == ( - "Classify Incoming Email" + "Classify Incoming Email Updated" + ) + + assert ( + client.delete(f"/repos/{repository_id}/features/{feature_id}").status_code + == 200 + ) + assert ( + client.delete(f"/repos/{repository_id}/evidence/{evidence_id}").status_code + == 200 ) search_response = client.get("/search", params={"q": "email"}) @@ -367,6 +417,99 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): app.dependency_overrides.clear() +def test_ui_manual_registry_entry_loop(tmp_path): + source = tmp_path / "manual-repo" + source.mkdir() + (source / "README.md").write_text("# Manual Repo\n", encoding="utf-8") + + def override_settings(): + return Settings( + database_path=str(tmp_path / "ui-manual.sqlite3"), + checkout_root=str(tmp_path / "ui-manual-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + create_response = client.post( + "/ui/repos", + data={"url": str(source), "branch": "main"}, + follow_redirects=False, + ) + assert create_response.status_code == 303 + repository_path = create_response.headers["location"] + repository_id = int(repository_path.rsplit("/", 1)[-1]) + + detail_response = client.get(repository_path) + assert detail_response.status_code == 200 + assert "Manual Registry Entry" in detail_response.text + + ability_response = client.post( + f"{repository_path}/abilities", + data={ + "name": "Manual Ability", + "description": "Curated by hand.", + "confidence": "0.95", + }, + follow_redirects=False, + ) + assert ability_response.status_code == 303 + ability_id = client.get(f"/repos/{repository_id}/ability-map").json()[ + "abilities" + ][0]["id"] + + capability_response = client.post( + f"{repository_path}/capabilities", + data={ + "ability_id": str(ability_id), + "name": "Manual Capability", + "description": "Curated capability.", + "inputs": "request, context", + "outputs": "response", + "confidence": "0.9", + }, + follow_redirects=False, + ) + assert capability_response.status_code == 303 + capability_id = client.get(f"/repos/{repository_id}/ability-map").json()[ + "abilities" + ][0]["capabilities"][0]["id"] + + feature_response = client.post( + f"{repository_path}/features", + data={ + "capability_id": str(capability_id), + "name": "Manual API", + "type": "REST endpoint", + "location": "src/manual.py", + "confidence": "0.88", + }, + follow_redirects=False, + ) + assert feature_response.status_code == 303 + + evidence_response = client.post( + f"{repository_path}/evidence", + data={ + "capability_id": str(capability_id), + "type": "documentation", + "reference": "README.md", + "strength": "medium", + }, + follow_redirects=False, + ) + assert evidence_response.status_code == 303 + + detail_response = client.get(repository_path) + assert "Manual Ability" in detail_response.text + assert "Manual Capability" in detail_response.text + assert "Manual API" in detail_response.text + assert "README.md" in detail_response.text + assert "ID " in detail_response.text + finally: + app.dependency_overrides.clear() + + def test_api_rejects_candidate_capability_feature_and_evidence(tmp_path): source = tmp_path / "repo" source.mkdir()