Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `OPENAI_API_KEY` | OpenAI or OpenAI-compatible APIs | `--backend openai` (local servers accept any non-empty value) |
| `OPENAI_BASE_URL` | OpenAI-compatible server URL (llama.cpp, vLLM, LM Studio, ...) | `--backend openai` (default: `https://api.openai.com/v1`) |
| `OPENAI_MODEL` | Model name for the OpenAI backend — for self-hosted servers, use the model name/alias your server exposes (check its `/v1/models` endpoint), e.g. `LFM2.5-8B-A1B-UD-Q4_K_XL` for llama.cpp | `--backend openai` (default: `gpt-4.1-mini`) |
| `GRAPHIFY_OPENAI_HEADERS_JSON` | Extra non-credential headers for an OpenAI-compatible gateway, as a JSON object of string values | Optional with `--backend openai`; credential and transport-controlled headers are rejected |
| `DEEPSEEK_API_KEY` | DeepSeek backend | `--backend deepseek` |
| `MOONSHOT_API_KEY` | Kimi Code backend | `--backend kimi` |
| `OLLAMA_BASE_URL` | Ollama local inference URL | `--backend ollama` (default: `http://localhost:11434`) |
Expand Down
11 changes: 10 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ def _run_cli() -> None:
print(" affected \"X\" reverse traversal to find nodes impacted by X")
print(" --relation R edge relation to traverse in reverse (repeatable)")
print(" --depth N reverse traversal depth (default 2)")
print(" --production-only exclude test, eval, and docs nodes during traversal")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" god-nodes list the most connected nodes (architectural hubs)")
print(" --top N how many to show (default 10)")
Expand Down Expand Up @@ -702,7 +703,15 @@ def _run_cli() -> None:
# (e.g. "cursor install --help" was silently installing into Cursor, #821).
# Exempt: free-text commands (user string may contain these tokens), and
# "install"/"uninstall" which have their own per-subcommand help handlers.
_FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"}
_FREE_TEXT_CMDS = {
"query",
"explain",
"path",
"affected",
"save-result",
"install",
"uninstall",
}
if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]):
print(f"Run 'graphify --help' for full usage.")
return
Expand Down
223 changes: 156 additions & 67 deletions graphify/affected.py
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",
Expand Down Expand Up @@ -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."""
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionresolve_seed()

18 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.



def resolve_seed(graph: nx.Graph, query: str) -> str | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionresolve_seed()

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
Expand All @@ -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)
Expand All @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionaffected_nodes()

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionaffected_nodes()

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)
Expand All @@ -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.")
Expand Down
Loading