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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,9 +634,23 @@ graphify-out/

/graphify query "what connects attention to the optimizer?"
/graphify query "..." --dfs --budget 1500
/graphify query "authentication community:Backend"
/graphify query "Q: stale cache include:memory"
/graphify query "callers god:RequestRouter"
/graphify path "DigestAuth" "Response"
/graphify path "src/auth.py::validate()" "src/http.py::respond()" # disambiguate duplicate labels
/graphify explain "SwinTransformer"

# Query retrieval is staged across code, docs, tests, and likely communities.
# Saved Q: work-memory nodes are fallback-only unless include:memory is present.
# community:<id|label> and god:<label> scope both retrieval and traversal.
# Quote filter labels containing spaces, for example community:"Authentication Flow".
# Query normalization keeps the first 32 terms and at most 128 characters per term.
# Ambiguous path/explain labels return ranked source-file::label identities and IDs; CLI lookup errors exit 2.
# Source-qualified lookup bounds paths to 4,096 characters, 256 components, and 255 characters per component.
# Query loads adjacent .graphify_labels.json names; canonical sidecar names override embedded community_name values.
# Label sidecars are accepted atomically up to 1 MiB and 4,096 validated entries; invalid artifacts fall back safely.

graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome useful # record how a Q&A turned out (work memory; outcome ∈ useful|dead_end|corrected)
graphify reflect # aggregate graphify-out/memory/ outcomes into reflections/LESSONS.md
graphify reflect --if-stale # no-op when LESSONS.md is already newer than every input (cheap to run each session)
Expand Down
3 changes: 3 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,9 @@ def _run_cli() -> None:
print(" --context C explicit edge-context filter (repeatable)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" query filters include:memory, community:<id|label>, god:<label>")
print(" query limits first 32 normalized terms, 128 characters per term")
print(" path/explain identities use <source-file>::<label> or an exact node ID; lookup errors exit 2")
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)")
Expand Down
106 changes: 58 additions & 48 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,17 @@ 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> [include:memory] "
"[community:<id|label>] [god:<label>]\" "
"[--dfs] [--context C] [--budget N] [--graph path]",
file=sys.stderr,
)
sys.exit(1)
from graphify.serve import _query_graph_text
from graphify.serve import (
_attach_adjacent_community_labels,
_query_graph_text,
)
from graphify.security import sanitize_label
from networkx.readwrite import json_graph
from graphify import querylog
Expand Down Expand Up @@ -411,6 +419,7 @@ def dispatch_command(cmd: str) -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
_attach_adjacent_community_labels(G, gp)
try:
from graphify.build import graph_has_legacy_ids as _legacy
if _legacy(_raw.get("nodes", [])):
Expand Down Expand Up @@ -446,6 +455,9 @@ def dispatch_command(cmd: str) -> None:
token_budget=budget,
duration_ms=(_time.perf_counter() - _t0) * 1000,
)
if _result.startswith("Error:"):
print(_result, file=sys.stderr)
sys.exit(2)
print(_result)
elif cmd == "affected":
if len(sys.argv) < 3:
Expand Down Expand Up @@ -602,7 +614,8 @@ def dispatch_command(cmd: str) -> None:
file=sys.stderr,
)
sys.exit(1)
from graphify.serve import _pick_scored_endpoint, _score_nodes
from graphify.serve import _format_resolution_error, _resolve_node
from graphify.security import sanitize_label as _sl
from networkx.readwrite import json_graph
import networkx as _nx

Expand All @@ -627,41 +640,32 @@ def dispatch_command(cmd: str) -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
src_scored = _score_nodes(G, [t.lower() for t in source_label.split()])
tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()])
if not src_scored:
print(f"No node matching '{source_label}' found.", file=sys.stderr)
sys.exit(1)
if not tgt_scored:
print(f"No node matching '{target_label}' found.", file=sys.stderr)
sys.exit(1)
src_nid = _pick_scored_endpoint(G, src_scored, source_label)
tgt_nid = _pick_scored_endpoint(G, tgt_scored, target_label)
src_resolution = _resolve_node(G, source_label)
tgt_resolution = _resolve_node(G, target_label)
if src_resolution.node_id is None:
print(
_format_resolution_error(G, source_label, src_resolution, role="source"),
file=sys.stderr,
)
sys.exit(2)
if tgt_resolution.node_id is None:
print(
_format_resolution_error(G, target_label, tgt_resolution, role="target"),
file=sys.stderr,
)
sys.exit(2)
src_nid = src_resolution.node_id
tgt_nid = tgt_resolution.node_id
# Ambiguity guard: when both queries resolve to the same node, the
# shortest path is trivially zero hops, which is almost never what the
# caller wanted (see bug #828).
if src_nid == tgt_nid:
print(
f"'{source_label}' and '{target_label}' both resolved to the same "
f"node '{src_nid}'. Use a more specific label or the exact node ID.",
f"'{_sl(source_label)}' and '{_sl(target_label)}' both resolved to the same "
f"node '{_sl(str(src_nid))}'. Use a more specific label or the exact node ID.",
file=sys.stderr,
)
sys.exit(1)
for _name, _scored, _nid in (
("source", src_scored, src_nid),
("target", tgt_scored, tgt_nid),
):
# A close runner-up only made the resolution ambiguous when the raw
# score head is what got picked; a full-token override was chosen on
# token coverage, not score, so the head's margin is irrelevant.
if len(_scored) >= 2 and _nid == _scored[0][1]:
_top, _runner = _scored[0][0], _scored[1][0]
if _top > 0 and (_top - _runner) / _top < 0.10:
print(
f"warning: {_name} match was ambiguous "
f"(top score {_top:g}, runner-up {_runner:g})",
file=sys.stderr,
)
try:
path_nodes = _nx.shortest_path(G.to_undirected(as_view=True), src_nid, tgt_nid)
except (_nx.NetworkXNoPath, _nx.NodeNotFound):
Expand All @@ -681,13 +685,15 @@ def dispatch_command(cmd: str) -> None:
forward = False
rel = edata.get("relation", "")
conf = edata.get("confidence", "")
conf_str = f" [{conf}]" if conf else ""
safe_rel = _sl(str(rel))
conf_str = f" [{_sl(str(conf))}]" if conf else ""
if i == 0:
segments.append(G.nodes[u].get("label", u))
segments.append(_sl(str(G.nodes[u].get("label") or u)))
neighbor = _sl(str(G.nodes[v].get("label") or v))
if forward:
segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
segments.append(f"--{safe_rel}{conf_str}--> {neighbor}")
else:
segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
segments.append(f"<--{safe_rel}{conf_str}-- {neighbor}")
print(f"Shortest path ({hops} hops):\n " + " ".join(segments))
from graphify import querylog
querylog.log_query(
Expand All @@ -701,7 +707,12 @@ def dispatch_command(cmd: str) -> None:
if len(sys.argv) < 3:
print('Usage: graphify explain "<node>" [--graph path]', file=sys.stderr)
sys.exit(1)
from graphify.serve import _find_node
from graphify.serve import (
_format_resolution_error,
_resolve_node,
_source_display,
)
from graphify.security import sanitize_label as _sl
from networkx.readwrite import json_graph

label = sys.argv[2]
Expand All @@ -724,19 +735,17 @@ def dispatch_command(cmd: str) -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
matches = _find_node(G, label)
if not matches:
print(f"No node matching '{label}' found.")
sys.exit(0)
nid = matches[0]
resolution = _resolve_node(G, label)
if resolution.node_id is None:
print(_format_resolution_error(G, label, resolution), file=sys.stderr)
sys.exit(2)
nid = resolution.node_id
d = G.nodes[nid]
print(f"Node: {d.get('label', nid)}")
print(f" ID: {nid}")
print(
f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip()
)
print(f" Type: {d.get('file_type', '')}")
print(f" Community: {d.get('community_name') or d.get('community', '')}")
print(f"Node: {_sl(str(d.get('label') or nid))}")
print(f" ID: {_sl(str(nid))}")
print(f" Source: {_sl(_source_display(d))}")
print(f" Type: {_sl(str(d.get('file_type', '')))}")
print(f" Community: {_sl(str(d.get('community_name') or d.get('community', '')))}")
# 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 Expand Up @@ -775,7 +784,8 @@ def dispatch_command(cmd: str) -> None:
rel = edata.get("relation", "")
conf = edata.get("confidence", "")
arrow = "-->" if direction == "out" else "<--"
print(f" {arrow} {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]")
neighbor = _sl(str(G.nodes[nb].get("label") or nb))
print(f" {arrow} {neighbor} [{_sl(str(rel))}] [{_sl(str(conf))}]")
if len(connections) > 20:
print(f" ... and {len(connections) - 20} more")
from graphify import querylog
Expand Down
15 changes: 13 additions & 2 deletions graphify/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import urllib.error
import urllib.parse
import urllib.request
import unicodedata
from collections.abc import Mapping
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -388,18 +389,28 @@ def check_graph_file_size_cap(path: Path) -> None:
# ---------------------------------------------------------------------------

_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
_UNSAFE_LABEL_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"})
_MAX_LABEL_LEN = 256


def sanitize_label(text: str | None) -> str:
"""Strip control characters and cap length.
"""Strip record-forging Unicode controls/formats and cap length.

Safe for embedding in JSON data (inside <script> tags) and plain text.
For direct HTML injection, wrap the result with html.escape().

Unicode categories Cc/Cf/Cs/Zl/Zp cover C0/C1 controls, bidi and invisible
formatting controls, isolated surrogates, and Unicode line/paragraph
separators. Printable letters, marks, symbols, punctuation, and normal
spaces are preserved.
"""
if text is None:
return ""
text = _CONTROL_CHAR_RE.sub("", str(text))
text = "".join(
char
for char in str(text)
if unicodedata.category(char) not in _UNSAFE_LABEL_CATEGORIES
)
if len(text) > _MAX_LABEL_LEN:
text = text[:_MAX_LABEL_LEN]
return text
Expand Down
Loading
Loading