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
30 changes: 26 additions & 4 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ def dispatch_command(cmd: str) -> None:
sys.exit(1)
elif cmd == "query":
if len(sys.argv) < 3:
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--budget N] [--graph path]", file=sys.stderr)
print("Usage: graphify query \"<question>\" [--dfs] [--context C] [--budget N] [--graph path] [--rationale]", file=sys.stderr)
sys.exit(1)
from graphify.serve import _query_graph_text
from graphify.security import sanitize_label
Expand All @@ -954,6 +954,7 @@ def dispatch_command(cmd: str) -> None:
budget = 2000
graph_path = _default_graph_path()
context_filters: list[str] = []
include_rationale = False
args = sys.argv[3:]
i = 0
while i < len(args):
Expand All @@ -980,6 +981,9 @@ def dispatch_command(cmd: str) -> None:
elif args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
i += 2
elif args[i] == "--rationale":
include_rationale = True
i += 1
else:
i += 1
gp = Path(graph_path).resolve()
Expand Down Expand Up @@ -1047,6 +1051,7 @@ def dispatch_command(cmd: str) -> None:
depth=2,
token_budget=budget,
context_filters=context_filters,
include_rationale=include_rationale,
)
querylog.log_query(
kind="query",
Expand Down Expand Up @@ -1440,17 +1445,25 @@ def dispatch_command(cmd: str) -> None:

elif cmd == "explain":
if len(sys.argv) < 3:
print('Usage: graphify explain "<node>" [--graph path]', file=sys.stderr)
print('Usage: graphify explain "<node>" [--graph path] [--rationale]', file=sys.stderr)
sys.exit(1)
from graphify.serve import _find_node, find_node_ambiguity
from networkx.readwrite import json_graph

label = sys.argv[2]
graph_path = _default_graph_path()
include_rationale = False
args = sys.argv[3:]
for i, a in enumerate(args):
if a == "--graph" and i + 1 < len(args):
i = 0
while i < len(args):
if args[i] == "--graph" and i + 1 < len(args):
graph_path = args[i + 1]
i += 2
elif args[i] == "--rationale":
include_rationale = True
i += 1
else:
i += 1
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
Expand Down Expand Up @@ -1486,6 +1499,15 @@ def dispatch_command(cmd: str) -> None:
)
print(f" Type: {d.get('file_type', '')}")
print(f" Community: {d.get('community_name') or d.get('community', '')}")
if include_rationale:
from graphify.security import sanitize_rationale, MAX_DETAIL_RATIONALE_CHARS
rat = sanitize_rationale(
d.get("rationale"),
single_line=False,
max_chars=MAX_DETAIL_RATIONALE_CHARS,
)
if rat:
print(f" Rationale: {rat}")
# Work-memory overlay: a derived experiential hint from `graphify reflect`,
# merged in display-only from the .graphify_learning.json sidecar next to
# graph.json. No line when the node has no overlay entry.
Expand Down
64 changes: 63 additions & 1 deletion graphify/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ def check_graph_file_size_cap(path: Path) -> None:
# Label sanitisation (mirrors code-review-graph's _sanitize_name pattern)
# ---------------------------------------------------------------------------

_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
_MAX_LABEL_LEN = 256


Expand All @@ -405,6 +405,68 @@ def sanitize_label(text: str | None) -> str:
return text


MAX_QUERY_RATIONALE_CHARS = 512
MAX_DETAIL_RATIONALE_CHARS = 2048

_CONTROL_CHAR_NO_NL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")


def sanitize_rationale(
text: str | None,
*,
single_line: bool = False,
max_chars: int | None = None,
) -> str:
"""Sanitize and optionally truncate a node rationale string.

In compact (single-line) mode:
- Normalizes all whitespace runs (newlines, tabs, spaces) to single spaces
- Strips control characters
- Caps output at `max_chars` (default MAX_QUERY_RATIONALE_CHARS = 512)
- Adds an explicit '...' truncation marker when capped

In detail mode:
- Preserves newlines/paragraphs while stripping unsafe control characters
- Caps output at `max_chars` (default MAX_DETAIL_RATIONALE_CHARS = 2048)
- Adds an explicit '...' truncation marker when capped

Returns empty string if text is None, empty, or whitespace-only.
"""
if text is None:
return ""
raw = str(text)
if not raw.strip():
return ""

if max_chars is None:
max_chars = MAX_QUERY_RATIONALE_CHARS if single_line else MAX_DETAIL_RATIONALE_CHARS

if single_line:
# Collapse all whitespace sequences to a single space, then strip control chars
flat = " ".join(raw.split())
cleaned = _CONTROL_CHAR_RE.sub("", flat).strip()
if not cleaned:
return ""
if len(cleaned) > max_chars:
marker = "..."
if max_chars <= len(marker):
return cleaned[:max_chars]
return cleaned[: max_chars - len(marker)].rstrip() + marker
return cleaned
else:
# Preserve newlines/paragraphs, normalize CRLF to LF, strip other control chars
normalized = raw.replace("\r\n", "\n").replace("\r", "\n")
cleaned = _CONTROL_CHAR_NO_NL_RE.sub("", normalized).strip()
if not cleaned:
return ""
if len(cleaned) > max_chars:
marker = "..."
if max_chars <= len(marker):
return cleaned[:max_chars]
return cleaned[: max_chars - len(marker)].rstrip() + marker
return cleaned


# ---------------------------------------------------------------------------
# Metadata sanitisation (recursive, bounded, HTML-safe)
# ---------------------------------------------------------------------------
Expand Down
62 changes: 55 additions & 7 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@
from typing import NamedTuple
import networkx as nx
from networkx.readwrite import json_graph
from graphify.security import sanitize_label, check_graph_file_size_cap
from graphify.security import (
sanitize_label,
check_graph_file_size_cap,
sanitize_rationale,
MAX_QUERY_RATIONALE_CHARS,
MAX_DETAIL_RATIONALE_CHARS,
)
from graphify.build import edge_data, edge_datas
from graphify.paths import default_graph_json as _default_graph_json

Expand Down Expand Up @@ -979,7 +985,7 @@ def _dfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], lis
return visited, edges_seen


def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None, include_rationale: bool = False) -> str:

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 regression_subgraph_to_text()

26 callers depend on it (afferent coupling).

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

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 regression_subgraph_to_text()

26 callers depend on it (afferent coupling).

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

Comment thread
hopstreax marked this conversation as resolved.
"""Render subgraph as text, cutting at token_budget (approx 3 chars/token).

seeds: exact-match nodes rendered first before the degree-sorted expansion,
Expand Down Expand Up @@ -1034,12 +1040,22 @@ def _adj(n):
status = sanitize_label(str(entry.get("status", "")))
if status:
learning_suffix = f" learning={status}{':stale' if entry.get('stale') else ''}"
why_suffix = ""
if include_rationale:
rationale_text = sanitize_rationale(
d.get("rationale"),
single_line=True,
max_chars=MAX_QUERY_RATIONALE_CHARS,
)
if rationale_text:
why_suffix = f" WHY {rationale_text}"
line = (
f"NODE {sanitize_label(d.get('label', nid))} "
f"[src={sanitize_label(str(d.get('source_file', '')))} "
f"loc={sanitize_label(str(d.get('source_location', '')))} "
f"community={sanitize_label(str(d.get('community_name') or d.get('community', '')))}"
f"{learning_suffix}]"
f"{why_suffix}"
)
lines.append(line)
for u, v in edges:
Expand Down Expand Up @@ -1171,6 +1187,7 @@ def _query_graph_text(
depth: int = 3,
token_budget: int = 2000,
context_filters: list[str] | None = None,
include_rationale: bool = False,
) -> str:
terms = _query_terms(question)
# One graph scoring pass produces both the combined ranking (used to drive
Expand Down Expand Up @@ -1210,7 +1227,14 @@ def _query_graph_text(
# Pass the seeds so the queried symbol renders first and survives truncation
# (#BUG2): a branch merge had silently dropped this argument, leaving the
# seed-first ordering as dead code.
return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget, seeds=start_nodes)
return header + _subgraph_to_text(
traversal_graph,
nodes,
edges,
token_budget,
seeds=start_nodes,
include_rationale=include_rationale,
)


def _find_node_tiers(
Expand Down Expand Up @@ -1561,6 +1585,11 @@ async def list_tools() -> list[types.Tool]:
"items": {"type": "string"},
"description": "Optional explicit edge-context filter, e.g. ['call', 'field']",
},
"include_rationale": {
"type": "boolean",
"default": False,
"description": "Include WHY rationale on nodes when available",
},
},
"required": ["question"],
},
Expand All @@ -1570,7 +1599,14 @@ async def list_tools() -> list[types.Tool]:
description="Get full details for a specific node by label or ID.",
inputSchema={
"type": "object",
"properties": {"label": {"type": "string", "description": "Node label or ID to look up"}},
"properties": {
"label": {"type": "string", "description": "Node label or ID to look up"},
"include_rationale": {
"type": "boolean",
"default": False,
"description": "Include node rationale if available",
},
},
"required": ["label"],
},
),
Expand Down Expand Up @@ -1702,6 +1738,7 @@ def _tool_query_graph(arguments: dict) -> str:
depth = min(int(arguments.get("depth", 3)), 6)
budget = int(arguments.get("token_budget", 2000))
context_filter = arguments.get("context_filter")
include_rationale = arguments.get("include_rationale") is True
_t0 = _time.perf_counter()
result = _query_graph_text(
G,
Expand All @@ -1710,6 +1747,7 @@ def _tool_query_graph(arguments: dict) -> str:
depth=depth,
token_budget=budget,
context_filters=context_filter,
include_rationale=include_rationale,
)
querylog.log_query(
kind="mcp_query",
Expand All @@ -1725,20 +1763,30 @@ def _tool_query_graph(arguments: dict) -> str:

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 regression_tool_get_neighbors()

fans out to 6 callees (efferent coupling).

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

def _tool_get_node(arguments: dict) -> str:
label = arguments["label"].lower()
include_rationale = arguments.get("include_rationale") is True
matches = [(nid, d) for nid, d in G.nodes(data=True)
if label in (d.get("label") or "").lower() or label == nid.lower()]
if not matches:
return f"No node matching '{label}' found."
nid, d = matches[0]
# Sanitise every LLM-derived field before concatenation (F-010).
return "\n".join([
lines = [
f"Node: {sanitize_label(d.get('label', nid))}",
f" ID: {sanitize_label(nid)}",
f" Source: {sanitize_label(str(d.get('source_file', '')))} {sanitize_label(str(d.get('source_location', '')))}",
f" Type: {sanitize_label(str(d.get('file_type', '')))}",
f" Community: {sanitize_label(str(d.get('community_name') or d.get('community', '')))}",
f" Degree: {G.degree(nid)}",
])
]
if include_rationale:
rat = sanitize_rationale(
d.get("rationale"),
single_line=False,
max_chars=MAX_DETAIL_RATIONALE_CHARS,
)
if rat:
lines.append(f" Rationale: {rat}")
lines.append(f" Degree: {G.degree(nid)}")
return "\n".join(lines)

def _tool_get_neighbors(arguments: dict) -> str:

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 regression_tool_get_neighbors()

fans out to 6 callees (efferent coupling).

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

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 regression_tool_get_neighbors()

fans out to 6 callees (efferent coupling).

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

Comment thread
hopstreax marked this conversation as resolved.
label = arguments["label"].lower()
Expand Down
58 changes: 55 additions & 3 deletions tests/test_explain_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ def _write_graph(tmp_path):
return p


def _run(monkeypatch, graph_path, label, capsys):
def _run(monkeypatch, graph_path, label, capsys, extra_args=None):
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(mainmod.sys, "argv",
["graphify", "explain", label, "--graph", str(graph_path)])
argv = ["graphify", "explain", label, "--graph", str(graph_path)]
if extra_args:
argv.extend(extra_args)
monkeypatch.setattr(mainmod.sys, "argv", argv)
mainmod.main()
return capsys.readouterr().out

Expand Down Expand Up @@ -320,3 +322,53 @@ def test_explain_matches_within_one_file_are_not_ambiguous(monkeypatch, tmp_path
out = _run(monkeypatch, p, "MetricsPort", capsys)
assert "Ambiguous" not in out
assert "Node: MetricsPort" in out


def test_explain_rationale_flag_shows_rationale(monkeypatch, tmp_path, capsys):
graph_data = {
"directed": False, "multigraph": False, "graph": {},
"nodes": [
{
"id": "cluster_node",
"label": "Clustering per deposito",
"source_file": "deposito.py",
"source_location": "L10",
"file_type": "code",
"community": 0,
"rationale": "cannot work on ORIGINALS_NORMALIZED as it stands, because timestamps and coordinates are missing",
}
],
"links": [],
}
p = tmp_path / "graph.json"
p.write_text(json.dumps(graph_data))

# Without --rationale flag
out_default = _run(monkeypatch, p, "Clustering per deposito", capsys)
assert "Rationale:" not in out_default

# With --rationale flag
out_rat = _run(monkeypatch, p, "Clustering per deposito", capsys, extra_args=["--rationale"])
assert " Rationale: cannot work on ORIGINALS_NORMALIZED as it stands, because timestamps and coordinates are missing" in out_rat


def test_explain_rationale_empty_omitted(monkeypatch, tmp_path, capsys):
graph_data = {
"directed": False, "multigraph": False, "graph": {},
"nodes": [
{
"id": "empty_rat_node",
"label": "EmptyNode",
"source_file": "empty.py",
"source_location": "L1",
"file_type": "code",
"community": 0,
"rationale": " \n\t ",
}
],
"links": [],
}
p = tmp_path / "graph.json"
p.write_text(json.dumps(graph_data))
out = _run(monkeypatch, p, "EmptyNode", capsys, extra_args=["--rationale"])
assert "Rationale:" not in out
Loading
Loading