diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index e22d26a..a1553be 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -135,6 +135,7 @@ CREATE TABLE IF NOT EXISTS approved_features ( type TEXT NOT NULL, location TEXT NOT NULL DEFAULT '', confidence REAL NOT NULL DEFAULT 1.0, + source_refs TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -145,6 +146,7 @@ CREATE TABLE IF NOT EXISTS approved_evidence ( type TEXT NOT NULL, reference TEXT NOT NULL, strength TEXT NOT NULL DEFAULT 'medium', + source_refs TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/src/repo_registry/candidate_graph/generator.py b/src/repo_registry/candidate_graph/generator.py index ce64b51..a257592 100644 --- a/src/repo_registry/candidate_graph/generator.py +++ b/src/repo_registry/candidate_graph/generator.py @@ -189,6 +189,7 @@ class CandidateGraphGenerator: path=fact.path, kind=fact.kind, name=fact.name, + line=fact.metadata.get("line"), ) for fact in facts ] diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 662569b..299f4e6 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -62,6 +62,7 @@ class SourceReference: path: str kind: str name: str + line: int | None = None @dataclass(frozen=True) @@ -123,6 +124,7 @@ class Evidence: type: str reference: str strength: str + source_refs: list[SourceReference] = field(default_factory=list) @dataclass(frozen=True) @@ -132,6 +134,7 @@ class Feature: type: str location: str confidence: float + source_refs: list[SourceReference] = field(default_factory=list) @dataclass(frozen=True) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 32428ac..df70c06 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -162,6 +162,7 @@ class RegistryService: type=feature.type, location=feature.location, confidence=feature.confidence, + source_refs=feature.source_refs, ) for evidence in capability.evidence: if evidence.status != "candidate": @@ -172,6 +173,7 @@ class RegistryService: type=evidence.type, reference=evidence.reference, strength=evidence.strength, + source_refs=evidence.source_refs, ) if pending_abilities: diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 1edadfa..465b7d1 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -40,6 +40,7 @@ class RegistryStore: migration_path = Path(__file__).parents[3] / "migrations" / "0001_initial.sql" with self.connect() as connection: connection.executescript(migration_path.read_text(encoding="utf-8")) + self._ensure_approved_source_ref_columns(connection) def connect(self) -> sqlite3.Connection: connection = sqlite3.connect(self.database_path) @@ -47,6 +48,20 @@ class RegistryStore: connection.execute("PRAGMA foreign_keys = ON") return connection + def _ensure_approved_source_ref_columns( + self, + connection: sqlite3.Connection, + ) -> None: + for table in ("approved_features", "approved_evidence"): + columns = { + row["name"] + for row in connection.execute(f"PRAGMA table_info({table})").fetchall() + } + if "source_refs" not in columns: + connection.execute( + f"ALTER TABLE {table} ADD COLUMN source_refs TEXT NOT NULL DEFAULT '[]'" + ) + def create_repository( self, *, @@ -1153,15 +1168,24 @@ class RegistryStore: type: str, location: str, confidence: float, + source_refs: list[SourceReference] | None = None, ) -> int: with self.connect() as connection: cursor = connection.execute( """ INSERT INTO approved_features - (repository_id, capability_id, name, type, location, confidence) - VALUES (?, ?, ?, ?, ?, ?) + (repository_id, capability_id, name, type, location, confidence, source_refs) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (repository_id, capability_id, name, type, location, confidence), + ( + repository_id, + capability_id, + name, + type, + location, + confidence, + self._source_refs_to_json(source_refs or []), + ), ) return int(cursor.lastrowid) @@ -1173,15 +1197,23 @@ class RegistryStore: type: str, reference: str, strength: str, + source_refs: list[SourceReference] | None = None, ) -> int: with self.connect() as connection: cursor = connection.execute( """ INSERT INTO approved_evidence - (repository_id, capability_id, type, reference, strength) - VALUES (?, ?, ?, ?, ?) + (repository_id, capability_id, type, reference, strength, source_refs) + VALUES (?, ?, ?, ?, ?, ?) """, - (repository_id, capability_id, type, reference, strength), + ( + repository_id, + capability_id, + type, + reference, + strength, + self._source_refs_to_json(source_refs or []), + ), ) return int(cursor.lastrowid) @@ -1208,7 +1240,7 @@ class RegistryStore: ).fetchall() feature_rows = connection.execute( """ - SELECT id, capability_id, name, type, location, confidence + SELECT id, capability_id, name, type, location, confidence, source_refs FROM approved_features WHERE repository_id = ? ORDER BY id @@ -1217,7 +1249,7 @@ class RegistryStore: ).fetchall() evidence_rows = connection.execute( """ - SELECT id, capability_id, type, reference, strength + SELECT id, capability_id, type, reference, strength, source_refs FROM approved_evidence WHERE repository_id = ? ORDER BY id @@ -1234,6 +1266,7 @@ class RegistryStore: type=row["type"], location=row["location"], confidence=row["confidence"], + source_refs=self._source_refs_from_json(row["source_refs"]), ) ) @@ -1245,6 +1278,7 @@ class RegistryStore: type=row["type"], reference=row["reference"], strength=row["strength"], + source_refs=self._source_refs_from_json(row["source_refs"]), ) ) @@ -1608,6 +1642,7 @@ class RegistryStore: "path": source_ref.path, "kind": source_ref.kind, "name": source_ref.name, + "line": source_ref.line, } for source_ref in source_refs ] @@ -1620,6 +1655,7 @@ class RegistryStore: path=item.get("path", ""), kind=item.get("kind", ""), name=item.get("name", ""), + line=item.get("line"), ) for item in json.loads(value) ] diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index cd67515..c45757b 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -927,11 +927,11 @@ def render_ability_map(ability_map: dict) -> str: capabilities = [] for capability in ability["capabilities"]: features = "".join( - f'
  • {escape(feature["name"])} {escape(feature["type"])} {escape(feature["location"])}
  • ' + render_approved_feature(feature) for feature in capability["features"] ) evidence = "".join( - f'
  • {escape(item["type"])} {escape(item["strength"])} {escape(item["reference"])}
  • ' + render_approved_evidence(item) for item in capability["evidence"] ) capabilities.append( @@ -955,6 +955,28 @@ def render_ability_map(ability_map: dict) -> str: return f'
    ' +def render_approved_feature(feature: dict) -> str: + return f""" +
  • + {escape(feature["name"])} + {escape(feature["type"])} + {escape(feature["location"])} + {render_sources(feature.get("source_refs", []))} +
  • + """ + + +def render_approved_evidence(evidence: dict) -> str: + return f""" +
  • + {escape(evidence["type"])} + {escape(evidence["strength"])} + {escape(evidence["reference"])} + {render_sources(evidence.get("source_refs", []))} +
  • + """ + + def search_result_href(result: dict) -> str: href = f"/ui/repos/{result['repository_id']}" if result.get("capability_id"): @@ -968,7 +990,7 @@ def render_sources(source_refs: list[dict]) -> str: if not source_refs: return "" sources = ", ".join( - f'{escape(ref["kind"])}:{escape(ref["path"] or ref["name"])}' + f'{escape(ref["kind"])}:{escape(source_ref_label(ref))}' for ref in source_refs[:5] ) if len(source_refs) > 5: @@ -976,6 +998,13 @@ def render_sources(source_refs: list[dict]) -> str: return f"

    {sources}

    " +def source_ref_label(ref: dict) -> str: + label = ref["path"] or ref["name"] + if ref.get("line"): + label = f"{label}:{ref['line']}" + return label + + def render_search_context(result: dict) -> str: details = [] if result.get("ability_name"): diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index c20cd30..275e8aa 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -297,6 +297,9 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path): assert len(second_approval.abilities) == 1 assert ability_map.abilities[0].name == "Review Example Repository Usefulness" assert ability_map.abilities[0].capabilities[0].features[0].location == "app.py" + assert ability_map.abilities[0].capabilities[0].features[0].source_refs + assert ability_map.abilities[0].capabilities[0].features[0].source_refs[0].line == 3 + assert ability_map.abilities[0].capabilities[0].evidence[0].source_refs candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id) assert candidate_graph.abilities[0].status == "approved" diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 3b369fa..13f49ea 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -315,6 +315,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert "Review UI Repo Repository Usefulness" in approved_detail.text assert "Language: Python" in approved_detail.text assert "Framework: FastAPI" in approved_detail.text + assert "interface:app.py:3" in approved_detail.text search_response = client.get("/ui/search", params={"q": "repository"}) assert search_response.status_code == 200