graphnav 1.2.2__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {graphnav-1.2.2/graphnav.egg-info → graphnav-1.2.3}/PKG-INFO +1 -1
  2. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/cli.py +24 -5
  3. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/config.py +49 -2
  4. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/doctor.py +5 -3
  5. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/graph_cache.py +1 -1
  6. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/graph_nav.py +17 -8
  7. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/graph_query.py +1 -1
  8. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/mcp_server.py +2 -1
  9. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/multirepo.py +73 -20
  10. {graphnav-1.2.2 → graphnav-1.2.3/graphnav.egg-info}/PKG-INFO +1 -1
  11. {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/SOURCES.txt +1 -0
  12. graphnav-1.2.3/graphnav.egg-info/entry_points.txt +2 -0
  13. {graphnav-1.2.2 → graphnav-1.2.3}/pyproject.toml +2 -2
  14. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_multirepo.py +3 -4
  15. graphnav-1.2.3/tests/test_robustness.py +201 -0
  16. graphnav-1.2.2/graphnav.egg-info/entry_points.txt +0 -2
  17. {graphnav-1.2.2 → graphnav-1.2.3}/LICENSE +0 -0
  18. {graphnav-1.2.2 → graphnav-1.2.3}/README.md +0 -0
  19. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/__init__.py +0 -0
  20. {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/runner.py +0 -0
  21. {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/dependency_links.txt +0 -0
  22. {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/requires.txt +0 -0
  23. {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/top_level.txt +0 -0
  24. {graphnav-1.2.2 → graphnav-1.2.3}/setup.cfg +0 -0
  25. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_cli.py +0 -0
  26. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_config.py +0 -0
  27. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_doctor.py +0 -0
  28. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_graph_cache.py +0 -0
  29. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_graph_nav.py +0 -0
  30. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_graph_query.py +0 -0
  31. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_mcp_server.py +0 -0
  32. {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphnav
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Knowledge-graph context injection for AI coding agents in monorepos
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -141,10 +141,17 @@ def _run_graph_query_command(kind: str, argv: list[str]) -> None:
141
141
  print(tools.impact(args.term))
142
142
  sys.exit(0)
143
143
 
144
- nav = load_bundle(
145
- graph_path, cfg.graph.skip_patterns,
146
- relation_weights=cfg.query.edge_relation_weights, repo_root=root,
147
- ).nav
144
+ import json as _json
145
+
146
+ try:
147
+ nav = load_bundle(
148
+ graph_path, cfg.graph.skip_patterns,
149
+ relation_weights=cfg.query.edge_relation_weights, repo_root=root,
150
+ ).nav
151
+ except (_json.JSONDecodeError, KeyError, OSError) as exc:
152
+ print(f"Error: could not read {graph_path} ({type(exc).__name__}: {exc}).", file=sys.stderr)
153
+ print("Run `graphnav map` to rebuild the graph.", file=sys.stderr)
154
+ sys.exit(2)
148
155
 
149
156
  if kind == "find":
150
157
  hits = nav.find_symbols(args.term, k=10)
@@ -284,6 +291,10 @@ def main() -> None:
284
291
  except GraphNotFoundError as e:
285
292
  print(f"Error: {e}", file=sys.stderr)
286
293
  sys.exit(2)
294
+ except Exception as e:
295
+ print(f"Error: could not read {graph_path} ({type(e).__name__}: {e}).", file=sys.stderr)
296
+ print("Run `graphnav map` to rebuild the graph.", file=sys.stderr)
297
+ sys.exit(2)
287
298
 
288
299
  ranked = query_files(
289
300
  prompt,
@@ -317,5 +328,13 @@ def main() -> None:
317
328
  sys.exit(124)
318
329
 
319
330
 
331
+ def entry() -> None:
332
+ try:
333
+ main()
334
+ except KeyboardInterrupt:
335
+ print("\n[graphnav] interrupted", file=sys.stderr)
336
+ sys.exit(130)
337
+
338
+
320
339
  if __name__ == "__main__":
321
- main()
340
+ entry()
@@ -133,8 +133,47 @@ def _apply_toml(cfg: Config, data: dict, warnings: list[str] | None = None) -> C
133
133
  return cfg
134
134
 
135
135
 
136
+ _NUMERIC_FIELDS = (
137
+ ("query", "top_k", int), ("query", "community_boost_weight", float),
138
+ ("query", "bm25_k1", float), ("query", "bm25_b", float),
139
+ ("query", "edge_boost_weight", float), ("query", "recency_boost_weight", float),
140
+ ("context", "max_file_chars", int),
141
+ ("codex", "timeout_seconds", int),
142
+ ("mono", "watch_poll_interval", float), ("mono", "context_budget_tokens", int),
143
+ ("mono", "context_top_files", int),
144
+ )
145
+
146
+
147
+ def _coerce_types(cfg: Config, warnings: list[str]) -> None:
148
+ defaults = Config()
149
+ for section, key, typ in _NUMERIC_FIELDS:
150
+ value = getattr(getattr(cfg, section), key)
151
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
152
+ fallback = getattr(getattr(defaults, section), key)
153
+ warnings.append(f"{section}.{key} has invalid value {value!r} — using default {fallback}")
154
+ setattr(getattr(cfg, section), key, fallback)
155
+ elif typ is int and not isinstance(value, int):
156
+ setattr(getattr(cfg, section), key, int(value))
157
+ if not isinstance(cfg.query.edge_relation_weights, dict):
158
+ warnings.append("query.edge_relation_weights must be a table — ignoring")
159
+ cfg.query.edge_relation_weights = {}
160
+ else:
161
+ for rel in list(cfg.query.edge_relation_weights):
162
+ w = cfg.query.edge_relation_weights[rel]
163
+ if isinstance(w, bool) or not isinstance(w, (int, float)):
164
+ warnings.append(f"query.edge_relation_weights.{rel} has invalid value {w!r} — ignoring")
165
+ del cfg.query.edge_relation_weights[rel]
166
+ for section, key in (("mono", "marker_files"), ("mono", "extra_skip_dirs"), ("graph", "skip_patterns"), ("codex", "extra_args")):
167
+ value = getattr(getattr(cfg, section), key)
168
+ if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
169
+ fallback = getattr(getattr(Config(), section), key)
170
+ warnings.append(f"{section}.{key} must be a list of strings — using default")
171
+ setattr(getattr(cfg, section), key, fallback)
172
+
173
+
136
174
  def _validate(cfg: Config) -> list[str]:
137
175
  warnings: list[str] = []
176
+ _coerce_types(cfg, warnings)
138
177
  if cfg.query.top_k < 1:
139
178
  warnings.append(f"query.top_k {cfg.query.top_k} clamped to 1")
140
179
  cfg.query.top_k = 1
@@ -192,12 +231,20 @@ def load_config_report(explicit_path: str | None = None) -> tuple[Config, str |
192
231
 
193
232
  source_path: str | None = None
194
233
  for path in candidates:
195
- if os.path.exists(path):
234
+ if not os.path.exists(path):
235
+ continue
236
+ try:
196
237
  with open(path, "rb") as f:
197
238
  data = tomllib.load(f)
198
- cfg = _apply_toml(cfg, data, warnings)
239
+ except (tomllib.TOMLDecodeError, OSError) as exc:
240
+ warnings.append(f"could not parse {path} ({exc}) — using defaults")
199
241
  source_path = path
200
242
  break
243
+ if not explicit_path and not any(section in data for section in _SECTION_TYPES):
244
+ continue
245
+ cfg = _apply_toml(cfg, data, warnings)
246
+ source_path = path
247
+ break
201
248
  else:
202
249
  if explicit_path:
203
250
  print(f"Warning: config file not found: {explicit_path}", file=sys.stderr)
@@ -11,8 +11,8 @@ from codex_graph.multirepo import (
11
11
  _graph_meta_path,
12
12
  _load_env_file,
13
13
  _overarching_graph_path,
14
- detect_services,
15
14
  find_graphify,
15
+ resolve_services,
16
16
  staleness_note,
17
17
  )
18
18
 
@@ -104,9 +104,11 @@ def _check_api_key(root: str, cfg: Config) -> CheckResult:
104
104
 
105
105
 
106
106
  def _check_services(root: str, cfg: Config) -> CheckResult:
107
- services = detect_services(root, cfg.mono.marker_files, cfg.mono.extra_skip_dirs)
107
+ services, single = resolve_services(root, cfg.mono.marker_files, cfg.mono.extra_skip_dirs)
108
108
  if not services:
109
- return CheckResult("fail", "services", "no services detectedadd code or marker files to subdirectories")
109
+ return CheckResult("fail", "services", "no source code found run graphnav from your project root")
110
+ if single:
111
+ return CheckResult("ok", "services", "single project (whole repo mapped as one graph)")
110
112
  names = [s.name for s in services]
111
113
  shown = ", ".join(names[:MAX_SERVICE_NAMES_SHOWN])
112
114
  if len(names) > MAX_SERVICE_NAMES_SHOWN:
@@ -149,7 +149,7 @@ def _build_bundle(
149
149
  ) -> GraphBundle:
150
150
  from codex_graph.multirepo import _symbols_by_file
151
151
 
152
- with open(graph_path) as f:
152
+ with open(graph_path, encoding="utf-8") as f:
153
153
  graph = json.load(f)
154
154
  return GraphBundle(
155
155
  stamp=stamp,
@@ -11,7 +11,7 @@ from codex_graph.graph_query import _tokenize
11
11
  class GraphNav:
12
12
  def __init__(self, graph_path: str, skip_patterns: list[str] | None = None, graph: dict | None = None):
13
13
  if graph is None:
14
- with open(graph_path) as f:
14
+ with open(graph_path, encoding="utf-8") as f:
15
15
  graph = json.load(f)
16
16
  self.skip = skip_patterns or []
17
17
  self._label_index = None
@@ -27,7 +27,10 @@ class GraphNav:
27
27
  self.file2ids[sf].append(nid)
28
28
  self.in_edges: dict[object, list] = defaultdict(list)
29
29
  self.out_edges: dict[object, list] = defaultdict(list)
30
- for e in graph.get("links", []):
30
+ links = graph.get("links")
31
+ if links is None:
32
+ links = graph.get("edges", [])
33
+ for e in links or []:
31
34
  s, t = e.get("source"), e.get("target")
32
35
  if s is None or t is None:
33
36
  continue
@@ -104,12 +107,18 @@ class GraphNav:
104
107
  return hits
105
108
 
106
109
  def neighbors(self, symbol: str, k: int = 12) -> dict:
107
- q = set(_tokenize(symbol))
108
- best, best_ov = None, 0
109
- for nid, n in self.id2node.items():
110
- ov = len(q & set(_tokenize(n.get("label", ""))))
111
- if ov > best_ov:
112
- best, best_ov = nid, ov
110
+ exact = self._labels().get(symbol.lower())
111
+ if exact:
112
+ best = exact[0]
113
+ else:
114
+ q = set(_tokenize(symbol))
115
+ best, best_score = None, (0, 0.0)
116
+ for nid, n in self.id2node.items():
117
+ toks = set(_tokenize(n.get("label", "")))
118
+ ov = len(q & toks)
119
+ score = (ov, ov / (len(toks) or 1))
120
+ if score > best_score:
121
+ best, best_score = nid, score
113
122
  fuzzy = False
114
123
  if best is None:
115
124
  fuzzy_ids = self._fuzzy_ids(symbol, n=1)
@@ -92,7 +92,7 @@ class GraphIndex:
92
92
  graph: dict | None = None,
93
93
  ):
94
94
  if graph is None:
95
- with open(graph_path) as f:
95
+ with open(graph_path, encoding="utf-8") as f:
96
96
  graph = json.load(f)
97
97
 
98
98
  nodes = graph.get("nodes", [])
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import sys
5
6
 
@@ -46,7 +47,7 @@ class GraphTools:
46
47
  relation_weights=self.query_cfg.edge_relation_weights,
47
48
  repo_root=self.root,
48
49
  ).nav
49
- except GraphNotFoundError:
50
+ except (GraphNotFoundError, OSError, json.JSONDecodeError, KeyError):
50
51
  return None
51
52
 
52
53
  def graph_context(self, task: str) -> str:
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import re
6
6
  import shutil
7
+ import signal
7
8
  import subprocess
8
9
  import sys
9
10
  import threading
@@ -39,14 +40,34 @@ def find_graphify() -> str | None:
39
40
  return None
40
41
 
41
42
 
43
+ def _any_subdir_has_marker(
44
+ root: str,
45
+ marker_files: list[str],
46
+ extra_skip_dirs: list[str] | None = None,
47
+ ) -> bool:
48
+ skip_dirs = SKIP_DIRS | frozenset(extra_skip_dirs or ())
49
+ try:
50
+ entries = os.listdir(root)
51
+ except OSError:
52
+ return False
53
+ for entry in entries:
54
+ abs_path = os.path.join(root, entry)
55
+ if not os.path.isdir(abs_path) or entry in skip_dirs or entry.startswith("."):
56
+ continue
57
+ if any(os.path.exists(os.path.join(abs_path, m)) for m in marker_files):
58
+ return True
59
+ return False
60
+
61
+
42
62
  def resolve_services(
43
63
  root: str,
44
64
  marker_files: list[str],
45
65
  extra_skip_dirs: list[str] | None = None,
46
66
  ) -> tuple[list[ServiceInfo], bool]:
47
- services = detect_services(root, marker_files, extra_skip_dirs)
48
- if services:
49
- return services, False
67
+ if _any_subdir_has_marker(root, marker_files, extra_skip_dirs):
68
+ services = detect_services(root, marker_files, extra_skip_dirs)
69
+ if services:
70
+ return services, False
50
71
  skip_dirs = SKIP_DIRS | frozenset(extra_skip_dirs or ())
51
72
  if _has_source_files(root, skip_dirs=skip_dirs):
52
73
  name = os.path.basename(os.path.abspath(root).rstrip(os.sep)) or "repo"
@@ -65,12 +86,12 @@ def _warn(msg: str) -> None:
65
86
 
66
87
  def _write_if_changed(path: str, content: str) -> bool:
67
88
  try:
68
- with open(path) as f:
89
+ with open(path, encoding="utf-8") as f:
69
90
  if f.read() == content:
70
91
  return False
71
- except OSError:
92
+ except (OSError, UnicodeDecodeError):
72
93
  pass
73
- with open(path, "w") as f:
94
+ with open(path, "w", encoding="utf-8") as f:
74
95
  f.write(content)
75
96
  return True
76
97
 
@@ -104,7 +125,7 @@ def _find_env_file(start: str) -> str | None:
104
125
  def _parse_env_file(path: str) -> dict[str, str]:
105
126
  env_vars: dict[str, str] = {}
106
127
  try:
107
- with open(path) as f:
128
+ with open(path, encoding="utf-8-sig") as f:
108
129
  for line in f:
109
130
  line = line.strip()
110
131
  if not line or line.startswith("#") or "=" not in line:
@@ -138,13 +159,22 @@ def _env_file_sources(root: str) -> list[str]:
138
159
  return sources
139
160
 
140
161
 
162
+ _KEY_ALIASES = {
163
+ "ANTHROPIC_KEY": "ANTHROPIC_API_KEY",
164
+ "OPENAI_KEY": "OPENAI_API_KEY",
165
+ "GEMINI_KEY": "GEMINI_API_KEY",
166
+ "DEEPSEEK_KEY": "DEEPSEEK_API_KEY",
167
+ }
168
+
169
+
141
170
  def _load_env_file(root: str) -> dict[str, str]:
142
171
  env_vars: dict[str, str] = {}
143
172
  for path in _env_file_sources(root):
144
173
  for key, value in _parse_env_file(path).items():
145
174
  env_vars.setdefault(key, value)
146
- if "ANTHROPIC_KEY" in env_vars and "ANTHROPIC_API_KEY" not in env_vars:
147
- env_vars["ANTHROPIC_API_KEY"] = env_vars["ANTHROPIC_KEY"]
175
+ for alias, canonical in _KEY_ALIASES.items():
176
+ if alias in env_vars and canonical not in env_vars:
177
+ env_vars[canonical] = env_vars[alias]
148
178
  return env_vars
149
179
 
150
180
 
@@ -316,7 +346,7 @@ def write_graph_meta(root: str) -> None:
316
346
  meta = {"built_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "git_sha": _git_sha(root)}
317
347
  path = _graph_meta_path(root)
318
348
  os.makedirs(os.path.dirname(path), exist_ok=True)
319
- with open(path, "w") as f:
349
+ with open(path, "w", encoding="utf-8") as f:
320
350
  json.dump(meta, f, indent=2)
321
351
 
322
352
 
@@ -325,7 +355,7 @@ def staleness_note(root: str) -> str:
325
355
  if not os.path.exists(path):
326
356
  return ""
327
357
  try:
328
- with open(path) as f:
358
+ with open(path, encoding="utf-8") as f:
329
359
  meta = json.load(f)
330
360
  except (OSError, json.JSONDecodeError):
331
361
  return ""
@@ -371,7 +401,7 @@ def partition_graph(
371
401
  overarching_graph_path: str,
372
402
  services: list[ServiceInfo],
373
403
  ) -> dict[str, int]:
374
- with open(overarching_graph_path) as f:
404
+ with open(overarching_graph_path, encoding="utf-8") as f:
375
405
  graph = json.load(f)
376
406
 
377
407
  service_names = {s.name for s in services}
@@ -414,7 +444,7 @@ def analyze_bridges(
414
444
  overarching_graph_path: str,
415
445
  services: list[ServiceInfo],
416
446
  ) -> dict[str, list[BridgeRow]]:
417
- with open(overarching_graph_path) as f:
447
+ with open(overarching_graph_path, encoding="utf-8") as f:
418
448
  graph = json.load(f)
419
449
 
420
450
  service_names = {s.name for s in services}
@@ -507,7 +537,7 @@ def write_symbols_md(service: ServiceInfo) -> str:
507
537
  os.makedirs(out_dir, exist_ok=True)
508
538
  path = os.path.join(out_dir, "SYMBOLS.md")
509
539
  try:
510
- with open(service.graph_path) as f:
540
+ with open(service.graph_path, encoding="utf-8") as f:
511
541
  graph = json.load(f)
512
542
  except (OSError, json.JSONDecodeError) as exc:
513
543
  _warn(f"could not read {service.graph_path} ({type(exc).__name__}) — symbols index will be empty")
@@ -589,7 +619,7 @@ def _write_managed_block(path: str, content: str) -> None:
589
619
  existing = ""
590
620
  if os.path.exists(path):
591
621
  try:
592
- with open(path) as f:
622
+ with open(path, encoding="utf-8", errors="replace") as f:
593
623
  existing = f.read()
594
624
  except OSError:
595
625
  existing = ""
@@ -733,7 +763,7 @@ def build_context_pack(
733
763
 
734
764
  def _extract_code_windows(abs_path, lines_wanted, before=2, after=14, max_lines=110):
735
765
  try:
736
- with open(abs_path, errors="replace") as f:
766
+ with open(abs_path, encoding="utf-8", errors="replace") as f:
737
767
  src = f.read().splitlines()
738
768
  except OSError:
739
769
  return ""
@@ -844,7 +874,10 @@ def build_context_pack_inline(
844
874
  text = "\n".join(out) + "\n"
845
875
  char_budget = max(budget_tokens, 0) * 4
846
876
  if char_budget and len(text) > char_budget:
847
- text = text[:char_budget].rstrip() + "\n```\n\n_(truncated to budget)_\n"
877
+ truncated = text[:char_budget].rstrip()
878
+ if truncated.count("```") % 2 == 1:
879
+ truncated += "\n```"
880
+ text = truncated + "\n\n_(truncated to budget)_\n"
848
881
  return text
849
882
 
850
883
 
@@ -904,7 +937,12 @@ def run_map(
904
937
  print(" Ensure an API key is available (e.g. ANTHROPIC_API_KEY or ANTHROPIC_KEY in a .env file).", file=sys.stderr)
905
938
  return 1
906
939
 
907
- bridges = _refresh(root, services, overarching_path, single=single)
940
+ try:
941
+ bridges = _refresh(root, services, overarching_path, single=single)
942
+ except (OSError, json.JSONDecodeError) as exc:
943
+ print(f"Error: extracted graph could not be read ({type(exc).__name__}: {exc}).", file=sys.stderr)
944
+ print(" Re-run `graphnav map`; if it persists, delete graphify-out/ and try again.", file=sys.stderr)
945
+ return 1
908
946
  total_bridges = sum(len(rows) for rows in bridges.values())
909
947
 
910
948
  print(f"\nSetup complete. Your AI coding agents are now configured for this repo.")
@@ -950,7 +988,10 @@ def run_watch(
950
988
  print(f"Error: bootstrap extraction failed (exit {rc}).", file=sys.stderr)
951
989
  return 1
952
990
 
953
- _refresh(root, services, overarching_path, single=single)
991
+ try:
992
+ _refresh(root, services, overarching_path, single=single)
993
+ except (OSError, json.JSONDecodeError) as exc:
994
+ _warn(f"could not read graph.json ({type(exc).__name__}) — will retry as it updates")
954
995
 
955
996
  def _start_watch() -> subprocess.Popen:
956
997
  return subprocess.Popen(
@@ -960,6 +1001,14 @@ def run_watch(
960
1001
  env=env,
961
1002
  )
962
1003
 
1004
+ def _sigterm(_signum, _frame):
1005
+ raise KeyboardInterrupt
1006
+
1007
+ try:
1008
+ signal.signal(signal.SIGTERM, _sigterm)
1009
+ except (ValueError, OSError):
1010
+ pass
1011
+
963
1012
  watch_proc = _start_watch()
964
1013
  backoff = RestartBackoff()
965
1014
  backoff.record_start(time.monotonic())
@@ -985,7 +1034,11 @@ def run_watch(
985
1034
  pending_mtime = None
986
1035
  ts = time.strftime("%H:%M:%S")
987
1036
  print(f"[graphnav] {ts} graph updated — refreshing symbols and bridges ...", file=sys.stderr)
988
- _refresh(root, services, overarching_path, single=single)
1037
+ try:
1038
+ _refresh(root, services, overarching_path, single=single)
1039
+ except (OSError, json.JSONDecodeError) as exc:
1040
+ _warn(f"could not read graph.json ({type(exc).__name__}) — will retry on next update")
1041
+ last_mtime = 0.0
989
1042
  else:
990
1043
  pending_mtime = mtime
991
1044
  elif mtime != last_mtime:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphnav
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Knowledge-graph context injection for AI coding agents in monorepos
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -25,4 +25,5 @@ tests/test_graph_nav.py
25
25
  tests/test_graph_query.py
26
26
  tests/test_mcp_server.py
27
27
  tests/test_multirepo.py
28
+ tests/test_robustness.py
28
29
  tests/test_runner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ graphnav = codex_graph.cli:entry
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphnav"
7
- version = "1.2.2"
7
+ version = "1.2.3"
8
8
  description = "Knowledge-graph context injection for AI coding agents in monorepos"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.11"
11
11
  dependencies = ["graphifyy>=0.8", "mcp>=1.2"]
12
12
 
13
13
  [project.scripts]
14
- graphnav = "codex_graph.cli:main"
14
+ graphnav = "codex_graph.cli:entry"
15
15
 
16
16
  [project.optional-dependencies]
17
17
  dev = ["pytest>=8", "anthropic>=0.40"]
@@ -1199,12 +1199,11 @@ class TestRunMap:
1199
1199
  monkeypatch.setattr("codex_graph.multirepo.shutil.which", lambda _: "/graphify")
1200
1200
  roots_seen = []
1201
1201
 
1202
- def fake_detect(root, markers, extra_skip_dirs=None):
1202
+ def fake_resolve(root, markers, extra_skip_dirs=None):
1203
1203
  roots_seen.append(root)
1204
- return []
1204
+ return [], False
1205
1205
 
1206
- monkeypatch.setattr("codex_graph.multirepo.detect_services", fake_detect)
1207
- monkeypatch.setattr("codex_graph.multirepo._has_source_files", lambda *a, **k: False)
1206
+ monkeypatch.setattr("codex_graph.multirepo.resolve_services", fake_resolve)
1208
1207
  run_map(".", MonoConfig())
1209
1208
  assert os.path.isabs(roots_seen[0])
1210
1209
 
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ from codex_graph.config import load_config_report
9
+ from codex_graph.graph_nav import GraphNav
10
+ from codex_graph.multirepo import (
11
+ _load_env_file,
12
+ _parse_env_file,
13
+ build_context_pack_inline,
14
+ resolve_services,
15
+ )
16
+ from tests.conftest import write_graph
17
+
18
+
19
+ class TestFlatRepoDetection:
20
+ def test_src_and_tests_dirs_without_markers_is_single_project(self, tmp_path):
21
+ (tmp_path / "src").mkdir()
22
+ (tmp_path / "src" / "main.py").write_text("def main():\n pass\n")
23
+ (tmp_path / "tests").mkdir()
24
+ (tmp_path / "tests" / "test_main.py").write_text("def test_x():\n pass\n")
25
+ services, single = resolve_services(str(tmp_path), ["pyproject.toml", "package.json"])
26
+ assert single is True
27
+ assert len(services) == 1
28
+ assert services[0].abs_path == str(tmp_path)
29
+
30
+ def test_root_marker_with_tests_dir_is_single_project(self, tmp_path):
31
+ (tmp_path / "pyproject.toml").touch()
32
+ (tmp_path / "app.py").write_text("x = 1\n")
33
+ (tmp_path / "tests").mkdir()
34
+ (tmp_path / "tests" / "test_app.py").write_text("y = 2\n")
35
+ services, single = resolve_services(str(tmp_path), ["pyproject.toml"])
36
+ assert single is True
37
+
38
+ def test_subdir_marker_still_monorepo(self, tmp_path):
39
+ for name in ("backend", "frontend"):
40
+ d = tmp_path / name
41
+ d.mkdir()
42
+ (d / "package.json").touch()
43
+ services, single = resolve_services(str(tmp_path), ["package.json"])
44
+ assert single is False
45
+ assert [s.name for s in services] == ["backend", "frontend"]
46
+
47
+ def test_one_marker_subdir_pulls_in_source_only_dirs(self, tmp_path):
48
+ backend = tmp_path / "backend"
49
+ backend.mkdir()
50
+ (backend / "pyproject.toml").touch()
51
+ shared = tmp_path / "shared"
52
+ shared.mkdir()
53
+ (shared / "util.py").write_text("z = 3\n")
54
+ services, single = resolve_services(str(tmp_path), ["pyproject.toml"])
55
+ assert single is False
56
+ assert {s.name for s in services} == {"backend", "shared"}
57
+
58
+
59
+ class TestConfigRobustness:
60
+ def test_malformed_toml_falls_back_to_defaults(self, tmp_path, monkeypatch):
61
+ (tmp_path / "config.toml").write_text("[query\ntop_k = 3\n")
62
+ monkeypatch.chdir(tmp_path)
63
+ cfg, source, warnings = load_config_report()
64
+ assert cfg.query.top_k == 5
65
+ assert any("could not parse" in w for w in warnings)
66
+
67
+ def test_foreign_config_toml_ignored(self, tmp_path, monkeypatch):
68
+ (tmp_path / "config.toml").write_text('[params]\ntitle = "My Hugo Site"\nbaseURL = "x"\n')
69
+ monkeypatch.chdir(tmp_path)
70
+ cfg, source, warnings = load_config_report()
71
+ assert source is None
72
+ assert warnings == []
73
+
74
+ def test_wrong_typed_value_uses_default(self, tmp_path, monkeypatch):
75
+ (tmp_path / "config.toml").write_text('[query]\ntop_k = "five"\n')
76
+ monkeypatch.chdir(tmp_path)
77
+ cfg, source, warnings = load_config_report()
78
+ assert cfg.query.top_k == 5
79
+ assert any("invalid value" in w for w in warnings)
80
+
81
+ def test_wrong_typed_list_uses_default(self, tmp_path, monkeypatch):
82
+ (tmp_path / "config.toml").write_text('[mono]\nmarker_files = "package.json"\n')
83
+ monkeypatch.chdir(tmp_path)
84
+ cfg, source, warnings = load_config_report()
85
+ assert "pyproject.toml" in cfg.mono.marker_files
86
+ assert any("list of strings" in w for w in warnings)
87
+
88
+ def test_explicit_path_with_no_known_sections_still_loads(self, tmp_path):
89
+ p = tmp_path / "custom.toml"
90
+ p.write_text("")
91
+ cfg, source, warnings = load_config_report(str(p))
92
+ assert source == str(p)
93
+
94
+
95
+ class TestNeighborsExactMatch:
96
+ def test_exact_label_beats_superstring(self):
97
+ graph = {
98
+ "nodes": [
99
+ {"id": "test_main", "label": "test_main", "source_file": "tests/test_app.py",
100
+ "file_type": "code", "source_location": "L1"},
101
+ {"id": "main", "label": "main", "source_file": "app.py",
102
+ "file_type": "code", "source_location": "L5"},
103
+ ],
104
+ "links": [],
105
+ }
106
+ nav = GraphNav("unused", graph=graph)
107
+ r = nav.neighbors("main")
108
+ assert r["symbol"] == "main"
109
+ assert r["defined_at"] == "app.py:L5"
110
+
111
+
112
+ class TestGraphNavEdgesKey:
113
+ def test_edges_key_fallback(self, tmp_path):
114
+ graph = {
115
+ "nodes": [
116
+ {"id": "a", "label": "alpha_func", "source_file": "a.py",
117
+ "file_type": "code", "source_location": "L1"},
118
+ {"id": "b", "label": "beta_func", "source_file": "b.py",
119
+ "file_type": "code", "source_location": "L2"},
120
+ ],
121
+ "edges": [{"source": "a", "target": "b", "relation": "calls"}],
122
+ }
123
+ nav = GraphNav("unused", graph=graph)
124
+ r = nav.neighbors("alpha_func")
125
+ assert r["callees"]
126
+ assert "beta_func" in r["callees"][0]
127
+
128
+
129
+ class TestEnvFileParsing:
130
+ def test_bom_stripped(self, tmp_path):
131
+ p = tmp_path / ".env"
132
+ p.write_bytes("ANTHROPIC_API_KEY=sk-bom\n".encode("utf-8"))
133
+ env = _parse_env_file(str(p))
134
+ assert env.get("ANTHROPIC_API_KEY") == "sk-bom"
135
+
136
+ def test_openai_key_alias(self, tmp_path, monkeypatch):
137
+ (tmp_path / ".env").write_text("OPENAI_KEY=sk-oai\n")
138
+ monkeypatch.chdir(tmp_path)
139
+ env = _load_env_file(str(tmp_path))
140
+ assert env.get("OPENAI_API_KEY") == "sk-oai"
141
+
142
+
143
+ class TestDoctorFlatRepo:
144
+ def test_flat_repo_services_check_passes(self, tmp_path, monkeypatch, capsys):
145
+ from codex_graph import doctor
146
+
147
+ (tmp_path / "app.py").write_text("def main():\n pass\n")
148
+ write_graph(
149
+ tmp_path / "graphify-out" / "graph.json",
150
+ [{"id": "m", "label": "main", "source_file": "app.py",
151
+ "file_type": "code", "source_location": "L1", "community": 0}],
152
+ [],
153
+ )
154
+ (tmp_path / "graphify-out" / ".graphnav-meta.json").write_text(
155
+ json.dumps({"built_at": "x", "git_sha": None})
156
+ )
157
+ monkeypatch.setattr(doctor, "find_graphify", lambda: "/usr/bin/graphify")
158
+ monkeypatch.setattr(
159
+ doctor.subprocess, "run",
160
+ lambda *a, **k: __import__("subprocess").CompletedProcess(a, 0, stdout="graphify 0.9", stderr=""),
161
+ )
162
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
163
+ rc = doctor.run_doctor(str(tmp_path))
164
+ out = capsys.readouterr().out
165
+ assert rc == 0
166
+ assert "single project" in out
167
+
168
+
169
+ class TestCorruptGraphCli:
170
+ def test_find_with_corrupt_graph_exits_2(self, tmp_path, monkeypatch, capsys):
171
+ (tmp_path / "graphify-out").mkdir()
172
+ (tmp_path / "graphify-out" / "graph.json").write_text("{not json")
173
+ monkeypatch.setattr(
174
+ sys, "argv", ["graphnav", "find", "anything", "--root", str(tmp_path)]
175
+ )
176
+ from codex_graph.cli import main
177
+
178
+ with pytest.raises(SystemExit) as exc:
179
+ main()
180
+ assert exc.value.code == 2
181
+ err = capsys.readouterr().err
182
+ assert "graphnav map" in err
183
+
184
+
185
+ class TestInlinePackTruncation:
186
+ def test_truncation_balances_code_fences(self, tmp_path):
187
+ nodes = []
188
+ for i in range(40):
189
+ name = f"mod_{i}"
190
+ src = tmp_path / f"{name}.py"
191
+ src.write_text("\n".join(f"def fn_{j}():\n pass" for j in range(30)))
192
+ nodes.append({
193
+ "id": name, "label": f"search target term {i}", "source_file": f"{name}.py",
194
+ "file_type": "code", "source_location": "L1", "community": 0,
195
+ })
196
+ write_graph(tmp_path / "graphify-out" / "graph.json", nodes, [])
197
+ pack = build_context_pack_inline(
198
+ str(tmp_path), "search target term", top_files=8, budget_tokens=100
199
+ )
200
+ assert "_(truncated to budget)_" in pack
201
+ assert pack.count("```") % 2 == 0
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- graphnav = codex_graph.cli:main
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes