Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions tree_sitter_analyzer/_uml_export_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
aimasteracc marked this conversation as resolved.

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,
Expand Down
37 changes: 29 additions & 8 deletions tree_sitter_analyzer/constraints/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, ()
Comment thread
aimasteracc marked this conversation as resolved.
Outdated

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),
)
94 changes: 8 additions & 86 deletions tree_sitter_analyzer/uml_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading