From 80dc8f7263f9a172179c2695470b30d897b0aa68 Mon Sep 17 00:00:00 2001 From: "aisheng.yu" Date: Wed, 5 Aug 2026 06:51:01 +0000 Subject: [PATCH 1/3] refactor(health): resolve current auto-sprint hotspot --- tree_sitter_analyzer/_uml_export_builders.py | 98 +++++++++++++++++++ tree_sitter_analyzer/constraints/evaluator.py | 37 +++++-- tree_sitter_analyzer/uml_export.py | 94 ++---------------- 3 files changed, 135 insertions(+), 94 deletions(-) diff --git a/tree_sitter_analyzer/_uml_export_builders.py b/tree_sitter_analyzer/_uml_export_builders.py index 8c86a73b9..cb8280b1c 100644 --- a/tree_sitter_analyzer/_uml_export_builders.py +++ b/tree_sitter_analyzer/_uml_export_builders.py @@ -11,6 +11,104 @@ from .uml_export import UMLDiagram, UMLEdge +def build_class_diagram( + classes: list[dict[str, Any]], + *, + max_edges: int, + include_external_bases: bool, + file_path: str | None, + class_name: str | None, + include_tests: bool, +) -> UMLDiagram: + """Build the class-diagram payload from an already-loaded hierarchy.""" + from . import uml_export as api + from .utils.test_detection import is_test_file + + if not include_tests: + classes = [item for item in classes if not is_test_file(item.get("file"))] + internal_names = {item.get("name", "") for item in classes if item.get("name")} + scope = ( + "class_neighbourhood" + if class_name is not None + else "file" + if file_path is not None + else "whole_project" + ) + nodes: set[str] = set() + production_edges: list[api.UMLEdge] = [] + test_edges: list[api.UMLEdge] = [] + for item in classes: + child = item.get("name", "") + if not child or ( + scope == "file" and not api._file_matches(item.get("file", ""), file_path) + ): + continue + if scope == "class_neighbourhood" and not api._is_neighbourhood( + child, item, class_name, classes + ): + continue + nodes.add(child) + for parent_text in item.get("parents") or []: + parent = str(parent_text).rsplit(".", 1)[-1] + if parent not in internal_names and not ( + include_external_bases and parent in api._EXTERNAL_BASES + ): + continue + nodes.add(parent) + edge = api.UMLEdge(parent, child, "inherits") + target = ( + test_edges + if include_tests + and scope == "whole_project" + and is_test_file(item.get("file")) + else production_edges + ) + target.append(edge) + + edges, truncated = _prioritized_class_edges(production_edges, test_edges, max_edges) + rendered_nodes = ( + sorted({name for edge in edges for name in (edge.source, edge.target)}) + or sorted(nodes)[:max_edges] + ) + return api.UMLDiagram( + diagram_type="class", + mermaid_type="classDiagram", + mermaid=api.render_class_mermaid(rendered_nodes, edges, truncated=truncated), + nodes=rendered_nodes, + edges=edges, + truncated=truncated, + metadata={ + "source": "class_hierarchy", + "scope": scope, + **( + {"not_found": True} + if class_name is not None and class_name not in internal_names + else {} + ), + }, + ) + + +def _prioritized_class_edges( + production_edges: list[UMLEdge], test_edges: list[UMLEdge], max_edges: int +) -> tuple[list[UMLEdge], bool]: + from . import uml_export as api + + if not test_edges: + edges, truncated = api._clamp_edges(production_edges, max_edges) + return api._dedupe_edges_by_signature(edges), truncated + edges, production_truncated = api._clamp_edges(production_edges, max_edges) + remaining = max_edges - len(edges) + if remaining <= 0: + return api._dedupe_edges_by_signature(edges), ( + production_truncated or bool(test_edges) + ) + selected_tests, tests_truncated = api._clamp_edges(test_edges, remaining) + return api._dedupe_edges_by_signature(edges + selected_tests), ( + production_truncated or tests_truncated + ) + + def build_sequence_diagram( exporter: Any, source: str, diff --git a/tree_sitter_analyzer/constraints/evaluator.py b/tree_sitter_analyzer/constraints/evaluator.py index 0f8f16003..1e5c56454 100644 --- a/tree_sitter_analyzer/constraints/evaluator.py +++ b/tree_sitter_analyzer/constraints/evaluator.py @@ -241,10 +241,11 @@ def _build_select_query( resolved — preserving the legacy ``CASE WHEN callee_resolved_file != ''`` behaviour. - Rules with a literal caller prefix cannot match rows outside that prefix. - Push that necessary condition into SQLite so the Python hot loop only sees - plausible candidates. ``instr`` is case-sensitive and treats glob-special - characters literally, preserving the regex matcher's path semantics. + Rules with literal caller or callee prefixes cannot match rows outside + those prefixes. Push both necessary conditions into SQLite so the Python + hot loop only sees plausible candidates. ``instr`` is case-sensitive and + treats glob-special characters literally, preserving the regex matcher's + path semantics. If any rule has no literal prefix, the query must retain every CALLS row because that rule may match anywhere. The ``db_conn`` argument is retained @@ -261,9 +262,29 @@ def _build_select_query( f"{callee_expr} AS callee_file " # nosec B608 — callee_expr is constructed from internal constants only "FROM edges WHERE kind = 'calls'" ) - prefixes = tuple(dict.fromkeys(cc.from_prefix for cc in compiled)) - if not prefixes or "" in prefixes or len(prefixes) > _MAX_SQL_PREFIX_FILTERS: + from_prefixes = tuple(dict.fromkeys(cc.from_prefix for cc in compiled)) + to_prefixes = tuple(dict.fromkeys(cc.to_prefix for cc in compiled)) + if ( + len(from_prefixes) > _MAX_SQL_PREFIX_FILTERS + or len(to_prefixes) > _MAX_SQL_PREFIX_FILTERS + ): return select_sql, () - prefix_filter = " OR ".join("instr(file_path, ?) = 1" for _ in prefixes) - return f"{select_sql} AND ({prefix_filter})", prefixes + filters: list[str] = [] + params: list[str] = [] + if from_prefixes and "" not in from_prefixes: + filters.append( + " OR ".join("instr(file_path, ?) = 1" for _ in from_prefixes) + ) + params.extend(from_prefixes) + if to_prefixes and "" not in to_prefixes: + filters.append( + " OR ".join(f"instr({callee_expr}, ?) = 1" for _ in to_prefixes) + ) + params.extend(to_prefixes) + if not filters: + return select_sql, () + return ( + f"{select_sql} AND " + " AND ".join(f"({item})" for item in filters), + tuple(params), + ) diff --git a/tree_sitter_analyzer/uml_export.py b/tree_sitter_analyzer/uml_export.py index 8dd934cca..1d6e7b7d4 100644 --- a/tree_sitter_analyzer/uml_export.py +++ b/tree_sitter_analyzer/uml_export.py @@ -436,93 +436,15 @@ def class_diagram( if should_close: cache.close() - # P1-C: strip test-corpus classes from whole-project view by default - if not include_tests: - classes = [c for c in classes if not is_test_file(c.get("file"))] - - internal_names = {c.get("name", "") for c in classes if c.get("name")} - raw_edges: list[UMLEdge] = [] - # Bug #789: track test edges separately for whole-project include_tests view - raw_test_edges: list[UMLEdge] = [] - nodes: set[str] = set() - - # Determine scope label for metadata - if class_name is not None: - scope = "class_neighbourhood" - elif file_path is not None: - scope = "file" - else: - scope = "whole_project" - - # P2-1: an unknown class_name must be distinguishable from a known - # class with an empty neighbourhood — agents can't tell them apart - # from an empty diagram alone. - not_found = class_name is not None and class_name not in internal_names + from ._uml_export_builders import build_class_diagram - for cls in classes: - child = cls.get("name", "") - if not child: - continue - - # P1-A scoping: apply file_path / class_name filter - if scope == "file": - cls_file = cls.get("file", "") - if not _file_matches(cls_file, file_path): - continue - elif scope == "class_neighbourhood": - # Include: the named class itself, its direct parents, and - # classes that list it as a direct parent (subclasses one hop) - if not _is_neighbourhood(child, cls, class_name, classes): - continue - - nodes.add(child) - child_is_test = is_test_file(cls.get("file")) - for parent_text in cls.get("parents") or []: - parent = str(parent_text).rsplit(".", 1)[-1] - if parent in internal_names or ( - include_external_bases and parent in _EXTERNAL_BASES - ): - nodes.add(parent) - edge = UMLEdge(parent, child, "inherits") - # Bug #789: when include_tests=True in whole-project view, - # separate test-child edges so production edges are prioritised - # first during clamping. Test classes fill remaining slots. - if include_tests and scope == "whole_project" and child_is_test: - raw_test_edges.append(edge) - else: - raw_edges.append(edge) - - # Bug #789: production edges first; test edges fill remaining capacity. - if raw_test_edges: - prod_edges, prod_truncated = _clamp_edges(raw_edges, max_edges) - remaining = max_edges - len(prod_edges) - if remaining > 0: - test_edges, test_truncated = _clamp_edges(raw_test_edges, remaining) - edges = _dedupe_edges_by_signature(prod_edges + test_edges) - truncated = prod_truncated or test_truncated - else: - edges = _dedupe_edges_by_signature(prod_edges) - truncated = prod_truncated or bool(raw_test_edges) - else: - edges, truncated = _clamp_edges(raw_edges, max_edges) - edges = _dedupe_edges_by_signature(edges) - rendered_nodes = sorted( - {n for edge in edges for n in (edge.source, edge.target)} - ) - if not rendered_nodes: - rendered_nodes = sorted(nodes)[:max_edges] - return UMLDiagram( - diagram_type="class", - mermaid_type="classDiagram", - mermaid=render_class_mermaid(rendered_nodes, edges, truncated=truncated), - nodes=rendered_nodes, - edges=edges, - truncated=truncated, - metadata={ - "source": "class_hierarchy", - "scope": scope, - **({"not_found": True} if not_found else {}), - }, + return build_class_diagram( + classes, + max_edges=max_edges, + include_external_bases=include_external_bases, + file_path=file_path, + class_name=class_name, + include_tests=include_tests, ) def package_diagram( From bd57e37c6e7633ff87fb9cdeb0d21b6f19b2401f Mon Sep 17 00:00:00 2001 From: "aisheng.yu" Date: Wed, 5 Aug 2026 06:53:42 +0000 Subject: [PATCH 2/3] fix(lint): remove stale UML import --- tree_sitter_analyzer/uml_export.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tree_sitter_analyzer/uml_export.py b/tree_sitter_analyzer/uml_export.py index 1d6e7b7d4..27f8ffeb0 100644 --- a/tree_sitter_analyzer/uml_export.py +++ b/tree_sitter_analyzer/uml_export.py @@ -13,7 +13,6 @@ from .call_path import CallPathFinder # noqa: F401 - public monkeypatch seam from .class_hierarchy import ClassHierarchy from .import_graph import ImportGraph -from .utils.test_detection import is_test_file _EXTERNAL_BASES = frozenset( { From b3a1fdb1833cb47d1e71e512ac7417c4c16da519 Mon Sep 17 00:00:00 2001 From: "aisheng.yu" Date: Wed, 5 Aug 2026 07:14:17 +0000 Subject: [PATCH 3/3] fix(constraints): preserve independent SQL prefix filters --- tests/unit/test_constraint_dsl.py | 78 ++++++++++++++++++- tests/unit/test_uml_export.py | 21 +++++ tree_sitter_analyzer/constraints/evaluator.py | 26 +++---- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/tests/unit/test_constraint_dsl.py b/tests/unit/test_constraint_dsl.py index 1ad77752a..7e9e38c91 100644 --- a/tests/unit/test_constraint_dsl.py +++ b/tests/unit/test_constraint_dsl.py @@ -590,8 +590,10 @@ def test_evaluate_keeps_rows_when_from_glob_has_no_literal_prefix( assert len(violations) == 1 assert violations[0].rule_id == "wildcard-caller" - def test_select_query_falls_back_for_large_prefix_sets(self) -> None: - """Large rule sets avoid SQLite expression and parameter limits.""" + def test_select_query_keeps_callee_filter_when_callers_exceed_limit( + self, + ) -> None: + """PR #1225: an oversized caller set must not discard the callee filter.""" from tree_sitter_analyzer.constraints.evaluator import ( _MAX_SQL_PREFIX_FILTERS, _build_select_query, @@ -611,6 +613,78 @@ def test_select_query_falls_back_for_large_prefix_sets(self) -> None: for index in range(_MAX_SQL_PREFIX_FILTERS + 1) ] + conn = sqlite3.connect(":memory:") + try: + select_sql, params = _build_select_query( + conn, + compile_constraints(constraints), + ) + finally: + conn.close() + + assert select_sql.count("instr(file_path, ?) = 1") == 0 + assert select_sql.count("callee_resolved_file") == 4 + assert params == ("forbidden/",) + + def test_select_query_keeps_caller_filter_when_callees_exceed_limit( + self, + ) -> None: + """PR #1225: an oversized callee set must not discard the caller filter.""" + from tree_sitter_analyzer.constraints.evaluator import ( + _MAX_SQL_PREFIX_FILTERS, + _build_select_query, + ) + from tree_sitter_analyzer.constraints.parser import compile_constraints + from tree_sitter_analyzer.constraints.schema import Constraint + + constraints = [ + Constraint( + id=f"rule-{index}", + severity="error", + rule="forbid", + from_glob="tree_sitter_analyzer/mcp/**", + to_glob=f"forbidden-{index}/**", + reason="test independent SQL filter bound", + ) + for index in range(_MAX_SQL_PREFIX_FILTERS + 1) + ] + + conn = sqlite3.connect(":memory:") + try: + select_sql, params = _build_select_query( + conn, + compile_constraints(constraints), + ) + finally: + conn.close() + + assert select_sql.count("instr(file_path, ?) = 1") == 1 + assert select_sql.count("callee_resolved_file") == 2 + assert params == ("tree_sitter_analyzer/mcp/",) + + def test_select_query_falls_back_when_both_prefix_sets_exceed_limit( + self, + ) -> None: + """PR #1225: two oversized prefix sets retain the unfiltered fallback.""" + from tree_sitter_analyzer.constraints.evaluator import ( + _MAX_SQL_PREFIX_FILTERS, + _build_select_query, + ) + from tree_sitter_analyzer.constraints.parser import compile_constraints + from tree_sitter_analyzer.constraints.schema import Constraint + + constraints = [ + Constraint( + id=f"rule-{index}", + severity="error", + rule="forbid", + from_glob=f"package-{index}/**", + to_glob=f"forbidden-{index}/**", + reason="test SQL filter fallback", + ) + for index in range(_MAX_SQL_PREFIX_FILTERS + 1) + ] + conn = sqlite3.connect(":memory:") try: select_sql, params = _build_select_query( diff --git a/tests/unit/test_uml_export.py b/tests/unit/test_uml_export.py index a8f4c8d98..ace79919e 100644 --- a/tests/unit/test_uml_export.py +++ b/tests/unit/test_uml_export.py @@ -7,6 +7,7 @@ import pytest from tree_sitter_analyzer import uml_export +from tree_sitter_analyzer._uml_export_builders import _prioritized_class_edges from tree_sitter_analyzer.uml_export import ( UMLEdge, UMLExporter, @@ -47,6 +48,26 @@ def test_render_flowchart_mermaid_supports_unlabeled_edges() -> None: assert "cli --> core" in mermaid +def test_prioritized_class_edges_reserves_remaining_capacity_for_tests() -> None: + production = [UMLEdge("Base", "Production", "inherits")] + tests = [UMLEdge("Base", "TestProduction", "inherits")] + + edges, truncated = _prioritized_class_edges(production, tests, max_edges=2) + + assert edges == production + tests + assert truncated is False + + +def test_prioritized_class_edges_drops_tests_when_production_fills_limit() -> None: + production = [UMLEdge("Base", "Production", "inherits")] + tests = [UMLEdge("Base", "TestProduction", "inherits")] + + edges, truncated = _prioritized_class_edges(production, tests, max_edges=1) + + assert edges == production + assert truncated is True + + def test_render_sequence_mermaid_uses_first_call_path() -> None: mermaid = render_sequence_mermaid( [ diff --git a/tree_sitter_analyzer/constraints/evaluator.py b/tree_sitter_analyzer/constraints/evaluator.py index 1e5c56454..0d83938fe 100644 --- a/tree_sitter_analyzer/constraints/evaluator.py +++ b/tree_sitter_analyzer/constraints/evaluator.py @@ -264,23 +264,21 @@ def _build_select_query( ) from_prefixes = tuple(dict.fromkeys(cc.from_prefix for cc in compiled)) to_prefixes = tuple(dict.fromkeys(cc.to_prefix for cc in compiled)) - if ( - len(from_prefixes) > _MAX_SQL_PREFIX_FILTERS - or len(to_prefixes) > _MAX_SQL_PREFIX_FILTERS - ): - return select_sql, () - filters: list[str] = [] params: list[str] = [] - if from_prefixes and "" not in from_prefixes: - filters.append( - " OR ".join("instr(file_path, ?) = 1" for _ in from_prefixes) - ) + if ( + from_prefixes + and "" not in from_prefixes + and len(from_prefixes) <= _MAX_SQL_PREFIX_FILTERS + ): + filters.append(" OR ".join("instr(file_path, ?) = 1" for _ in from_prefixes)) params.extend(from_prefixes) - if to_prefixes and "" not in to_prefixes: - filters.append( - " OR ".join(f"instr({callee_expr}, ?) = 1" for _ in to_prefixes) - ) + if ( + to_prefixes + and "" not in to_prefixes + and len(to_prefixes) <= _MAX_SQL_PREFIX_FILTERS + ): + filters.append(" OR ".join(f"instr({callee_expr}, ?) = 1" for _ in to_prefixes)) params.extend(to_prefixes) if not filters: return select_sql, ()