diff --git a/README.md b/README.md index 1cdec4f25b..0ad339ec4a 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/graphify/__main__.py b/graphify/__main__.py index 924ae986d3..f62f451235 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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 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)") @@ -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 diff --git a/graphify/affected.py b/graphify/affected.py index 543772f12c..395f8dd84f 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -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,7 +109,7 @@ 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 @@ -114,7 +117,7 @@ def _prefer_file_node( 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 @@ -122,11 +125,69 @@ def _prefer_file_node( 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: # 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)) + + def affected_nodes( 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.") diff --git a/graphify/cli.py b/graphify/cli.py index caec6410e8..7880bc1125 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -85,6 +85,24 @@ def _default_graph_path() -> str: return str(Path(_GRAPHIFY_OUT) / "graph.json") +def _format_path_source(index: int, node_id: object, data: dict) -> str: + """Format one path node with honest, deterministic source evidence.""" + label = str(data.get("label") or node_id) + source_file = data.get("source_file") + source_location = data.get("source_location") + if source_file: + source = str(source_file) + if source_location not in (None, ""): + source = f"{source}:{source_location}" + else: + source = "" + return ( + f" node[{index}] label={json.dumps(label, ensure_ascii=False)} " + f"source={json.dumps(source, ensure_ascii=False)} " + f"id={json.dumps(str(node_id), ensure_ascii=False)}" + ) + + def _stamped_manifest_files( files_by_type: dict[str, list[str]], sem_result: dict, @@ -1061,45 +1079,69 @@ def dispatch_command(cmd: str) -> None: _touch_query_stamp(gp) print(_result) elif cmd == "affected": - if len(sys.argv) < 3: - print("Usage: graphify affected \"\" [--relation R] [--depth N] [--graph path]", file=sys.stderr) - sys.exit(1) + usage = ( + "Usage: graphify affected \"\" [--relation R] " + "[--depth N] [--production-only] [--graph path]" + ) + args = sys.argv[2:] + if any(arg in {"-h", "--help", "-?"} for arg in args): + print(usage) + print( + "Example: graphify affected authorizeCollection --production-only" + ) + return from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_affected, load_graph - query = sys.argv[2] + query: str | None = None graph_path = _default_graph_path() depth = 2 + production_only = False relations: list[str] = [] - args = sys.argv[3:] i = 0 while i < len(args): - if args[i] == "--graph" and i + 1 < len(args): + arg = args[i] + if arg == "--graph" and i + 1 < len(args): graph_path = args[i + 1] i += 2 - elif args[i].startswith("--graph="): - graph_path = args[i].split("=", 1)[1] + elif arg.startswith("--graph=") and arg.split("=", 1)[1]: + graph_path = arg.split("=", 1)[1] i += 1 - elif args[i] == "--depth" and i + 1 < len(args): + elif arg == "--depth" and i + 1 < len(args): try: depth = int(args[i + 1]) except ValueError: print("error: --depth must be an integer", file=sys.stderr) sys.exit(1) i += 2 - elif args[i].startswith("--depth="): + elif arg.startswith("--depth="): try: - depth = int(args[i].split("=", 1)[1]) + depth = int(arg.split("=", 1)[1]) except ValueError: print("error: --depth must be an integer", file=sys.stderr) sys.exit(1) i += 1 - elif args[i] == "--relation" and i + 1 < len(args): + elif arg == "--relation" and i + 1 < len(args): relations.append(args[i + 1]) i += 2 - elif args[i].startswith("--relation="): - relations.append(args[i].split("=", 1)[1]) + elif arg.startswith("--relation=") and arg.split("=", 1)[1]: + relations.append(arg.split("=", 1)[1]) i += 1 - else: + elif arg == "--production-only": + production_only = True i += 1 + elif arg.startswith("-"): + print(f"error: unknown affected option: {arg}", file=sys.stderr) + print(usage, file=sys.stderr) + sys.exit(2) + elif query is None: + query = arg + i += 1 + else: + print(f"error: unexpected affected argument: {arg}", file=sys.stderr) + print(usage, file=sys.stderr) + sys.exit(2) + if query is None: + print(usage, file=sys.stderr) + sys.exit(1) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1118,6 +1160,7 @@ def dispatch_command(cmd: str) -> None: query, relations=relations or DEFAULT_AFFECTED_RELATIONS, depth=depth, + production_only=production_only, ) ) elif cmd in ("god-nodes", "god_nodes"): @@ -1421,6 +1464,9 @@ def dispatch_command(cmd: str) -> None: else: segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}") print(f"Shortest path ({hops} hops):\n " + " ".join(segments)) + print("Source proof:") + for index, node_id in enumerate(path_nodes): + print(_format_path_source(index, node_id, G.nodes[node_id])) from graphify import querylog querylog.log_query( kind="path", diff --git a/graphify/llm.py b/graphify/llm.py index 46a4785d32..b4e18b1960 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -35,6 +35,60 @@ # is the standard heuristic for English/code on BPE tokenizers. _CHARS_PER_TOKEN = 4 +_HTTP_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +_PROTECTED_OPENAI_HEADERS = frozenset( + { + "accept", + "accept-encoding", + "authorization", + "connection", + "content-length", + "content-type", + "host", + "proxy-authorization", + "api-key", + "x-api-key", + "cookie", + "set-cookie", + "te", + "trailer", + "transfer-encoding", + "upgrade", + } +) + + +def _openai_default_headers() -> dict[str, str]: + """Parse safe OpenAI-compatible request headers from the environment.""" + raw = os.environ.get("GRAPHIFY_OPENAI_HEADERS_JSON", "") + if not raw.strip(): + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON must contain valid JSON.") from exc + if not isinstance(parsed, dict): + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON must be a JSON object.") + + headers: dict[str, str] = {} + normalized_names: set[str] = set() + for raw_name, value in parsed.items(): + name = raw_name.strip() if isinstance(raw_name, str) else "" + normalized_name = name.casefold() + if not name or _HTTP_HEADER_NAME_RE.fullmatch(name) is None: + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON contains an invalid header name.") + if normalized_name in _PROTECTED_OPENAI_HEADERS: + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON cannot set protected headers.") + if normalized_name in normalized_names: + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON contains duplicate header names.") + if not isinstance(value, str): + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON header values must be strings.") + if "\r" in value or "\n" in value: + raise ValueError("GRAPHIFY_OPENAI_HEADERS_JSON contains an invalid header value.") + normalized_names.add(normalized_name) + headers[name] = value + return headers + def _get_tokenizer(): """Return a tiktoken encoder for accurate token counts, or None if tiktoken @@ -1198,8 +1252,13 @@ def _call_openai_compat( _retries = _resolve_max_retries() if backend == "ollama" and not os.environ.get("GRAPHIFY_MAX_RETRIES", "").strip(): _retries = 0 - client = OpenAI(api_key=api_key, base_url=base_url, timeout=_resolve_api_timeout(), - max_retries=_retries) + client = OpenAI( + api_key=api_key, + base_url=base_url, + timeout=_resolve_api_timeout(), + max_retries=_retries, + default_headers=_openai_default_headers(), + ) kwargs: dict = { "model": model, "messages": [ @@ -2707,7 +2766,13 @@ def _rec(inp, out) -> None: from openai import OpenAI except ImportError as exc: raise ImportError(_backend_pkg_hint("openai", "openai")) from exc - client = OpenAI(api_key=key, base_url=cfg["base_url"], timeout=_resolve_api_timeout(), max_retries=_resolve_max_retries()) + client = OpenAI( + api_key=key, + base_url=cfg["base_url"], + timeout=_resolve_api_timeout(), + max_retries=_resolve_max_retries(), + default_headers=_openai_default_headers(), + ) kwargs: dict = { "model": mdl, "messages": [{"role": "user", "content": prompt}], diff --git a/graphify/prs.py b/graphify/prs.py index 9534e6c006..a624621782 100644 --- a/graphify/prs.py +++ b/graphify/prs.py @@ -554,28 +554,33 @@ def render_pr_detail(pr: PRInfo, repo: str | None = None) -> None: _TRIAGE_MODEL_DEFAULTS: dict[str, str] = { "claude": "claude-opus-4-7", "kimi": "kimi-k2.6", - "openai": "gpt-4.1-mini", "gemini": "gemini-3-flash-preview", } +def _triage_model(backend: str) -> str: + """Resolve triage model, respecting configured OpenAI-compatible routing.""" + from graphify.llm import _default_model_for_backend + + explicit = os.environ.get("GRAPHIFY_TRIAGE_MODEL", "").strip() + if explicit: + return explicit + if backend == "openai": + return _default_model_for_backend(backend) + return _TRIAGE_MODEL_DEFAULTS.get(backend) or _default_model_for_backend(backend) + + def _resolve_triage_backend() -> tuple[str, str]: """Return (backend, model) using GRAPHIFY_TRIAGE_BACKEND or first available key.""" from graphify.llm import BACKENDS, _get_backend_api_key, _default_model_for_backend explicit = os.environ.get("GRAPHIFY_TRIAGE_BACKEND", "").strip() if explicit in BACKENDS: - model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL") - or _TRIAGE_MODEL_DEFAULTS.get(explicit) - or _default_model_for_backend(explicit)) - return explicit, model + return explicit, _triage_model(explicit) for b in ("claude", "kimi", "openai", "gemini"): if _get_backend_api_key(b): - model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL") - or _TRIAGE_MODEL_DEFAULTS.get(b) - or _default_model_for_backend(b)) - return b, model + return b, _triage_model(b) import shutil if shutil.which("claude"): @@ -637,9 +642,14 @@ def triage_with_opus(prs: list[PRInfo], base: str) -> None: elif backend in ("kimi", "openai", "gemini", "ollama"): from openai import OpenAI + from graphify.llm import _openai_default_headers cfg = BACKENDS[backend] api_key = _get_backend_api_key(backend) or "ollama" - client = OpenAI(api_key=api_key, base_url=cfg.get("base_url", "")) + client = OpenAI( + api_key=api_key, + base_url=cfg.get("base_url", ""), + default_headers=_openai_default_headers(), + ) with client.chat.completions.create( model=model, max_tokens=1024, stream=True, messages=[{"role": "user", "content": prompt}], diff --git a/tests/test_affected_production.py b/tests/test_affected_production.py new file mode 100644 index 0000000000..3310a9bb42 --- /dev/null +++ b/tests/test_affected_production.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import networkx as nx +import pytest + +from graphify.affected import affected_nodes, format_affected, resolve_seed + + +def _add_node(graph: nx.DiGraph, node_id: str, path: str, *, label: str | None = None) -> None: + graph.add_node( + node_id, + label=label or f"{node_id}()", + source_file=path, + source_location="L1", + ) + + +@pytest.mark.parametrize("query", ["readDocStrict", "readDocStrict()"]) +def test_resolve_seed_prefers_one_production_definition_over_test_mocks(query: str) -> None: + graph = nx.DiGraph() + _add_node(graph, "production", "src/settings/document_store.ts", label="readDocStrict()") + _add_node(graph, "test-mock", "tests/settings.test.ts", label="readDocStrict()") + _add_node(graph, "nested-mock", "src/__tests__/settings.ts", label="readDocStrict()") + + assert resolve_seed(graph, query) == "production" + + +def test_resolve_seed_remains_ambiguous_with_two_production_definitions() -> None: + graph = nx.DiGraph() + _add_node(graph, "production-a", "src/a.ts", label="readDocStrict()") + _add_node(graph, "production-b", "src/b.ts", label="readDocStrict()") + _add_node(graph, "test-mock", "tests/settings.test.ts", label="readDocStrict()") + + assert resolve_seed(graph, "readDocStrict") is None + + +def test_resolve_seed_test_only_duplicates_remain_ambiguous() -> None: + graph = nx.DiGraph() + _add_node(graph, "test-a", "tests/a.ts", label="readDocStrict()") + _add_node(graph, "test-b", "src/__tests__/b.ts", label="readDocStrict()") + + assert resolve_seed(graph, "readDocStrict") is None + + +def test_resolve_seed_does_not_treat_unknown_source_as_production() -> None: + graph = nx.DiGraph() + _add_node(graph, "unknown", "", label="readDocStrict()") + _add_node(graph, "test-mock", "tests/settings.test.ts", label="readDocStrict()") + + assert resolve_seed(graph, "readDocStrict") is None + + +def test_resolve_seed_keeps_non_string_ids_ambiguous_without_error() -> None: + graph = nx.DiGraph() + graph.add_node(1, label="readDocStrict()") + graph.add_node(2, label="readDocStrict()") + + assert resolve_seed(graph, "readDocStrict") is None + assert format_affected(graph, "readDocStrict") == ( + "No unique node match for readDocStrict" + ) + + +def test_resolve_seed_does_not_treat_docs_as_production() -> None: + graph = nx.DiGraph() + _add_node(graph, "docs", "docs/example.ts", label="readDocStrict()") + _add_node(graph, "test-mock", "tests/settings.test.ts", label="readDocStrict()") + + assert resolve_seed(graph, "readDocStrict") is None + + +def test_resolve_seed_preserves_explicit_node_id() -> None: + graph = nx.DiGraph() + _add_node(graph, "explicit-test-id", "tests/settings.test.ts", label="readDocStrict()") + _add_node(graph, "production", "src/settings.ts", label="readDocStrict()") + + assert resolve_seed(graph, "explicit-test-id") == "explicit-test-id" + + +@pytest.mark.parametrize( + "path", + [ + "tests/caller.py", + "src/__tests__/caller.ts", + "src/caller.test.ts", + "eval/caller.py", + "docs/caller.py", + "", + ], +) +def test_production_only_excludes_nonproduction_paths(path: str) -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/target.py") + _add_node(graph, "caller", path) + graph.add_edge("caller", "seed", relation="calls") + + assert affected_nodes(graph, "seed", production_only=True) == [] + + +@pytest.mark.parametrize( + "path", + ["src/contest.py", "src/latest/x.py", "src/document_service.py"], +) +def test_production_only_keeps_similar_production_names(path: str) -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/target.py") + _add_node(graph, "caller", path) + graph.add_edge("caller", "seed", relation="calls") + + assert [hit.node_id for hit in affected_nodes(graph, "seed", production_only=True)] == [ + "caller" + ] + + +def test_production_only_does_not_traverse_excluded_nodes() -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/target.py") + _add_node(graph, "test-bridge", "tests/bridge.py") + _add_node(graph, "production-caller", "src/caller.py") + graph.add_edge("test-bridge", "seed", relation="calls") + graph.add_edge("production-caller", "test-bridge", relation="calls") + + assert affected_nodes(graph, "seed", depth=2, production_only=True) == [] + + +def test_production_only_excludes_nonproduction_edge_call_site() -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/target.py") + _add_node(graph, "production-caller", "src/caller.py") + graph.add_edge( + "production-caller", + "seed", + relation="calls", + source_file="tests/caller.test.py", + source_location="L7", + ) + + assert affected_nodes(graph, "seed", production_only=True) == [] + + +def test_production_only_does_not_seed_excluded_members() -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/service.py") + _add_node(graph, "test-member", "tests/service.py") + _add_node(graph, "production-caller", "src/caller.py") + graph.add_edge("seed", "test-member", relation="method") + graph.add_edge("production-caller", "test-member", relation="calls") + + assert affected_nodes(graph, "seed", production_only=True) == [] + + +def test_default_traversal_behavior_is_unchanged() -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/target.py") + _add_node(graph, "test-caller", "tests/caller.py") + graph.add_edge("test-caller", "seed", relation="calls") + + assert [hit.node_id for hit in affected_nodes(graph, "seed")] == ["test-caller"] + + +def test_format_affected_contains_no_excluded_paths_in_production_mode() -> None: + graph = nx.DiGraph() + _add_node(graph, "seed", "src/target.py", label="target()") + _add_node(graph, "production", "src/caller.py") + _add_node(graph, "test", "tests/caller.py") + _add_node(graph, "docs", "docs/caller.py") + graph.add_edge("production", "seed", relation="calls") + graph.add_edge("test", "seed", relation="calls") + graph.add_edge("docs", "seed", relation="calls") + + output = format_affected(graph, "target", production_only=True) + + assert "src/caller.py" in output + assert "tests/caller.py" not in output + assert "docs/caller.py" not in output diff --git a/tests/test_affected_production_cli.py b/tests/test_affected_production_cli.py new file mode 100644 index 0000000000..25f34bb058 --- /dev/null +++ b/tests/test_affected_production_cli.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json + +import networkx as nx +import pytest +from networkx.readwrite import json_graph + +import graphify.__main__ as mainmod + + +def _write_graph(tmp_path): + graph = nx.DiGraph() + graph.add_node("target", label="Target()", source_file="src/target.ts") + graph.add_node("caller", label="Caller()", source_file="src/caller.ts") + graph.add_node("test", label="TestCaller()", source_file="src/__tests__/caller.test.ts") + graph.add_edge("caller", "target", relation="calls") + graph.add_edge("test", "target", relation="calls") + graph_path = tmp_path / "graph.json" + graph_path.write_text( + json.dumps(json_graph.node_link_data(graph, edges="links")), + encoding="utf-8", + ) + return graph_path + + +def _run(monkeypatch, graph_path, *args: str) -> None: + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "affected", *args, "--graph", str(graph_path)], + ) + + mainmod.main() + + +def test_affected_cli_production_only_excludes_test_results(monkeypatch, tmp_path, capsys) -> None: + graph_path = _write_graph(tmp_path) + + _run(monkeypatch, graph_path, "Target", "--production-only") + + output = capsys.readouterr().out + assert "src/caller.ts" in output + assert "src/__tests__/caller.test.ts" not in output + assert "Scope: production only (tests, eval, docs excluded)" in output + + +def test_production_only_is_order_independent(monkeypatch, tmp_path, capsys) -> None: + graph_path = _write_graph(tmp_path) + + _run(monkeypatch, graph_path, "--production-only", "Target") + + output = capsys.readouterr().out + assert "src/caller.ts" in output + assert "src/__tests__/caller.test.ts" not in output + + +@pytest.mark.parametrize("bad_flag", ["--production-onl", "--production-only=false"]) +def test_unknown_production_flags_fail_closed(monkeypatch, tmp_path, capsys, bad_flag: str) -> None: + graph_path = _write_graph(tmp_path) + + with pytest.raises(SystemExit) as error: + _run(monkeypatch, graph_path, "Target", bad_flag) + + assert error.value.code == 2 + assert "unknown affected option" in capsys.readouterr().err + + +def test_affected_help_is_command_specific(monkeypatch, capsys) -> None: + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "affected", "--help"]) + + mainmod.main() + + output = capsys.readouterr().out + assert "Usage: graphify affected" in output + assert "authorizeCollection --production-only" in output + + +def test_help_lists_affected_production_only(monkeypatch, capsys) -> None: + monkeypatch.setattr(mainmod.sys, "argv", ["graphify", "--help"]) + + mainmod.main() + + assert "--production-only" in capsys.readouterr().out diff --git a/tests/test_openai_default_headers.py b/tests/test_openai_default_headers.py new file mode 100644 index 0000000000..875a5325b2 --- /dev/null +++ b/tests/test_openai_default_headers.py @@ -0,0 +1,183 @@ +"""Tests for governed headers on OpenAI-compatible Graphify requests.""" + +from __future__ import annotations + +import json +import sys +import types +from types import SimpleNamespace + +import pytest + +from graphify import llm + + +def _install_fake_openai(monkeypatch): + constructor_calls: list[dict] = [] + request_calls: list[dict] = [] + + class FakeOpenAI: + def __init__(self, **kwargs): + constructor_calls.append(kwargs) + self.chat = SimpleNamespace( + completions=SimpleNamespace(create=self._create), + ) + + @staticmethod + def _create(**kwargs): + request_calls.append(kwargs) + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content='{"nodes":[],"edges":[],"hyperedges":[]}'), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + fake_module = types.ModuleType("openai") + setattr(fake_module, "OpenAI", FakeOpenAI) + monkeypatch.setitem(sys.modules, "openai", fake_module) + return constructor_calls, request_calls + + +@pytest.mark.parametrize("raw", [None, "", " "]) +def test_headers_are_empty_when_environment_is_unset_or_blank(monkeypatch, raw): + if raw is None: + monkeypatch.delenv("GRAPHIFY_OPENAI_HEADERS_JSON", raising=False) + else: + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", raw) + + assert llm._openai_default_headers() == {} + + +def test_headers_accept_synapse_metadata_and_trim_names(monkeypatch): + monkeypatch.setenv( + "GRAPHIFY_OPENAI_HEADERS_JSON", + json.dumps( + { + " x-privacy-tier ": "local-only", + "x-task-type": "code", + } + ), + ) + + assert llm._openai_default_headers() == { + "x-privacy-tier": "local-only", + "x-task-type": "code", + } + + +def test_headers_reject_invalid_json(monkeypatch): + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", "{not-json") + + with pytest.raises(ValueError, match="must contain valid JSON"): + llm._openai_default_headers() + + +@pytest.mark.parametrize("raw", ["[]", "null", '"x-task-type"']) +def test_headers_reject_non_object_json(monkeypatch, raw): + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", raw) + + with pytest.raises(ValueError, match="must be a JSON object"): + llm._openai_default_headers() + + +def test_headers_reject_non_string_values(monkeypatch): + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", '{"x-task-type": 42}') + + with pytest.raises(ValueError, match="values must be strings"): + llm._openai_default_headers() + + +@pytest.mark.parametrize("name", ["", " ", "bad header", "bad:header", "héader"]) +def test_headers_reject_blank_or_invalid_names(monkeypatch, name): + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", json.dumps({name: "safe"})) + + with pytest.raises(ValueError, match="invalid header name"): + llm._openai_default_headers() + + +@pytest.mark.parametrize( + "name", + [ + "authorization", + "proxy-authorization", + "api-key", + "x-api-key", + "cookie", + "set-cookie", + "host", + "content-length", + "transfer-encoding", + ], +) +def test_headers_reject_protected_names_case_insensitively(monkeypatch, name): + secret = "must-not-appear-in-error" + monkeypatch.setenv( + "GRAPHIFY_OPENAI_HEADERS_JSON", + json.dumps({f" {name.upper()} ": secret}), + ) + + with pytest.raises(ValueError, match="cannot set protected headers") as error: + llm._openai_default_headers() + assert secret not in str(error.value) + + +def test_headers_reject_line_breaks_without_leaking_values(monkeypatch): + secret = "must-not-appear-in-error\r\ninjected: value" + monkeypatch.setenv( + "GRAPHIFY_OPENAI_HEADERS_JSON", + json.dumps({"x-task-type": secret}), + ) + + with pytest.raises(ValueError, match="invalid header value") as error: + llm._openai_default_headers() + assert secret not in str(error.value) + + +def test_headers_reject_duplicate_names_after_normalization(monkeypatch): + monkeypatch.setenv( + "GRAPHIFY_OPENAI_HEADERS_JSON", + '{"X-Task-Type": "code", " x-task-type ": "other"}', + ) + + with pytest.raises(ValueError, match="duplicate header names"): + llm._openai_default_headers() + + +def test_extraction_client_receives_synapse_headers_and_auto_model(monkeypatch): + expected_headers = { + "x-privacy-tier": "local-only", + "x-task-type": "code", + } + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", json.dumps(expected_headers)) + constructor_calls, request_calls = _install_fake_openai(monkeypatch) + + llm._call_openai_compat( + "https://synapse.example/v1", + "fake-key", + "auto", + "user message", + backend="openai", + ) + + assert constructor_calls[0]["default_headers"] == expected_headers + assert request_calls[0]["model"] == "auto" + + +def test_lightweight_client_receives_synapse_headers_and_auto_model(monkeypatch): + expected_headers = { + "x-privacy-tier": "local-only", + "x-task-type": "code", + } + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", json.dumps(expected_headers)) + monkeypatch.setenv("GRAPHIFY_OPENAI_MODEL", "auto") + monkeypatch.setattr(llm, "_get_backend_api_key", lambda _backend: "fake-key") + constructor_calls, request_calls = _install_fake_openai(monkeypatch) + + llm._call_llm("label this", backend="openai") + + assert constructor_calls[0]["default_headers"] == expected_headers + assert request_calls[0]["model"] == "auto" diff --git a/tests/test_path_source_evidence.py b/tests/test_path_source_evidence.py new file mode 100644 index 0000000000..fc0b5a596a --- /dev/null +++ b/tests/test_path_source_evidence.py @@ -0,0 +1,163 @@ +"""Source-evidence contracts for ``graphify path`` output.""" + +from __future__ import annotations + +import json + +import graphify.__main__ as mainmod + + +def _write_graph(tmp_path, nodes: list[dict], links: list[dict]): + graph_path = tmp_path / "graph.json" + graph_path.write_text( + json.dumps( + { + "directed": True, + "multigraph": False, + "graph": {}, + "nodes": nodes, + "links": links, + } + ), + encoding="utf-8", + ) + return graph_path + + +def _run(monkeypatch, capsys, graph_path, source: str, target: str, *extra: str) -> str: + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + [ + "graphify", + "path", + source, + target, + "--graph", + str(graph_path), + *extra, + ], + ) + mainmod.main() + return capsys.readouterr().out + + +def _node(node_id: str, label: str, source_file=None, source_location=None) -> dict: + data = {"id": node_id, "label": label} + if source_file is not None: + data["source_file"] = source_file + if source_location is not None: + data["source_location"] = source_location + return data + + +def _link(source: str, target: str, relation="calls", confidence="EXTRACTED") -> dict: + return { + "source": source, + "target": target, + "relation": relation, + "confidence": confidence, + } + + +def test_one_hop_includes_source_and_target_file_line(monkeypatch, tmp_path, capsys): + graph_path = _write_graph( + tmp_path, + [ + _node("start", "Start", "src/start.ts", "L10"), + _node("embed", "Embed", "src/nim/embed.ts", "L42"), + ], + [_link("start", "embed")], + ) + + output = _run(monkeypatch, capsys, graph_path, "Start", "Embed") + + assert 'node[0] label="Start" source="src/start.ts:L10" id="start"' in output + assert 'node[1] label="Embed" source="src/nim/embed.ts:L42" id="embed"' in output + + +def test_multi_hop_includes_source_proof_for_every_node(monkeypatch, tmp_path, capsys): + graph_path = _write_graph( + tmp_path, + [ + _node("a", "Alpha", "src/a.py", "L1"), + _node("b", "Bridge", "src/bridge.py", "L2"), + _node("c", "Charlie", "src/c.py", "L3"), + ], + [_link("a", "b"), _link("b", "c", "returns", "INFERRED")], + ) + + output = _run(monkeypatch, capsys, graph_path, "Alpha", "Charlie") + + proof_lines = [line.strip() for line in output.splitlines() if line.strip().startswith("node[")] + assert proof_lines == [ + 'node[0] label="Alpha" source="src/a.py:L1" id="a"', + 'node[1] label="Bridge" source="src/bridge.py:L2" id="b"', + 'node[2] label="Charlie" source="src/c.py:L3" id="c"', + ] + + +def test_missing_location_keeps_known_source_file(monkeypatch, tmp_path, capsys): + graph_path = _write_graph( + tmp_path, + [_node("a", "Alpha", "src/a.py", "L1"), _node("embed", "Embed", "src/nim/embed.ts")], + [_link("a", "embed")], + ) + + output = _run(monkeypatch, capsys, graph_path, "Alpha", "Embed") + + assert 'node[1] label="Embed" source="src/nim/embed.ts" id="embed"' in output + + +def test_missing_source_uses_honest_placeholder_and_node_id(monkeypatch, tmp_path, capsys): + graph_path = _write_graph( + tmp_path, + [_node("a", "Alpha", "src/a.py", "L1"), _node("external", "External")], + [_link("a", "external")], + ) + + output = _run(monkeypatch, capsys, graph_path, "Alpha", "External") + + assert 'node[1] label="External" source="" id="external"' in output + + +def test_source_proof_preserves_relation_confidence_and_reverse_arrow( + monkeypatch, + tmp_path, + capsys, +): + graph_path = _write_graph( + tmp_path, + [_node("a", "Alpha", "src/a.py", "L1"), _node("b", "Beta", "src/b.py", "L2")], + [_link("a", "b", "references", "INFERRED")], + ) + + output = _run(monkeypatch, capsys, graph_path, "Beta", "Alpha", "--undirected") + + assert "Beta <--references [INFERRED]-- Alpha" in output + assert "Beta --references [INFERRED]--> Alpha" not in output + + +def test_source_proof_order_is_deterministic(monkeypatch, tmp_path, capsys): + graph_path = _write_graph( + tmp_path, + [ + _node("start", "Start", "src/start.py", "L1"), + _node("right", "Right", "src/right.py", "L2"), + _node("left", "Left", "src/left.py", "L3"), + _node("goal", "Goal", "src/goal.py", "L4"), + ], + [ + _link("start", "right"), + _link("right", "goal"), + _link("start", "left"), + _link("left", "goal"), + ], + ) + + first = _run(monkeypatch, capsys, graph_path, "Start", "Goal") + second = _run(monkeypatch, capsys, graph_path, "Start", "Goal") + + assert first == second + assert 'node[1] label="Left" source="src/left.py:L3" id="left"' in first diff --git a/tests/test_prs_synapse_routing.py b/tests/test_prs_synapse_routing.py new file mode 100644 index 0000000000..4ad7b83579 --- /dev/null +++ b/tests/test_prs_synapse_routing.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +import sys +import types +from datetime import datetime, timezone +from types import SimpleNamespace + +from graphify.prs import PRInfo, _resolve_triage_backend, triage_with_opus + + +def _install_streaming_openai(monkeypatch): + constructor_calls: list[dict] = [] + request_calls: list[dict] = [] + + class FakeStream: + def __enter__(self): + return iter([SimpleNamespace(choices=[])]) + + def __exit__(self, _exc_type, _exc, _traceback): + return False + + class FakeOpenAI: + def __init__(self, **kwargs): + constructor_calls.append(kwargs) + self.chat = SimpleNamespace( + completions=SimpleNamespace(create=self._create), + ) + + @staticmethod + def _create(**kwargs): + request_calls.append(kwargs) + return FakeStream() + + fake_module = types.ModuleType("openai") + setattr(fake_module, "OpenAI", FakeOpenAI) + monkeypatch.setitem(sys.modules, "openai", fake_module) + return constructor_calls, request_calls + + +def test_openai_triage_uses_governed_headers_and_auto_model(monkeypatch, capsys) -> None: + headers = {"x-privacy-tier": "local-only", "x-task-type": "code"} + monkeypatch.setenv("GRAPHIFY_TRIAGE_BACKEND", "openai") + monkeypatch.setenv("GRAPHIFY_OPENAI_MODEL", "auto") + monkeypatch.setenv("OPENAI_API_KEY", "fake-key") + monkeypatch.setenv("GRAPHIFY_OPENAI_HEADERS_JSON", json.dumps(headers)) + constructor_calls, request_calls = _install_streaming_openai(monkeypatch) + pr = PRInfo( + number=1, + title="Synthetic change", + branch="feature", + base_branch="v8", + author="developer", + is_draft=False, + review_decision="", + ci_status="SUCCESS", + updated_at=datetime.now(timezone.utc), + expected_base="v8", + ) + + triage_with_opus([pr], "v8") + + assert constructor_calls[0]["default_headers"] == headers + assert request_calls[0]["model"] == "auto" + assert "openai / auto" in capsys.readouterr().out + + +def test_openai_triage_model_uses_real_graphify_env_precedence(monkeypatch) -> None: + monkeypatch.setenv("GRAPHIFY_TRIAGE_BACKEND", "openai") + monkeypatch.delenv("GRAPHIFY_TRIAGE_MODEL", raising=False) + monkeypatch.setenv("GRAPHIFY_OPENAI_MODEL", "auto") + + assert _resolve_triage_backend() == ("openai", "auto") + + +def test_explicit_triage_model_still_overrides_openai_model(monkeypatch) -> None: + monkeypatch.setenv("GRAPHIFY_TRIAGE_BACKEND", "openai") + monkeypatch.setenv("GRAPHIFY_TRIAGE_MODEL", "triage-override") + monkeypatch.setenv("GRAPHIFY_OPENAI_MODEL", "auto") + + assert _resolve_triage_backend() == ("openai", "triage-override")