-
-
Notifications
You must be signed in to change notification settings - Fork 10.4k
feat: add governed gateway routing and production graph queries #2735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v8
Are you sure you want to change the base?
Changes from all commits
9e7147f
28150b2
cef540e
c2ab7b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,16 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from collections import deque | ||
| from collections.abc import Hashable | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Iterable | ||
| from pathlib import Path, PurePosixPath | ||
| from typing import Iterable, cast | ||
| import unicodedata | ||
|
|
||
| import networkx as nx | ||
|
|
||
| from graphify.paths import _is_test_path | ||
|
|
||
|
|
||
| DEFAULT_AFFECTED_RELATIONS = ( | ||
| "calls", | ||
|
|
@@ -94,7 +97,7 @@ def _as_repo_relative(query: str) -> str: | |
|
|
||
| def _prefer_file_node( | ||
| graph: nx.Graph, | ||
| node_ids: list[str], | ||
| node_ids: list[Hashable], | ||
| query: str, | ||
| ) -> str | None: | ||
| """Return the file-level node when a source_file query matches many nodes.""" | ||
|
|
@@ -106,27 +109,85 @@ def _prefer_file_node( | |
| and _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename | ||
| ] | ||
| if len(exact_file_nodes) == 1: | ||
| return exact_file_nodes[0] | ||
| return str(exact_file_nodes[0]) | ||
|
|
||
| l1_nodes = [ | ||
| node_id | ||
| for node_id in node_ids | ||
| if str(graph.nodes[node_id].get("source_location", "")) == "L1" | ||
| ] | ||
| if len(l1_nodes) == 1: | ||
| return l1_nodes[0] | ||
| return str(l1_nodes[0]) | ||
|
|
||
| basename_nodes = [ | ||
| node_id | ||
| for node_id in node_ids | ||
| if _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename | ||
| ] | ||
| if len(basename_nodes) == 1: | ||
| return basename_nodes[0] | ||
| return str(basename_nodes[0]) | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| _NON_PRODUCTION_DIR_SEGMENTS = frozenset({"docs", "eval"}) | ||
| _GraphEdge = tuple[object, object, dict] | ||
|
|
||
|
|
||
| def _is_production_source(path: str) -> bool: | ||
| """Return whether a path is production code for affected traversal. | ||
|
|
||
| Tests use the shared repository classifier. Whole ``docs`` and ``eval`` | ||
| directory segments are also excluded. Segment matching is conservative: | ||
| names such as ``contest``, ``latest``, and ``document_service`` remain | ||
| production paths. | ||
| """ | ||
| if not path or _is_test_path(path): | ||
| return False | ||
| normalized = str(path).replace("\\", "/") | ||
| segments = (segment.casefold() for segment in PurePosixPath(normalized).parts) | ||
| return not any(segment in _NON_PRODUCTION_DIR_SEGMENTS for segment in segments) | ||
|
|
||
|
|
||
| def _unique_or_production_match( | ||
| graph: nx.Graph, node_ids: list[Hashable] | ||
| ) -> str | None: | ||
| """Resolve uniquely, preferring one proven production node.""" | ||
| if len(node_ids) == 1: | ||
| return str(node_ids[0]) | ||
| production_nodes = [ | ||
| node_id | ||
| for node_id in node_ids | ||
| if _is_production_source(str(graph.nodes[node_id].get("source_file", ""))) | ||
| ] | ||
| if len(production_nodes) == 1: | ||
| return str(production_nodes[0]) | ||
| return None | ||
|
|
||
|
|
||
| def _label_matches(graph: nx.Graph, query: str, *, bare: bool) -> list[Hashable]: | ||
| normalize = _bare_name if bare else _normalize_label | ||
| normalized_query = normalize(query) | ||
| return [ | ||
| node_id | ||
| for node_id, data in graph.nodes(data=True) | ||
| if normalize(str(data.get("label", ""))) == normalized_query | ||
| ] | ||
|
|
||
|
|
||
| def _resolve_source_match(graph: nx.Graph, query: str, query_lower: str) -> str | None: | ||
| repo_relative_query = _as_repo_relative(query) | ||
| query_path = _normalize_label(repo_relative_query) | ||
| matches = [ | ||
| node_id | ||
| for node_id, data in graph.nodes(data=True) | ||
| if _normalize_label(str(data.get("source_file", ""))) in (query_lower, query_path) | ||
| ] | ||
| if len(matches) == 1: | ||
| return str(matches[0]) | ||
| return _prefer_file_node(graph, matches, repo_relative_query) if matches else None | ||
|
|
||
|
|
||
| def resolve_seed(graph: nx.Graph, query: str) -> str | None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
19 callers depend on it (afferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| # A trailing path separator must not change a source-file match — serve's | ||
| # _find_node tokenizes the path (which drops it), so strip it here for parity | ||
|
|
@@ -135,40 +196,24 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None: | |
| if query in graph: | ||
| return query | ||
| query_lower = _normalize_label(query) | ||
| exact_label_matches = [ | ||
| str(node_id) | ||
| for node_id, data in graph.nodes(data=True) | ||
| if _normalize_label(str(data.get("label", ""))) == query_lower | ||
| ] | ||
| if len(exact_label_matches) == 1: | ||
| return exact_label_matches[0] | ||
| exact_label_match = _unique_or_production_match( | ||
| graph, _label_matches(graph, query_lower, bare=False) | ||
| ) | ||
| if exact_label_match is not None: | ||
| return exact_label_match | ||
| # Callable labels are decorated ("name()"), so a bare "name" query falls | ||
| # through exact matching and then ties with any "name*" sibling in the | ||
| # contains pass. Match on the undecorated name before giving up. | ||
| query_bare = _bare_name(query_lower) | ||
| bare_name_matches = [ | ||
| str(node_id) | ||
| for node_id, data in graph.nodes(data=True) | ||
| if _bare_name(str(data.get("label", ""))) == query_bare | ||
| ] | ||
| if len(bare_name_matches) == 1: | ||
| return bare_name_matches[0] | ||
| bare_name_match = _unique_or_production_match( | ||
| graph, _label_matches(graph, query_lower, bare=True) | ||
| ) | ||
| if bare_name_match is not None: | ||
| return bare_name_match | ||
| # Compare paths in repo-relative form. Only this branch is path-shaped; the | ||
| # label branches above keep the query verbatim. | ||
| query_path = _normalize_label(_as_repo_relative(query)) | ||
| exact_source_matches = [ | ||
| str(node_id) | ||
| for node_id, data in graph.nodes(data=True) | ||
| if _normalize_label(str(data.get("source_file", ""))) in (query_lower, query_path) | ||
| ] | ||
| if len(exact_source_matches) == 1: | ||
| return exact_source_matches[0] | ||
| if exact_source_matches: | ||
| preferred_file_node = _prefer_file_node( | ||
| graph, exact_source_matches, _as_repo_relative(query) | ||
| ) | ||
| if preferred_file_node is not None: | ||
| return preferred_file_node | ||
| source_match = _resolve_source_match(graph, query, query_lower) | ||
| if source_match is not None: | ||
| return source_match | ||
| contains_matches = [ | ||
| str(node_id) | ||
| for node_id, data in graph.nodes(data=True) | ||
|
|
@@ -179,66 +224,97 @@ def resolve_seed(graph: nx.Graph, query: str) -> str | None: | |
| return None | ||
|
|
||
|
|
||
| def _is_production_node(graph: nx.Graph, node_id: str) -> bool: | ||
| source_file = str(graph.nodes[node_id].get("source_file", "")) | ||
| return _is_production_source(source_file) | ||
|
|
||
|
|
||
| def _out_edges(graph: nx.Graph, node_id: str) -> Iterable[_GraphEdge]: | ||
| edge_reader = getattr(graph, "out_edges", None) | ||
| if callable(edge_reader): | ||
| return cast(Iterable[_GraphEdge], edge_reader(node_id, data=True)) | ||
| return ( | ||
| (source, target, data) | ||
| for source, target, data in graph.edges(data=True) | ||
| if source == node_id | ||
| ) | ||
|
|
||
|
|
||
| def _in_edges(graph: nx.Graph, node_id: str) -> Iterable[_GraphEdge]: | ||
| edge_reader = getattr(graph, "in_edges", None) | ||
| if callable(edge_reader): | ||
| return cast(Iterable[_GraphEdge], edge_reader(node_id, data=True)) | ||
| return ( | ||
| (source, target, data) | ||
| for source, target, data in graph.edges(data=True) | ||
| if target == node_id | ||
| ) | ||
|
|
||
|
|
||
| def _seed_members( | ||
| graph: nx.Graph, | ||
| seed: str, | ||
| seen: set[str], | ||
| queue: deque[tuple[str, int]], | ||
| *, | ||
| production_only: bool, | ||
| ) -> None: | ||
| """Add root members as traversal-only seeds, subject to path policy.""" | ||
| for _source, member, data in _out_edges(graph, seed): | ||
| if str(data.get("relation", "")) not in ("method", "contains"): | ||
| continue | ||
| member_id = str(member) | ||
| if member_id in seen: | ||
| continue | ||
| if production_only and not _is_production_node(graph, member_id): | ||
| continue | ||
| seen.add(member_id) | ||
| queue.append((member_id, 0)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
fans out to 6 callees (efferent coupling); 28 callers depend on it (afferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
|
|
||
|
|
||
| def affected_nodes( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
fans out to 6 callees (efferent coupling); 28 callers depend on it (afferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| graph: nx.Graph, | ||
| seed: str, | ||
| *, | ||
| relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS, | ||
| depth: int = 2, | ||
| production_only: bool = False, | ||
| ) -> list[AffectedHit]: | ||
| """Find reverse dependencies, optionally traversing production code only.""" | ||
| relation_set = set(relations) | ||
| seen = {seed} | ||
| queue: deque[tuple[str, int]] = deque([(seed, 0)]) | ||
| hits: list[AffectedHit] = [] | ||
|
|
||
| # #1669: seed the reverse walk with the root's own member nodes (one outward | ||
| # `method`/`contains` hop). A caller can bind to a class's method node rather | ||
| # than the class node itself (e.g. `Service.call` resolves to the `def | ||
| # self.call` node, #1634), so those callers are unreachable from the class | ||
| # otherwise. The member nodes are seeds only (not reported as hits), and | ||
| # `method`/`contains` stay out of the general relation-filtered walk, so this | ||
| # adds no forward noise anywhere else. | ||
| if hasattr(graph, "out_edges"): | ||
| member_edges = graph.out_edges(seed, data=True) | ||
| else: | ||
| member_edges = ( | ||
| (s, t, d) for s, t, d in graph.edges(data=True) if s == seed | ||
| ) | ||
| for _s, member, data in member_edges: | ||
| if str(data.get("relation", "")) not in ("method", "contains"): | ||
| continue | ||
| member = str(member) | ||
| if member not in seen: | ||
| seen.add(member) | ||
| queue.append((member, 0)) | ||
| # Seed the reverse walk with root members (#1669); members are not reported. | ||
| _seed_members(graph, seed, seen, queue, production_only=production_only) | ||
|
|
||
| while queue: | ||
| current, current_depth = queue.popleft() | ||
| if current_depth >= depth: | ||
| continue | ||
| if hasattr(graph, "in_edges"): | ||
| incoming = graph.in_edges(current, data=True) | ||
| else: | ||
| incoming = ( | ||
| (source, target, data) | ||
| for source, target, data in graph.edges(data=True) | ||
| if target == current | ||
| ) | ||
| for source, _target, data in incoming: | ||
| for source, _target, data in _in_edges(graph, current): | ||
| relation = str(data.get("relation", "")) | ||
| if relation not in relation_set: | ||
| continue | ||
| source = str(source) | ||
| if source in seen: | ||
| continue | ||
| if production_only and not _is_production_node(graph, source): | ||
| continue | ||
| via_file = str(data.get("source_file") or "") | ||
| if production_only and via_file and not _is_production_source(via_file): | ||
| continue | ||
| seen.add(source) | ||
| # Carry the matched edge's location (taken from the SAME edge dict | ||
| # whose relation passed the filter, so relation and location stay | ||
| # consistent) — that is the call/import/reference site in `source`'s | ||
| # own file, which is where the user should click (#BUG1). | ||
| hit = AffectedHit( | ||
| source, current_depth + 1, relation, | ||
| via_file=str(data.get("source_file") or "") or None, | ||
| source, | ||
| current_depth + 1, | ||
| relation, | ||
| via_file=via_file or None, | ||
| via_location=str(data.get("source_location") or "") or None, | ||
| ) | ||
| hits.append(hit) | ||
|
|
@@ -253,17 +329,30 @@ def format_affected( | |
| *, | ||
| relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS, | ||
| depth: int = 2, | ||
| production_only: bool = False, | ||
| ) -> str: | ||
| """Render affected nodes, optionally excluding non-production traversal.""" | ||
| relation_list = tuple(relations) | ||
| seed = resolve_seed(graph, query) | ||
| if seed is None: | ||
| return f"No unique node match for {query}" | ||
|
|
||
| hits = affected_nodes(graph, seed, relations=relation_list, depth=depth) | ||
| hits = affected_nodes( | ||
| graph, | ||
| seed, | ||
| relations=relation_list, | ||
| depth=depth, | ||
| production_only=production_only, | ||
| ) | ||
| lines = [ | ||
| f"Affected nodes for {_node_label(graph, seed)}", | ||
| f"Relations: {', '.join(relation_list)}", | ||
| f"Depth: {depth}", | ||
| ( | ||
| "Scope: production only (tests, eval, docs excluded)" | ||
| if production_only | ||
| else "Scope: all graph nodes" | ||
| ), | ||
| ] | ||
| if not hits: | ||
| lines.append("No affected nodes found.") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolve_seed()18 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.