graphnav 1.2.2__tar.gz → 1.3.0__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 (33) hide show
  1. {graphnav-1.2.2/graphnav.egg-info → graphnav-1.3.0}/PKG-INFO +1 -1
  2. {graphnav-1.2.2 → graphnav-1.3.0}/README.md +4 -2
  3. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/cli.py +27 -6
  4. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/config.py +57 -2
  5. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/doctor.py +5 -3
  6. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/graph_cache.py +1 -1
  7. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/graph_nav.py +17 -8
  8. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/graph_query.py +1 -1
  9. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/mcp_server.py +17 -4
  10. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/multirepo.py +170 -23
  11. {graphnav-1.2.2 → graphnav-1.3.0/graphnav.egg-info}/PKG-INFO +1 -1
  12. {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/SOURCES.txt +2 -0
  13. graphnav-1.3.0/graphnav.egg-info/entry_points.txt +2 -0
  14. {graphnav-1.2.2 → graphnav-1.3.0}/pyproject.toml +2 -2
  15. graphnav-1.3.0/tests/test_auto_rebuild.py +140 -0
  16. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_cli.py +1 -1
  17. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_multirepo.py +3 -4
  18. graphnav-1.3.0/tests/test_robustness.py +201 -0
  19. graphnav-1.2.2/graphnav.egg-info/entry_points.txt +0 -2
  20. {graphnav-1.2.2 → graphnav-1.3.0}/LICENSE +0 -0
  21. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/__init__.py +0 -0
  22. {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/runner.py +0 -0
  23. {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/dependency_links.txt +0 -0
  24. {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/requires.txt +0 -0
  25. {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/top_level.txt +0 -0
  26. {graphnav-1.2.2 → graphnav-1.3.0}/setup.cfg +0 -0
  27. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_config.py +0 -0
  28. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_doctor.py +0 -0
  29. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_graph_cache.py +0 -0
  30. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_graph_nav.py +0 -0
  31. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_graph_query.py +0 -0
  32. {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_mcp_server.py +0 -0
  33. {graphnav-1.2.2 → graphnav-1.3.0}/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.3.0
4
4
  Summary: Knowledge-graph context injection for AI coding agents in monorepos
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -18,6 +18,7 @@ GraphNav solves this by:
18
18
 
19
19
  ## GraphNav Core Features
20
20
 
21
+ - **Always fresh, automatically** — when source files change, the next graph query detects it and rebuilds the graph in the background. No daemon, no manual re-runs, nothing to remember.
21
22
  - **Token-budgeted context packs** — `graphnav context "<task>"` returns only the relevant code, inline, with no LLM call.
22
23
  - **Native MCP tools** — `graphnav serve` exposes the graph to agents over the Model Context Protocol, refreshed automatically when the graph changes.
23
24
  - **Graph-aware ranking** — BM25 plus relation-weighted call-edge expansion and a git-recency nudge, so the file you actually need to edit surfaces even when its text doesn't match the query.
@@ -40,7 +41,7 @@ That's the whole setup. Run `graphnav` from your project root and it:
40
41
  2. Builds the knowledge graph
41
42
  3. Writes the agent instruction files
42
43
 
43
- Then open the repo in your AI coding tool and start working. **There is nothing else to run.**
44
+ Then open the repo in your AI coding tool and start working. **There is nothing else to run — ever.** When you edit files, the next graph query (from you or your agent) automatically rebuilds the graph in the background, so it never goes stale. Disable with `auto_rebuild = false` under `[mono]` or `GRAPHNAV_NO_AUTO_REBUILD=1`.
44
45
 
45
46
  Requires Python ≥ 3.11. Pulls `graphifyy` (the `graphify` binary) automatically — including under `pipx`, `--user`, and virtualenv installs.
46
47
 
@@ -63,7 +64,7 @@ After running `graphnav`, every AI agent in the repo has access to:
63
64
  ### Optional
64
65
 
65
66
  ```bash
66
- graphnav watch # keep the graph live as you edit
67
+ graphnav watch # eager mode: rebuild on every save instead of at query time
67
68
  graphnav doctor # diagnose the setup if something looks wrong
68
69
  ```
69
70
 
@@ -308,6 +309,7 @@ Place a `config.toml` in the project root (or pass `--config PATH`):
308
309
  ```toml
309
310
  [mono]
310
311
  graphify_backend = "claude" # LLM backend for extraction
312
+ auto_rebuild = true # rebuild the graph in the background when queries find it stale
311
313
  watch_poll_interval = 3.0 # seconds between mtime checks in watch mode
312
314
  context_budget_tokens = 2000 # token budget for graphnav context output
313
315
  context_top_files = 8 # max files returned by context command
@@ -104,6 +104,7 @@ def _run_context_command(argv: list[str]) -> None:
104
104
  "task": task,
105
105
  "skip_patterns": cfg.graph.skip_patterns,
106
106
  "query_cfg": cfg.query,
107
+ "auto_rebuild": cfg.mono.auto_rebuild,
107
108
  }
108
109
  if args.files is not None:
109
110
  kwargs["top_files"] = args.files
@@ -129,6 +130,7 @@ def _run_graph_query_command(kind: str, argv: list[str]) -> None:
129
130
 
130
131
  cfg = load_config(args.config)
131
132
  root = os.path.abspath(args.root)
133
+ multirepo.maybe_auto_rebuild(root, enabled=cfg.mono.auto_rebuild)
132
134
  graph_path = multirepo._overarching_graph_path(root)
133
135
  if not os.path.exists(graph_path):
134
136
  print(f"Error: no knowledge graph at {graph_path}. Run `graphnav map` first.", file=sys.stderr)
@@ -137,14 +139,21 @@ def _run_graph_query_command(kind: str, argv: list[str]) -> None:
137
139
  if kind == "impact":
138
140
  from codex_graph.mcp_server import GraphTools
139
141
 
140
- tools = GraphTools(root, cfg.graph.skip_patterns, query_cfg=cfg.query)
142
+ tools = GraphTools(root, cfg.graph.skip_patterns, query_cfg=cfg.query, auto_rebuild=False)
141
143
  print(tools.impact(args.term))
142
144
  sys.exit(0)
143
145
 
144
- nav = load_bundle(
145
- graph_path, cfg.graph.skip_patterns,
146
- relation_weights=cfg.query.edge_relation_weights, repo_root=root,
147
- ).nav
146
+ import json as _json
147
+
148
+ try:
149
+ nav = load_bundle(
150
+ graph_path, cfg.graph.skip_patterns,
151
+ relation_weights=cfg.query.edge_relation_weights, repo_root=root,
152
+ ).nav
153
+ except (_json.JSONDecodeError, KeyError, OSError) as exc:
154
+ print(f"Error: could not read {graph_path} ({type(exc).__name__}: {exc}).", file=sys.stderr)
155
+ print("Run `graphnav map` to rebuild the graph.", file=sys.stderr)
156
+ sys.exit(2)
148
157
 
149
158
  if kind == "find":
150
159
  hits = nav.find_symbols(args.term, k=10)
@@ -284,6 +293,10 @@ def main() -> None:
284
293
  except GraphNotFoundError as e:
285
294
  print(f"Error: {e}", file=sys.stderr)
286
295
  sys.exit(2)
296
+ except Exception as e:
297
+ print(f"Error: could not read {graph_path} ({type(e).__name__}: {e}).", file=sys.stderr)
298
+ print("Run `graphnav map` to rebuild the graph.", file=sys.stderr)
299
+ sys.exit(2)
287
300
 
288
301
  ranked = query_files(
289
302
  prompt,
@@ -317,5 +330,13 @@ def main() -> None:
317
330
  sys.exit(124)
318
331
 
319
332
 
333
+ def entry() -> None:
334
+ try:
335
+ main()
336
+ except KeyboardInterrupt:
337
+ print("\n[graphnav] interrupted", file=sys.stderr)
338
+ sys.exit(130)
339
+
340
+
320
341
  if __name__ == "__main__":
321
- main()
342
+ entry()
@@ -51,6 +51,7 @@ class MonoConfig:
51
51
  context_budget_tokens: int = 2000
52
52
  context_top_files: int = 8
53
53
  extra_skip_dirs: list[str] = field(default_factory=list)
54
+ auto_rebuild: bool = True
54
55
 
55
56
 
56
57
  @dataclass
@@ -129,12 +130,58 @@ def _apply_toml(cfg: Config, data: dict, warnings: list[str] | None = None) -> C
129
130
  context_budget_tokens=m.get("context_budget_tokens", cfg.mono.context_budget_tokens),
130
131
  context_top_files=m.get("context_top_files", cfg.mono.context_top_files),
131
132
  extra_skip_dirs=m.get("extra_skip_dirs", cfg.mono.extra_skip_dirs),
133
+ auto_rebuild=m.get("auto_rebuild", cfg.mono.auto_rebuild),
132
134
  )
133
135
  return cfg
134
136
 
135
137
 
138
+ _NUMERIC_FIELDS = (
139
+ ("query", "top_k", int), ("query", "community_boost_weight", float),
140
+ ("query", "bm25_k1", float), ("query", "bm25_b", float),
141
+ ("query", "edge_boost_weight", float), ("query", "recency_boost_weight", float),
142
+ ("context", "max_file_chars", int),
143
+ ("codex", "timeout_seconds", int),
144
+ ("mono", "watch_poll_interval", float), ("mono", "context_budget_tokens", int),
145
+ ("mono", "context_top_files", int),
146
+ )
147
+
148
+
149
+ def _coerce_types(cfg: Config, warnings: list[str]) -> None:
150
+ defaults = Config()
151
+ for section, key, typ in _NUMERIC_FIELDS:
152
+ value = getattr(getattr(cfg, section), key)
153
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
154
+ fallback = getattr(getattr(defaults, section), key)
155
+ warnings.append(f"{section}.{key} has invalid value {value!r} — using default {fallback}")
156
+ setattr(getattr(cfg, section), key, fallback)
157
+ elif typ is int and not isinstance(value, int):
158
+ setattr(getattr(cfg, section), key, int(value))
159
+ if not isinstance(cfg.query.edge_relation_weights, dict):
160
+ warnings.append("query.edge_relation_weights must be a table — ignoring")
161
+ cfg.query.edge_relation_weights = {}
162
+ else:
163
+ for rel in list(cfg.query.edge_relation_weights):
164
+ w = cfg.query.edge_relation_weights[rel]
165
+ if isinstance(w, bool) or not isinstance(w, (int, float)):
166
+ warnings.append(f"query.edge_relation_weights.{rel} has invalid value {w!r} — ignoring")
167
+ del cfg.query.edge_relation_weights[rel]
168
+ for section, key in (("mono", "auto_rebuild"), ("context", "show_scores")):
169
+ value = getattr(getattr(cfg, section), key)
170
+ if not isinstance(value, bool):
171
+ fallback = getattr(getattr(Config(), section), key)
172
+ warnings.append(f"{section}.{key} must be true or false — using default {fallback}")
173
+ setattr(getattr(cfg, section), key, fallback)
174
+ for section, key in (("mono", "marker_files"), ("mono", "extra_skip_dirs"), ("graph", "skip_patterns"), ("codex", "extra_args")):
175
+ value = getattr(getattr(cfg, section), key)
176
+ if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
177
+ fallback = getattr(getattr(Config(), section), key)
178
+ warnings.append(f"{section}.{key} must be a list of strings — using default")
179
+ setattr(getattr(cfg, section), key, fallback)
180
+
181
+
136
182
  def _validate(cfg: Config) -> list[str]:
137
183
  warnings: list[str] = []
184
+ _coerce_types(cfg, warnings)
138
185
  if cfg.query.top_k < 1:
139
186
  warnings.append(f"query.top_k {cfg.query.top_k} clamped to 1")
140
187
  cfg.query.top_k = 1
@@ -192,12 +239,20 @@ def load_config_report(explicit_path: str | None = None) -> tuple[Config, str |
192
239
 
193
240
  source_path: str | None = None
194
241
  for path in candidates:
195
- if os.path.exists(path):
242
+ if not os.path.exists(path):
243
+ continue
244
+ try:
196
245
  with open(path, "rb") as f:
197
246
  data = tomllib.load(f)
198
- cfg = _apply_toml(cfg, data, warnings)
247
+ except (tomllib.TOMLDecodeError, OSError) as exc:
248
+ warnings.append(f"could not parse {path} ({exc}) — using defaults")
199
249
  source_path = path
200
250
  break
251
+ if not explicit_path and not any(section in data for section in _SECTION_TYPES):
252
+ continue
253
+ cfg = _apply_toml(cfg, data, warnings)
254
+ source_path = path
255
+ break
201
256
  else:
202
257
  if explicit_path:
203
258
  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
 
@@ -7,7 +8,11 @@ from codex_graph import GraphNotFoundError
7
8
  from codex_graph.config import QueryConfig, load_config
8
9
  from codex_graph.graph_cache import DEFAULT_PACK_SKIP_PATTERNS, load_bundle
9
10
  from codex_graph.graph_nav import GraphNav
10
- from codex_graph.multirepo import _overarching_graph_path, build_context_pack_inline
11
+ from codex_graph.multirepo import (
12
+ _overarching_graph_path,
13
+ build_context_pack_inline,
14
+ maybe_auto_rebuild,
15
+ )
11
16
 
12
17
  MAX_REGION_LINES = 200
13
18
 
@@ -31,10 +36,12 @@ class GraphTools:
31
36
  root: str,
32
37
  skip_patterns: list[str] | None = None,
33
38
  query_cfg: QueryConfig | None = None,
39
+ auto_rebuild: bool = True,
34
40
  ):
35
41
  self.root = os.path.abspath(root)
36
42
  self.skip_patterns = skip_patterns or list(DEFAULT_PACK_SKIP_PATTERNS)
37
43
  self.query_cfg = query_cfg or QueryConfig()
44
+ self.auto_rebuild = auto_rebuild
38
45
  self.graph_path = _overarching_graph_path(self.root)
39
46
 
40
47
  @property
@@ -46,16 +53,17 @@ class GraphTools:
46
53
  relation_weights=self.query_cfg.edge_relation_weights,
47
54
  repo_root=self.root,
48
55
  ).nav
49
- except GraphNotFoundError:
56
+ except (GraphNotFoundError, OSError, json.JSONDecodeError, KeyError):
50
57
  return None
51
58
 
52
59
  def graph_context(self, task: str) -> str:
53
60
  return build_context_pack_inline(
54
61
  root=self.root, task=task, skip_patterns=self.skip_patterns,
55
- query_cfg=self.query_cfg,
62
+ query_cfg=self.query_cfg, auto_rebuild=self.auto_rebuild,
56
63
  )
57
64
 
58
65
  def graph_find(self, query: str) -> str:
66
+ maybe_auto_rebuild(self.root, enabled=self.auto_rebuild)
59
67
  if self.nav is None:
60
68
  return _NO_GRAPH
61
69
  hits = self.nav.find_symbols(query, k=8)
@@ -68,6 +76,7 @@ class GraphTools:
68
76
  return "\n".join(lines)
69
77
 
70
78
  def graph_neighbors(self, symbol: str) -> str:
79
+ maybe_auto_rebuild(self.root, enabled=self.auto_rebuild)
71
80
  if self.nav is None:
72
81
  return _NO_GRAPH
73
82
  r = self.nav.neighbors(symbol)
@@ -95,6 +104,7 @@ class GraphTools:
95
104
  return f"error: {exc}"
96
105
 
97
106
  def impact(self, symbol: str) -> str:
107
+ maybe_auto_rebuild(self.root, enabled=self.auto_rebuild)
98
108
  if self.nav is None:
99
109
  return _NO_GRAPH
100
110
  r = self.nav.neighbors(symbol)
@@ -129,7 +139,10 @@ def serve(root: str = ".", config_path: str | None = None) -> int:
129
139
  return 1
130
140
 
131
141
  cfg = load_config(config_path)
132
- tools = GraphTools(os.path.abspath(root), cfg.graph.skip_patterns, query_cfg=cfg.query)
142
+ tools = GraphTools(
143
+ os.path.abspath(root), cfg.graph.skip_patterns, query_cfg=cfg.query,
144
+ auto_rebuild=cfg.mono.auto_rebuild,
145
+ )
133
146
  server = FastMCP("graphnav")
134
147
 
135
148
  @server.tool()
@@ -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
 
@@ -278,6 +308,81 @@ def run_extract(
278
308
  return _stream_proc(proc, timeout)
279
309
 
280
310
 
311
+ AUTO_REBUILD_COOLDOWN = 60.0
312
+
313
+
314
+ def _newest_source_mtime(root: str, max_depth: int = 4) -> float:
315
+ newest = 0.0
316
+ base = root.rstrip(os.sep).count(os.sep)
317
+ for dirpath, dirnames, filenames in os.walk(root):
318
+ depth = dirpath.count(os.sep) - base
319
+ if depth >= max_depth:
320
+ dirnames[:] = []
321
+ else:
322
+ dirnames[:] = [
323
+ d for d in dirnames
324
+ if d not in SKIP_DIRS and not d.startswith(".")
325
+ ]
326
+ for fn in filenames:
327
+ if os.path.splitext(fn)[1] in SOURCE_EXTENSIONS:
328
+ try:
329
+ mt = os.stat(os.path.join(dirpath, fn)).st_mtime
330
+ except OSError:
331
+ continue
332
+ if mt > newest:
333
+ newest = mt
334
+ return newest
335
+
336
+
337
+ def graph_is_stale(root: str) -> bool:
338
+ try:
339
+ graph_mtime = os.stat(_overarching_graph_path(root)).st_mtime
340
+ except OSError:
341
+ return True
342
+ return _newest_source_mtime(root) > graph_mtime + 1.0
343
+
344
+
345
+ def maybe_auto_rebuild(root: str, enabled: bool = True) -> bool:
346
+ if not enabled or os.environ.get("GRAPHNAV_NO_AUTO_REBUILD") == "1":
347
+ return False
348
+ root = os.path.abspath(root)
349
+ if not graph_is_stale(root):
350
+ return False
351
+ out_dir = os.path.join(root, "graphify-out")
352
+ try:
353
+ os.makedirs(out_dir, exist_ok=True)
354
+ except OSError:
355
+ return False
356
+ pid_path = os.path.join(out_dir, ".graphnav-rebuild.pid")
357
+ try:
358
+ st = os.stat(pid_path)
359
+ with open(pid_path) as f:
360
+ pid = int(f.read().strip() or 0)
361
+ if pid > 0:
362
+ try:
363
+ os.kill(pid, 0)
364
+ return False
365
+ except OSError:
366
+ pass
367
+ if time.time() - st.st_mtime < AUTO_REBUILD_COOLDOWN:
368
+ return False
369
+ except (OSError, ValueError):
370
+ pass
371
+ log_path = os.path.join(out_dir, "auto-rebuild.log")
372
+ try:
373
+ with open(log_path, "ab") as log:
374
+ proc = subprocess.Popen(
375
+ [sys.executable, "-m", "codex_graph.cli", "map", "--root", root],
376
+ stdout=log, stderr=log, start_new_session=True,
377
+ env=_build_subprocess_env(root),
378
+ )
379
+ with open(pid_path, "w") as f:
380
+ f.write(str(proc.pid))
381
+ return True
382
+ except OSError:
383
+ return False
384
+
385
+
281
386
  def _overarching_graph_path(root: str) -> str:
282
387
  return os.path.join(root, "graphify-out", "graph.json")
283
388
 
@@ -316,7 +421,7 @@ def write_graph_meta(root: str) -> None:
316
421
  meta = {"built_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "git_sha": _git_sha(root)}
317
422
  path = _graph_meta_path(root)
318
423
  os.makedirs(os.path.dirname(path), exist_ok=True)
319
- with open(path, "w") as f:
424
+ with open(path, "w", encoding="utf-8") as f:
320
425
  json.dump(meta, f, indent=2)
321
426
 
322
427
 
@@ -325,7 +430,7 @@ def staleness_note(root: str) -> str:
325
430
  if not os.path.exists(path):
326
431
  return ""
327
432
  try:
328
- with open(path) as f:
433
+ with open(path, encoding="utf-8") as f:
329
434
  meta = json.load(f)
330
435
  except (OSError, json.JSONDecodeError):
331
436
  return ""
@@ -371,7 +476,7 @@ def partition_graph(
371
476
  overarching_graph_path: str,
372
477
  services: list[ServiceInfo],
373
478
  ) -> dict[str, int]:
374
- with open(overarching_graph_path) as f:
479
+ with open(overarching_graph_path, encoding="utf-8") as f:
375
480
  graph = json.load(f)
376
481
 
377
482
  service_names = {s.name for s in services}
@@ -414,7 +519,7 @@ def analyze_bridges(
414
519
  overarching_graph_path: str,
415
520
  services: list[ServiceInfo],
416
521
  ) -> dict[str, list[BridgeRow]]:
417
- with open(overarching_graph_path) as f:
522
+ with open(overarching_graph_path, encoding="utf-8") as f:
418
523
  graph = json.load(f)
419
524
 
420
525
  service_names = {s.name for s in services}
@@ -507,7 +612,7 @@ def write_symbols_md(service: ServiceInfo) -> str:
507
612
  os.makedirs(out_dir, exist_ok=True)
508
613
  path = os.path.join(out_dir, "SYMBOLS.md")
509
614
  try:
510
- with open(service.graph_path) as f:
615
+ with open(service.graph_path, encoding="utf-8") as f:
511
616
  graph = json.load(f)
512
617
  except (OSError, json.JSONDecodeError) as exc:
513
618
  _warn(f"could not read {service.graph_path} ({type(exc).__name__}) — symbols index will be empty")
@@ -589,7 +694,7 @@ def _write_managed_block(path: str, content: str) -> None:
589
694
  existing = ""
590
695
  if os.path.exists(path):
591
696
  try:
592
- with open(path) as f:
697
+ with open(path, encoding="utf-8", errors="replace") as f:
593
698
  existing = f.read()
594
699
  except OSError:
595
700
  existing = ""
@@ -636,9 +741,18 @@ def build_context_pack(
636
741
  from codex_graph.graph_query import query_files
637
742
 
638
743
  root = os.path.abspath(root)
744
+ if mono_cfg is None:
745
+ mono_cfg = MonoConfig()
746
+ rebuild_started = maybe_auto_rebuild(root, enabled=mono_cfg.auto_rebuild)
639
747
  overarching_path = _overarching_graph_path(root)
640
748
  if not os.path.exists(overarching_path):
641
749
  rel = os.path.relpath(overarching_path, root)
750
+ if rebuild_started:
751
+ return (
752
+ f"# Context for: {task}\n\n"
753
+ "Knowledge graph is being built automatically in the background — "
754
+ "retry this in ~30s.\n"
755
+ )
642
756
  return (
643
757
  f"# Context for: {task}\n\n"
644
758
  f"No knowledge graph found at {rel}.\n"
@@ -649,8 +763,6 @@ def build_context_pack(
649
763
  skip_patterns = list(DEFAULT_PACK_SKIP_PATTERNS)
650
764
  if query_cfg is None:
651
765
  query_cfg = QueryConfig()
652
- if mono_cfg is None:
653
- mono_cfg = MonoConfig()
654
766
 
655
767
  degraded = False
656
768
  try:
@@ -678,6 +790,8 @@ def build_context_pack(
678
790
  note = staleness_note(root)
679
791
  if note:
680
792
  out_lines += [note, ""]
793
+ if rebuild_started:
794
+ out_lines += ["_Source files changed — automatic graph rebuild started in the background._", ""]
681
795
  if degraded:
682
796
  out_lines.append(
683
797
  "_Knowledge graph could not be read (corrupt or invalid graph.json) — "
@@ -733,7 +847,7 @@ def build_context_pack(
733
847
 
734
848
  def _extract_code_windows(abs_path, lines_wanted, before=2, after=14, max_lines=110):
735
849
  try:
736
- with open(abs_path, errors="replace") as f:
850
+ with open(abs_path, encoding="utf-8", errors="replace") as f:
737
851
  src = f.read().splitlines()
738
852
  except OSError:
739
853
  return ""
@@ -757,14 +871,22 @@ def _extract_code_windows(abs_path, lines_wanted, before=2, after=14, max_lines=
757
871
 
758
872
 
759
873
  def build_context_pack_inline(
760
- root, task, top_files=3, budget_tokens=2500, skip_patterns=None, query_cfg=None
874
+ root, task, top_files=3, budget_tokens=2500, skip_patterns=None, query_cfg=None,
875
+ auto_rebuild=True,
761
876
  ):
762
877
  from codex_graph.graph_cache import DEFAULT_PACK_SKIP_PATTERNS, load_bundle
763
878
  from codex_graph.graph_query import query_files
764
879
 
765
880
  root = os.path.abspath(root)
881
+ rebuild_started = maybe_auto_rebuild(root, enabled=auto_rebuild)
766
882
  overarching_path = _overarching_graph_path(root)
767
883
  if not os.path.exists(overarching_path):
884
+ if rebuild_started:
885
+ return (
886
+ f"# Context for: {task}\n\n"
887
+ "Knowledge graph is being built automatically in the background — "
888
+ "retry this in ~30s.\n"
889
+ )
768
890
  return f"# Context for: {task}\n\nNo knowledge graph found.\n"
769
891
  if skip_patterns is None:
770
892
  skip_patterns = list(DEFAULT_PACK_SKIP_PATTERNS)
@@ -797,6 +919,8 @@ def build_context_pack_inline(
797
919
  note = staleness_note(root)
798
920
  if note:
799
921
  out += [note, ""]
922
+ if rebuild_started:
923
+ out += ["_Source files changed — automatic graph rebuild started in the background._", ""]
800
924
  out.append(
801
925
  "## Relevant code (extracted from the knowledge graph — already in context, do not re-open these files)"
802
926
  )
@@ -844,7 +968,10 @@ def build_context_pack_inline(
844
968
  text = "\n".join(out) + "\n"
845
969
  char_budget = max(budget_tokens, 0) * 4
846
970
  if char_budget and len(text) > char_budget:
847
- text = text[:char_budget].rstrip() + "\n```\n\n_(truncated to budget)_\n"
971
+ truncated = text[:char_budget].rstrip()
972
+ if truncated.count("```") % 2 == 1:
973
+ truncated += "\n```"
974
+ text = truncated + "\n\n_(truncated to budget)_\n"
848
975
  return text
849
976
 
850
977
 
@@ -904,7 +1031,12 @@ def run_map(
904
1031
  print(" Ensure an API key is available (e.g. ANTHROPIC_API_KEY or ANTHROPIC_KEY in a .env file).", file=sys.stderr)
905
1032
  return 1
906
1033
 
907
- bridges = _refresh(root, services, overarching_path, single=single)
1034
+ try:
1035
+ bridges = _refresh(root, services, overarching_path, single=single)
1036
+ except (OSError, json.JSONDecodeError) as exc:
1037
+ print(f"Error: extracted graph could not be read ({type(exc).__name__}: {exc}).", file=sys.stderr)
1038
+ print(" Re-run `graphnav map`; if it persists, delete graphify-out/ and try again.", file=sys.stderr)
1039
+ return 1
908
1040
  total_bridges = sum(len(rows) for rows in bridges.values())
909
1041
 
910
1042
  print(f"\nSetup complete. Your AI coding agents are now configured for this repo.")
@@ -950,7 +1082,10 @@ def run_watch(
950
1082
  print(f"Error: bootstrap extraction failed (exit {rc}).", file=sys.stderr)
951
1083
  return 1
952
1084
 
953
- _refresh(root, services, overarching_path, single=single)
1085
+ try:
1086
+ _refresh(root, services, overarching_path, single=single)
1087
+ except (OSError, json.JSONDecodeError) as exc:
1088
+ _warn(f"could not read graph.json ({type(exc).__name__}) — will retry as it updates")
954
1089
 
955
1090
  def _start_watch() -> subprocess.Popen:
956
1091
  return subprocess.Popen(
@@ -960,6 +1095,14 @@ def run_watch(
960
1095
  env=env,
961
1096
  )
962
1097
 
1098
+ def _sigterm(_signum, _frame):
1099
+ raise KeyboardInterrupt
1100
+
1101
+ try:
1102
+ signal.signal(signal.SIGTERM, _sigterm)
1103
+ except (ValueError, OSError):
1104
+ pass
1105
+
963
1106
  watch_proc = _start_watch()
964
1107
  backoff = RestartBackoff()
965
1108
  backoff.record_start(time.monotonic())
@@ -985,7 +1128,11 @@ def run_watch(
985
1128
  pending_mtime = None
986
1129
  ts = time.strftime("%H:%M:%S")
987
1130
  print(f"[graphnav] {ts} graph updated — refreshing symbols and bridges ...", file=sys.stderr)
988
- _refresh(root, services, overarching_path, single=single)
1131
+ try:
1132
+ _refresh(root, services, overarching_path, single=single)
1133
+ except (OSError, json.JSONDecodeError) as exc:
1134
+ _warn(f"could not read graph.json ({type(exc).__name__}) — will retry on next update")
1135
+ last_mtime = 0.0
989
1136
  else:
990
1137
  pending_mtime = mtime
991
1138
  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.3.0
4
4
  Summary: Knowledge-graph context injection for AI coding agents in monorepos
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -17,6 +17,7 @@ graphnav.egg-info/dependency_links.txt
17
17
  graphnav.egg-info/entry_points.txt
18
18
  graphnav.egg-info/requires.txt
19
19
  graphnav.egg-info/top_level.txt
20
+ tests/test_auto_rebuild.py
20
21
  tests/test_cli.py
21
22
  tests/test_config.py
22
23
  tests/test_doctor.py
@@ -25,4 +26,5 @@ tests/test_graph_nav.py
25
26
  tests/test_graph_query.py
26
27
  tests/test_mcp_server.py
27
28
  tests/test_multirepo.py
29
+ tests/test_robustness.py
28
30
  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.3.0"
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"]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from unittest.mock import MagicMock
6
+
7
+ import pytest
8
+
9
+ from codex_graph import multirepo
10
+ from codex_graph.config import load_config_report
11
+ from codex_graph.multirepo import graph_is_stale, maybe_auto_rebuild
12
+ from tests.conftest import write_graph
13
+
14
+
15
+ @pytest.fixture
16
+ def allow_auto(monkeypatch):
17
+ monkeypatch.delenv("GRAPHNAV_NO_AUTO_REBUILD", raising=False)
18
+
19
+
20
+ @pytest.fixture
21
+ def fake_popen(monkeypatch):
22
+ proc = MagicMock()
23
+ proc.pid = 99999999
24
+ popen = MagicMock(return_value=proc)
25
+ monkeypatch.setattr(multirepo.subprocess, "Popen", popen)
26
+ monkeypatch.setattr(multirepo, "_git_sha", lambda root: None)
27
+ monkeypatch.setattr("codex_graph.graph_cache._git_recency", lambda root: {})
28
+ return popen
29
+
30
+
31
+ def _make_repo(tmp_path, stale: bool):
32
+ src = tmp_path / "app.py"
33
+ src.write_text("def main():\n pass\n")
34
+ write_graph(tmp_path / "graphify-out" / "graph.json", [], [])
35
+ graph = tmp_path / "graphify-out" / "graph.json"
36
+ if stale:
37
+ os.utime(graph, (time.time() - 100, time.time() - 100))
38
+ else:
39
+ os.utime(src, (time.time() - 100, time.time() - 100))
40
+ return tmp_path
41
+
42
+
43
+ class TestStaleness:
44
+ def test_missing_graph_is_stale(self, tmp_path):
45
+ (tmp_path / "app.py").write_text("x = 1\n")
46
+ assert graph_is_stale(str(tmp_path)) is True
47
+
48
+ def test_fresh_graph_not_stale(self, tmp_path):
49
+ _make_repo(tmp_path, stale=False)
50
+ assert graph_is_stale(str(tmp_path)) is False
51
+
52
+ def test_edited_source_is_stale(self, tmp_path):
53
+ _make_repo(tmp_path, stale=True)
54
+ assert graph_is_stale(str(tmp_path)) is True
55
+
56
+
57
+ class TestMaybeAutoRebuild:
58
+ def test_spawns_on_stale(self, tmp_path, allow_auto, fake_popen):
59
+ _make_repo(tmp_path, stale=True)
60
+ assert maybe_auto_rebuild(str(tmp_path)) is True
61
+ fake_popen.assert_called_once()
62
+ argv = fake_popen.call_args[0][0]
63
+ assert argv[-2:] == ["--root", str(tmp_path)]
64
+ assert "map" in argv
65
+ pid_file = tmp_path / "graphify-out" / ".graphnav-rebuild.pid"
66
+ assert pid_file.read_text() == "99999999"
67
+
68
+ def test_no_spawn_when_fresh(self, tmp_path, allow_auto, fake_popen):
69
+ _make_repo(tmp_path, stale=False)
70
+ assert maybe_auto_rebuild(str(tmp_path)) is False
71
+ fake_popen.assert_not_called()
72
+
73
+ def test_no_spawn_when_disabled(self, tmp_path, allow_auto, fake_popen):
74
+ _make_repo(tmp_path, stale=True)
75
+ assert maybe_auto_rebuild(str(tmp_path), enabled=False) is False
76
+ fake_popen.assert_not_called()
77
+
78
+ def test_env_escape_hatch(self, tmp_path, monkeypatch, fake_popen):
79
+ monkeypatch.setenv("GRAPHNAV_NO_AUTO_REBUILD", "1")
80
+ _make_repo(tmp_path, stale=True)
81
+ assert maybe_auto_rebuild(str(tmp_path)) is False
82
+ fake_popen.assert_not_called()
83
+
84
+ def test_skips_when_rebuild_running(self, tmp_path, allow_auto, fake_popen):
85
+ _make_repo(tmp_path, stale=True)
86
+ pid_file = tmp_path / "graphify-out" / ".graphnav-rebuild.pid"
87
+ pid_file.write_text(str(os.getpid()))
88
+ assert maybe_auto_rebuild(str(tmp_path)) is False
89
+ fake_popen.assert_not_called()
90
+
91
+ def test_cooldown_after_dead_rebuild(self, tmp_path, allow_auto, fake_popen):
92
+ _make_repo(tmp_path, stale=True)
93
+ pid_file = tmp_path / "graphify-out" / ".graphnav-rebuild.pid"
94
+ pid_file.write_text("999999999")
95
+ assert maybe_auto_rebuild(str(tmp_path)) is False
96
+ fake_popen.assert_not_called()
97
+ old = time.time() - 120
98
+ os.utime(pid_file, (old, old))
99
+ assert maybe_auto_rebuild(str(tmp_path)) is True
100
+ fake_popen.assert_called_once()
101
+
102
+
103
+ class TestPackNotes:
104
+ def test_inline_pack_notes_rebuild(self, tmp_path, allow_auto, fake_popen, monkeypatch):
105
+ _make_repo(tmp_path, stale=True)
106
+ write_graph(
107
+ tmp_path / "graphify-out" / "graph.json",
108
+ [{"id": "m", "label": "main", "source_file": "app.py",
109
+ "file_type": "code", "source_location": "L1", "community": 0}],
110
+ [],
111
+ )
112
+ graph = tmp_path / "graphify-out" / "graph.json"
113
+ os.utime(graph, (time.time() - 100, time.time() - 100))
114
+ pack = multirepo.build_context_pack_inline(str(tmp_path), "main")
115
+ assert "automatic graph rebuild started" in pack
116
+
117
+ def test_no_graph_pack_reports_building(self, tmp_path, allow_auto, fake_popen):
118
+ (tmp_path / "app.py").write_text("def main():\n pass\n")
119
+ pack = multirepo.build_context_pack_inline(str(tmp_path), "main")
120
+ assert "being built automatically" in pack
121
+
122
+
123
+ class TestConfigKnob:
124
+ def test_auto_rebuild_default_true(self):
125
+ cfg, _, _ = load_config_report()
126
+ assert cfg.mono.auto_rebuild is True
127
+
128
+ def test_auto_rebuild_from_toml(self, tmp_path, monkeypatch):
129
+ (tmp_path / "config.toml").write_text("[mono]\nauto_rebuild = false\n")
130
+ monkeypatch.chdir(tmp_path)
131
+ cfg, _, warnings = load_config_report()
132
+ assert cfg.mono.auto_rebuild is False
133
+ assert warnings == []
134
+
135
+ def test_wrong_typed_auto_rebuild(self, tmp_path, monkeypatch):
136
+ (tmp_path / "config.toml").write_text('[mono]\nauto_rebuild = "yes"\n')
137
+ monkeypatch.chdir(tmp_path)
138
+ cfg, _, warnings = load_config_report()
139
+ assert cfg.mono.auto_rebuild is True
140
+ assert any("true or false" in w for w in warnings)
@@ -123,7 +123,7 @@ class TestContextCommand:
123
123
  def test_context_forwards_budget_and_files(self, tmp_path, monkeypatch):
124
124
  captured = {}
125
125
 
126
- def fake_pack(root, task, skip_patterns=None, top_files=None, budget_tokens=None, query_cfg=None):
126
+ def fake_pack(root, task, skip_patterns=None, top_files=None, budget_tokens=None, query_cfg=None, **kw):
127
127
  captured["top_files"] = top_files
128
128
  captured["budget_tokens"] = budget_tokens
129
129
  return ""
@@ -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