diff --git a/apps/backend/app/intelligence/query_service.py b/apps/backend/app/intelligence/query_service.py index daa52987..da3e3666 100644 --- a/apps/backend/app/intelligence/query_service.py +++ b/apps/backend/app/intelligence/query_service.py @@ -83,7 +83,7 @@ class ArchitectureSnapshotFacts: snapshot: RiSnapshot nodes: list[RiNode] - #: Stable keys of every ``symbol`` node in the snapshot, and nothing else. + #: Stable keys of top-level ``symbol`` definitions used by module inventory. #: The architecture consumer needs to know what each file defines, but #: whole symbol rows are what dominate a large snapshot -- see the bound in #: ``architecture_facts``. Keys alone are short strings and carry the file @@ -391,10 +391,16 @@ def architecture_facts(self, repository_id: str) -> ArchitectureSnapshotFacts | # that symbol is an endpoint of a relationship edge. Symbols dominate a # large snapshot, so excluding the unreferenced ones is the bound that # matters here — while still returning every node the consumer reads. - endpoint_keys = {key for edge in edges for key in (edge.subject_key, edge.object_key)} - node_filter = RiNode.node_kind.in_(ARCHITECTURE_NODE_KINDS) - if endpoint_keys: - node_filter = or_(node_filter, RiNode.stable_key.in_(endpoint_keys)) + # Keep endpoint membership in SQL: a large graph must not become one + # bind parameter per endpoint (SQLite/PostgreSQL parameter limits). + endpoint_filter = ( + RiEdge.snapshot_id == snapshot.snapshot_id, + RiEdge.predicate.in_(ARCHITECTURE_FACT_PREDICATES), + ) + endpoint_keys = ( + select(RiEdge.subject_key).where(*endpoint_filter).union(select(RiEdge.object_key).where(*endpoint_filter)) + ) + node_filter = or_(RiNode.node_kind.in_(ARCHITECTURE_NODE_KINDS), RiNode.stable_key.in_(endpoint_keys)) nodes = list( self.db.scalars( select(RiNode) @@ -402,10 +408,25 @@ def architecture_facts(self, repository_id: str) -> ArchitectureSnapshotFacts | .order_by(RiNode.stable_key, RiNode.id) ).all() ) + # Module inventory only counts top-level definitions. Apply the same + # qualified-name rule as ArchitectureAnalyzer before materializing keys; + # nested methods dominate many real repositories and are never rendered. + delimiter = ( + func.strpos(RiNode.stable_key, "::") + if self.db.get_bind().dialect.name == "postgresql" + else func.instr(RiNode.stable_key, "::") + ) + qualified_name = func.substr(RiNode.stable_key, delimiter + 2) symbol_keys = list( self.db.scalars( select(RiNode.stable_key) - .where(RiNode.snapshot_id == snapshot.snapshot_id, RiNode.node_kind == "symbol") + .where( + RiNode.snapshot_id == snapshot.snapshot_id, + RiNode.node_kind == "symbol", + delimiter > 1, + qualified_name != "", + qualified_name.not_like("%.%"), + ) .order_by(RiNode.stable_key) ).all() ) diff --git a/apps/backend/tests/test_architecture_relationships.py b/apps/backend/tests/test_architecture_relationships.py index 337f1b96..908f3533 100644 --- a/apps/backend/tests/test_architecture_relationships.py +++ b/apps/backend/tests/test_architecture_relationships.py @@ -582,3 +582,55 @@ def test_repository_furniture_does_not_become_an_architecture_module(auth_client # And nothing is left describing itself by its own path. assert not any("derived from repository intelligence" in node["description"] for node in architecture["nodes"]) assert not any("Owns unknown concerns" in node["responsibilities"] for node in architecture["nodes"]) + + +def test_architecture_endpoint_membership_uses_a_bounded_sql_subquery(auth_client): + from sqlalchemy import event + from app.core.database import SessionLocal + + sources = { + "src/alpha.ts": b"import { beta } from './beta';\nexport const alpha = beta;\n", + "src/beta.ts": b"export const beta = 1;\n", + } + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + observed = [] + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + engine = session.get_bind() + + def capture(connection, cursor, statement, parameters, context, executemany): + if "ri_nodes" in statement and "UNION" in statement: + observed.append((statement, len(parameters))) + + event.listen(engine, "before_cursor_execute", capture) + try: + facts = SnapshotQueryService(session, record.owner_id).architecture_facts(record.id) + finally: + event.remove(engine, "before_cursor_execute", capture) + assert facts is not None + assert observed + assert max(count for _, count in observed) < 50 + endpoint_keys = {key for edge in facts.edges for key in (edge.subject_key, edge.object_key)} + assert endpoint_keys <= {node.stable_key for node in facts.nodes} + + +def test_architecture_inventory_does_not_materialize_nested_method_keys(auth_client): + from app.core.database import SessionLocal + + source = ( + "export class LargeClass {\n" + + "".join(f" method_{index}() {{ return {index}; }}\n" for index in range(500)) + + "}\n" + ) + sources = {"src/large.ts": source.encode()} + repository = _upload(auth_client, sources) + _persist_snapshot(repository["id"], sources) + with SessionLocal() as session: + record = session.get(RepositoryRecord, repository["id"]) + facts = SnapshotQueryService(session, record.owner_id).architecture_facts(record.id) + assert facts is not None + assert facts.symbol_keys == ["src/large.ts::LargeClass"] + response = auth_client.get(f"/analysis/{repository['id']}/architecture") + assert response.status_code == 200 + assert "Defines 1 symbol: LargeClass" in response.text