graphnav 1.2.1__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.1/graphnav.egg-info → graphnav-1.2.3}/PKG-INFO +1 -1
  2. {graphnav-1.2.1 → graphnav-1.2.3}/README.md +36 -21
  3. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/cli.py +47 -22
  4. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/config.py +49 -2
  5. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/doctor.py +5 -3
  6. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/graph_cache.py +1 -1
  7. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/graph_nav.py +17 -8
  8. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/graph_query.py +1 -1
  9. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/mcp_server.py +2 -1
  10. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/multirepo.py +138 -38
  11. {graphnav-1.2.1 → graphnav-1.2.3/graphnav.egg-info}/PKG-INFO +1 -1
  12. {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/SOURCES.txt +1 -0
  13. graphnav-1.2.3/graphnav.egg-info/entry_points.txt +2 -0
  14. {graphnav-1.2.1 → graphnav-1.2.3}/pyproject.toml +2 -2
  15. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_cli.py +23 -4
  16. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_doctor.py +2 -2
  17. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_multirepo.py +25 -3
  18. graphnav-1.2.3/tests/test_robustness.py +201 -0
  19. graphnav-1.2.1/graphnav.egg-info/entry_points.txt +0 -2
  20. {graphnav-1.2.1 → graphnav-1.2.3}/LICENSE +0 -0
  21. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/__init__.py +0 -0
  22. {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/runner.py +0 -0
  23. {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/dependency_links.txt +0 -0
  24. {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/requires.txt +0 -0
  25. {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/top_level.txt +0 -0
  26. {graphnav-1.2.1 → graphnav-1.2.3}/setup.cfg +0 -0
  27. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_config.py +0 -0
  28. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_graph_cache.py +0 -0
  29. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_graph_nav.py +0 -0
  30. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_graph_query.py +0 -0
  31. {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_mcp_server.py +0 -0
  32. {graphnav-1.2.1 → 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.1
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
@@ -27,13 +27,22 @@ GraphNav solves this by:
27
27
 
28
28
  ---
29
29
 
30
- ## Install
30
+ ## Setup is one command
31
31
 
32
32
  ```bash
33
33
  pip install graphnav
34
+ graphnav
34
35
  ```
35
36
 
36
- Requires Python 3.11. Pulls `graphifyy` (the `graphify` binary) automatically.
37
+ That's the whole setup. Run `graphnav` from your project root and it:
38
+
39
+ 1. Auto-detects your project — a **single folder** or a **monorepo** of services
40
+ 2. Builds the knowledge graph
41
+ 3. Writes the agent instruction files
42
+
43
+ Then open the repo in your AI coding tool and start working. **There is nothing else to run.**
44
+
45
+ Requires Python ≥ 3.11. Pulls `graphifyy` (the `graphify` binary) automatically — including under `pipx`, `--user`, and virtualenv installs.
37
46
 
38
47
  **API key:** Place a `.env` file anywhere up the directory tree from your project (or inside any service subfolder). graphnav walks up and down to find it:
39
48
 
@@ -42,22 +51,7 @@ OPENAI_API_KEY=sk-...
42
51
  ANTHROPIC_KEY=sk-ant-...
43
52
  ```
44
53
 
45
- ---
46
-
47
- ## Quickstart
48
-
49
- ```bash
50
- # In your monorepo root — detects services, builds graphs, writes agent instructions
51
- graphnav map
52
-
53
- # Get a context pack for a task (free, no LLM, ~instant)
54
- graphnav context "add a critique scoring function to the coach"
55
-
56
- # Keep graphs live as you edit
57
- graphnav watch
58
- ```
59
-
60
- After `map`, every AI agent in the repo has access to:
54
+ After running `graphnav`, every AI agent in the repo has access to:
61
55
 
62
56
  - **`CLAUDE.md`** — picked up by Claude Code
63
57
  - **`AGENTS.md`** — picked up by OpenAI Codex CLI
@@ -66,13 +60,34 @@ After `map`, every AI agent in the repo has access to:
66
60
  - **`<service>/graphify-out/BRIDGES.md`** — exact cross-service call sites with line numbers
67
61
  - **`graphify-out/MONOREPO_MAP.md`** — overview of all services and their connections
68
62
 
63
+ ### Optional
64
+
65
+ ```bash
66
+ graphnav watch # keep the graph live as you edit
67
+ graphnav doctor # diagnose the setup if something looks wrong
68
+ ```
69
+
69
70
  ---
70
71
 
71
72
  ## Commands
72
73
 
74
+ > **You only need `graphnav`.** Running it bare does the full setup (it runs `map` for you). `watch` and `doctor` are the only other commands you'd type by hand. Everything below `graphnav watch` — `context`, `serve`, `find`, `neighbors`, `impact` — is meant for your **AI agent** to call automatically (via the generated instruction files or the MCP server), not for you to run manually. They're documented here for completeness.
75
+
76
+ ### `graphnav` (just this)
77
+
78
+ From your project root, run `graphnav` with no arguments. It auto-detects whether you have a single-folder project or a monorepo, builds the knowledge graph, writes all agent instruction files, and stops. This is the one command you run.
79
+
80
+ ```
81
+ graphnav
82
+ ```
83
+
84
+ For a single-folder project it maps the whole repo as one graph; for a monorepo it builds per-service graphs and cross-service bridges. Equivalent to running `graphnav map` explicitly.
85
+
86
+ ---
87
+
73
88
  ### `graphnav map`
74
89
 
75
- Builds the knowledge graph and generates all agent instruction files.
90
+ The build step that bare `graphnav` runs for you. Builds the knowledge graph and generates all agent instruction files.
76
91
 
77
92
  ```
78
93
  graphnav map [--root PATH] [--backend BACKEND] [--dry-run]
@@ -212,7 +227,7 @@ It checks the `graphify` binary, the config file (and reports any validation war
212
227
 
213
228
  ### `graphnav` (no subcommand)
214
229
 
215
- If run with no arguments in a monorepo root, auto-detects services and runs `map` automatically. If a prompt is given, falls through to the context-injection path for the Codex CLI.
230
+ Run with no arguments from any project root, it auto-detects the project shape (single folder or monorepo) and runs the full setup automatically. If a prompt is given, it falls through to the context-injection path for the Codex CLI.
216
231
 
217
232
  ---
218
233
 
@@ -344,7 +359,7 @@ ANTHROPIC_KEY=sk-ant-...
344
359
  Then:
345
360
 
346
361
  ```bash
347
- graphnav map # one-time setup, or re-run after large refactors
362
+ graphnav # one-time setup, or re-run after large refactors
348
363
  graphnav watch # optional: keep graphs live during active development
349
364
  ```
350
365
 
@@ -51,13 +51,16 @@ def _auto_map_if_needed(cfg_path: str | None) -> None:
51
51
 
52
52
  cfg = load_config(cfg_path)
53
53
  root = os.path.abspath(".")
54
- services = multirepo.detect_services(root, cfg.mono.marker_files, cfg.mono.extra_skip_dirs)
54
+ services, single = multirepo.resolve_services(root, cfg.mono.marker_files, cfg.mono.extra_skip_dirs)
55
55
  if not services:
56
- return
56
+ print("[graphnav] No source code found here. Run graphnav from your project's root directory.", file=sys.stderr)
57
+ sys.exit(1)
57
58
 
58
- names = ", ".join(s.name for s in services)
59
- print(f"[graphnav] Detected {len(services)} service(s): {names}")
60
- print(f"[graphnav] Running 'graphnav map' to build knowledge graphs ...", file=sys.stderr)
59
+ if single:
60
+ print(f"[graphnav] Single project detected. Building knowledge graph ...", file=sys.stderr)
61
+ else:
62
+ names = ", ".join(s.name for s in services)
63
+ print(f"[graphnav] Detected {len(services)} service(s): {names}. Building knowledge graphs ...", file=sys.stderr)
61
64
  rc = multirepo.run_map(root=root, mono_cfg=cfg.mono)
62
65
  sys.exit(rc)
63
66
 
@@ -138,10 +141,17 @@ def _run_graph_query_command(kind: str, argv: list[str]) -> None:
138
141
  print(tools.impact(args.term))
139
142
  sys.exit(0)
140
143
 
141
- nav = load_bundle(
142
- graph_path, cfg.graph.skip_patterns,
143
- relation_weights=cfg.query.edge_relation_weights, repo_root=root,
144
- ).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)
145
155
 
146
156
  if kind == "find":
147
157
  hits = nav.find_symbols(args.term, k=10)
@@ -216,16 +226,20 @@ def main() -> None:
216
226
  parser = argparse.ArgumentParser(
217
227
  prog="graphnav",
218
228
  description=(
219
- "Codex CLI with knowledge-graph context injection for monorepos.\n\n"
220
- "First-run (after pip install): just run 'graphnav' or 'graphnav map'\n"
221
- "in your monorepo root — services are auto-detected and graphs are built.\n\n"
222
- "Subcommands:\n"
223
- " map Build per-service graphs and cross-service bridge notes\n"
224
- " watch Keep graphs and bridge notes up-to-date as files change\n"
225
- " context Print a token-budgeted context pack for a task (free, no LLM)\n"
226
- " serve Run the MCP server so AI agents call the graph tools natively\n"
227
- " find Find symbols by query; neighbors/impact show a symbol's blast radius\n"
228
- " doctor Diagnose the setup (graphify binary, config, graph, API key, cache)"
229
+ "GraphNav knowledge-graph context for AI coding agents.\n\n"
230
+ "Setup is ONE command. From your project root, just run:\n"
231
+ " graphnav\n"
232
+ "It auto-detects your project (single folder or monorepo), builds the\n"
233
+ "knowledge graph, and writes the agent instruction files. Nothing else\n"
234
+ "to run open the repo in your AI coding tool and start working.\n\n"
235
+ "Optional commands:\n"
236
+ " watch Keep the graph live as you edit\n"
237
+ " doctor Diagnose the setup if something looks wrong\n\n"
238
+ "Commands your AI agent calls for you (you rarely run these by hand):\n"
239
+ " context Token-budgeted context pack for a task (free, no LLM)\n"
240
+ " serve MCP server exposing the graph tools natively\n"
241
+ " find / neighbors / impact Symbol lookup and blast-radius queries\n"
242
+ " map The build step that `graphnav` runs for you"
229
243
  ),
230
244
  formatter_class=argparse.RawDescriptionHelpFormatter,
231
245
  )
@@ -243,8 +257,7 @@ def main() -> None:
243
257
  if not prompt:
244
258
  if sys.stdin.isatty():
245
259
  _auto_map_if_needed(args.config)
246
- parser.print_help()
247
- sys.exit(1)
260
+ return
248
261
  prompt = sys.stdin.read().strip()
249
262
  if not prompt:
250
263
  parser.print_help()
@@ -278,6 +291,10 @@ def main() -> None:
278
291
  except GraphNotFoundError as e:
279
292
  print(f"Error: {e}", file=sys.stderr)
280
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)
281
298
 
282
299
  ranked = query_files(
283
300
  prompt,
@@ -311,5 +328,13 @@ def main() -> None:
311
328
  sys.exit(124)
312
329
 
313
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
+
314
339
  if __name__ == "__main__":
315
- 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
@@ -17,24 +18,80 @@ def find_graphify() -> str | None:
17
18
  path = shutil.which("graphify")
18
19
  if path:
19
20
  return path
20
- candidate = os.path.join(os.path.dirname(sys.executable), "graphify")
21
- if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
22
- return candidate
21
+ exe = "graphify.exe" if os.name == "nt" else "graphify"
22
+ search_dirs = [os.path.dirname(sys.executable)]
23
+ import sysconfig
24
+
25
+ for scheme in sysconfig.get_scheme_names():
26
+ try:
27
+ d = sysconfig.get_path("scripts", scheme)
28
+ except Exception:
29
+ d = None
30
+ if d:
31
+ search_dirs.append(d)
32
+ seen: set[str] = set()
33
+ for d in search_dirs:
34
+ if not d or d in seen:
35
+ continue
36
+ seen.add(d)
37
+ candidate = os.path.join(d, exe)
38
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
39
+ return candidate
23
40
  return None
24
41
 
25
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
+
62
+ def resolve_services(
63
+ root: str,
64
+ marker_files: list[str],
65
+ extra_skip_dirs: list[str] | None = None,
66
+ ) -> tuple[list[ServiceInfo], bool]:
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
71
+ skip_dirs = SKIP_DIRS | frozenset(extra_skip_dirs or ())
72
+ if _has_source_files(root, skip_dirs=skip_dirs):
73
+ name = os.path.basename(os.path.abspath(root).rstrip(os.sep)) or "repo"
74
+ root_service = ServiceInfo(
75
+ name=name,
76
+ abs_path=root,
77
+ graph_path=_overarching_graph_path(root),
78
+ )
79
+ return [root_service], True
80
+ return [], False
81
+
82
+
26
83
  def _warn(msg: str) -> None:
27
84
  print(f"[graphnav] warning: {msg}", file=sys.stderr)
28
85
 
29
86
 
30
87
  def _write_if_changed(path: str, content: str) -> bool:
31
88
  try:
32
- with open(path) as f:
89
+ with open(path, encoding="utf-8") as f:
33
90
  if f.read() == content:
34
91
  return False
35
- except OSError:
92
+ except (OSError, UnicodeDecodeError):
36
93
  pass
37
- with open(path, "w") as f:
94
+ with open(path, "w", encoding="utf-8") as f:
38
95
  f.write(content)
39
96
  return True
40
97
 
@@ -68,7 +125,7 @@ def _find_env_file(start: str) -> str | None:
68
125
  def _parse_env_file(path: str) -> dict[str, str]:
69
126
  env_vars: dict[str, str] = {}
70
127
  try:
71
- with open(path) as f:
128
+ with open(path, encoding="utf-8-sig") as f:
72
129
  for line in f:
73
130
  line = line.strip()
74
131
  if not line or line.startswith("#") or "=" not in line:
@@ -102,13 +159,22 @@ def _env_file_sources(root: str) -> list[str]:
102
159
  return sources
103
160
 
104
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
+
105
170
  def _load_env_file(root: str) -> dict[str, str]:
106
171
  env_vars: dict[str, str] = {}
107
172
  for path in _env_file_sources(root):
108
173
  for key, value in _parse_env_file(path).items():
109
174
  env_vars.setdefault(key, value)
110
- if "ANTHROPIC_KEY" in env_vars and "ANTHROPIC_API_KEY" not in env_vars:
111
- 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]
112
178
  return env_vars
113
179
 
114
180
 
@@ -280,7 +346,7 @@ def write_graph_meta(root: str) -> None:
280
346
  meta = {"built_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "git_sha": _git_sha(root)}
281
347
  path = _graph_meta_path(root)
282
348
  os.makedirs(os.path.dirname(path), exist_ok=True)
283
- with open(path, "w") as f:
349
+ with open(path, "w", encoding="utf-8") as f:
284
350
  json.dump(meta, f, indent=2)
285
351
 
286
352
 
@@ -289,7 +355,7 @@ def staleness_note(root: str) -> str:
289
355
  if not os.path.exists(path):
290
356
  return ""
291
357
  try:
292
- with open(path) as f:
358
+ with open(path, encoding="utf-8") as f:
293
359
  meta = json.load(f)
294
360
  except (OSError, json.JSONDecodeError):
295
361
  return ""
@@ -335,7 +401,7 @@ def partition_graph(
335
401
  overarching_graph_path: str,
336
402
  services: list[ServiceInfo],
337
403
  ) -> dict[str, int]:
338
- with open(overarching_graph_path) as f:
404
+ with open(overarching_graph_path, encoding="utf-8") as f:
339
405
  graph = json.load(f)
340
406
 
341
407
  service_names = {s.name for s in services}
@@ -378,7 +444,7 @@ def analyze_bridges(
378
444
  overarching_graph_path: str,
379
445
  services: list[ServiceInfo],
380
446
  ) -> dict[str, list[BridgeRow]]:
381
- with open(overarching_graph_path) as f:
447
+ with open(overarching_graph_path, encoding="utf-8") as f:
382
448
  graph = json.load(f)
383
449
 
384
450
  service_names = {s.name for s in services}
@@ -471,7 +537,7 @@ def write_symbols_md(service: ServiceInfo) -> str:
471
537
  os.makedirs(out_dir, exist_ok=True)
472
538
  path = os.path.join(out_dir, "SYMBOLS.md")
473
539
  try:
474
- with open(service.graph_path) as f:
540
+ with open(service.graph_path, encoding="utf-8") as f:
475
541
  graph = json.load(f)
476
542
  except (OSError, json.JSONDecodeError) as exc:
477
543
  _warn(f"could not read {service.graph_path} ({type(exc).__name__}) — symbols index will be empty")
@@ -553,7 +619,7 @@ def _write_managed_block(path: str, content: str) -> None:
553
619
  existing = ""
554
620
  if os.path.exists(path):
555
621
  try:
556
- with open(path) as f:
622
+ with open(path, encoding="utf-8", errors="replace") as f:
557
623
  existing = f.read()
558
624
  except OSError:
559
625
  existing = ""
@@ -697,7 +763,7 @@ def build_context_pack(
697
763
 
698
764
  def _extract_code_windows(abs_path, lines_wanted, before=2, after=14, max_lines=110):
699
765
  try:
700
- with open(abs_path, errors="replace") as f:
766
+ with open(abs_path, encoding="utf-8", errors="replace") as f:
701
767
  src = f.read().splitlines()
702
768
  except OSError:
703
769
  return ""
@@ -808,7 +874,10 @@ def build_context_pack_inline(
808
874
  text = "\n".join(out) + "\n"
809
875
  char_budget = max(budget_tokens, 0) * 4
810
876
  if char_budget and len(text) > char_budget:
811
- 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"
812
881
  return text
813
882
 
814
883
 
@@ -816,9 +885,13 @@ def _refresh(
816
885
  root: str,
817
886
  services: list[ServiceInfo],
818
887
  overarching_graph_path: str,
888
+ single: bool = False,
819
889
  ) -> dict[str, list[BridgeRow]]:
820
- partition_graph(overarching_graph_path, services)
821
- bridges = analyze_bridges(overarching_graph_path, services)
890
+ if single:
891
+ bridges = {s.name: [] for s in services}
892
+ else:
893
+ partition_graph(overarching_graph_path, services)
894
+ bridges = analyze_bridges(overarching_graph_path, services)
822
895
  for svc in services:
823
896
  write_bridges_md(svc, bridges[svc.name])
824
897
  write_symbols_md(svc)
@@ -840,13 +913,14 @@ def run_map(
840
913
  print("Error: 'graphify' not found. Install with: pip install graphifyy", file=sys.stderr)
841
914
  return 1
842
915
 
843
- services = detect_services(root, mono_cfg.marker_files, mono_cfg.extra_skip_dirs)
916
+ services, single = resolve_services(root, mono_cfg.marker_files, mono_cfg.extra_skip_dirs)
844
917
  if not services:
845
- print(f"No services detected in {root}. Add code to subdirectories (or marker files like package.json/pyproject.toml).", file=sys.stderr)
918
+ print(f"No source code found in {root}. Run graphnav from a directory that contains code.", file=sys.stderr)
846
919
  return 1
847
920
 
921
+ shape = "whole repo (single project)" if single else f"{len(services)} service(s): {', '.join(s.name for s in services)}"
848
922
  if dry_run:
849
- print(f"Detected {len(services)} service(s):")
923
+ print(f"Detected {shape}:")
850
924
  for svc in services:
851
925
  print(f" {svc.name} {svc.abs_path}")
852
926
  print("[dry-run] No graphify calls made.")
@@ -856,23 +930,34 @@ def run_map(
856
930
  env = _build_subprocess_env(root)
857
931
  overarching_path = _overarching_graph_path(root)
858
932
 
859
- print(f"[graphnav] Building overarching graph across {len(services)} service(s): {', '.join(s.name for s in services)}", file=sys.stderr)
933
+ print(f"[graphnav] Building knowledge graph for {shape} ...", file=sys.stderr)
860
934
  rc = build_overarching_graph(root, graphify_path, backend, env=env)
861
935
  if rc != 0 or not os.path.exists(overarching_path):
862
- print(f"Error: overarching graphify extraction failed (exit {rc}).", file=sys.stderr)
936
+ print(f"Error: graphify extraction failed (exit {rc}).", file=sys.stderr)
863
937
  print(" Ensure an API key is available (e.g. ANTHROPIC_API_KEY or ANTHROPIC_KEY in a .env file).", file=sys.stderr)
864
938
  return 1
865
939
 
866
- bridges = _refresh(root, services, overarching_path)
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
867
946
  total_bridges = sum(len(rows) for rows in bridges.values())
868
947
 
869
- print(f"\nDone. {len(services)} service(s) mapped, {total_bridges} cross-service connection(s) found.")
870
- print(f" Overarching graph : {overarching_path}")
871
- for svc in services:
872
- to = ", ".join(svc.bridges_to) if svc.bridges_to else "none"
873
- print(f" {svc.name}/graphify-out/ (bridges -> {to})")
874
- print(f" Monorepo map : {os.path.join(root, 'graphify-out', 'MONOREPO_MAP.md')}")
875
- print(f" Copilot instructions : {os.path.join(root, '.github', 'copilot-instructions.md')}")
948
+ print(f"\nSetup complete. Your AI coding agents are now configured for this repo.")
949
+ if single:
950
+ print(f" Knowledge graph : {overarching_path}")
951
+ print(f" Symbol index : {os.path.join(root, 'graphify-out', 'SYMBOLS.md')}")
952
+ else:
953
+ print(f" {len(services)} service(s) mapped, {total_bridges} cross-service connection(s) found.")
954
+ print(f" Overarching graph : {overarching_path}")
955
+ for svc in services:
956
+ to = ", ".join(svc.bridges_to) if svc.bridges_to else "none"
957
+ print(f" {svc.name}/graphify-out/ (bridges -> {to})")
958
+ print(f" Agent instructions : CLAUDE.md, AGENTS.md, .github/copilot-instructions.md")
959
+ print(f"\nNothing else to run. Open the repo in your AI coding tool and start working.")
960
+ print(f"(Optional: `graphnav watch` keeps the graph live as you edit.)")
876
961
  return 0
877
962
 
878
963
 
@@ -887,9 +972,9 @@ def run_watch(
887
972
  print("Error: 'graphify' not found. Install with: pip install graphifyy", file=sys.stderr)
888
973
  return 1
889
974
 
890
- services = detect_services(root, mono_cfg.marker_files, mono_cfg.extra_skip_dirs)
975
+ services, single = resolve_services(root, mono_cfg.marker_files, mono_cfg.extra_skip_dirs)
891
976
  if not services:
892
- print(f"No services detected in {root}.", file=sys.stderr)
977
+ print(f"No source code found in {root}.", file=sys.stderr)
893
978
  return 1
894
979
 
895
980
  backend = backend_override or mono_cfg.graphify_backend
@@ -897,13 +982,16 @@ def run_watch(
897
982
  overarching_path = _overarching_graph_path(root)
898
983
 
899
984
  if not os.path.exists(overarching_path):
900
- print(f"[graphnav] Bootstrapping overarching graph for {len(services)} service(s) ...", file=sys.stderr)
985
+ print(f"[graphnav] Bootstrapping knowledge graph ...", file=sys.stderr)
901
986
  rc = build_overarching_graph(root, graphify_path, backend, env=env)
902
987
  if rc != 0 or not os.path.exists(overarching_path):
903
988
  print(f"Error: bootstrap extraction failed (exit {rc}).", file=sys.stderr)
904
989
  return 1
905
990
 
906
- _refresh(root, services, overarching_path)
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")
907
995
 
908
996
  def _start_watch() -> subprocess.Popen:
909
997
  return subprocess.Popen(
@@ -913,6 +1001,14 @@ def run_watch(
913
1001
  env=env,
914
1002
  )
915
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
+
916
1012
  watch_proc = _start_watch()
917
1013
  backoff = RestartBackoff()
918
1014
  backoff.record_start(time.monotonic())
@@ -937,8 +1033,12 @@ def run_watch(
937
1033
  last_mtime = mtime
938
1034
  pending_mtime = None
939
1035
  ts = time.strftime("%H:%M:%S")
940
- print(f"[graphnav] {ts} graph updated — re-partitioning and re-analyzing bridges ...", file=sys.stderr)
941
- _refresh(root, services, overarching_path)
1036
+ print(f"[graphnav] {ts} graph updated — refreshing symbols and bridges ...", file=sys.stderr)
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
942
1042
  else:
943
1043
  pending_mtime = mtime
944
1044
  elif mtime != last_mtime:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphnav
3
- Version: 1.2.1
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.1"
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"]
@@ -170,7 +170,7 @@ class TestAutoMap:
170
170
  assert exc.value.code == 0
171
171
  assert str(tmp_path) == called_with["root"]
172
172
 
173
- def test_no_args_without_services_shows_help(self, tmp_path, monkeypatch, capsys):
173
+ def test_no_args_without_source_exits_with_guidance(self, tmp_path, monkeypatch, capsys):
174
174
  monkeypatch.setattr(sys, "argv", ["codex-graph"])
175
175
  monkeypatch.chdir(tmp_path)
176
176
  monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
@@ -178,8 +178,27 @@ class TestAutoMap:
178
178
  from codex_graph.cli import main
179
179
  main()
180
180
  assert exc.value.code == 1
181
- out = capsys.readouterr().out
182
- assert "graphnav" in out
181
+ err = capsys.readouterr().err
182
+ assert "No source code" in err
183
+
184
+ def test_no_args_flat_repo_runs_map(self, tmp_path, monkeypatch, capsys):
185
+ (tmp_path / "app.py").write_text("def main():\n return 1\n")
186
+ monkeypatch.setattr(sys, "argv", ["codex-graph"])
187
+ monkeypatch.chdir(tmp_path)
188
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
189
+
190
+ called = {}
191
+
192
+ def fake_run_map(root, mono_cfg, backend_override=None, dry_run=False):
193
+ called["root"] = root
194
+ return 0
195
+
196
+ monkeypatch.setattr("codex_graph.multirepo.run_map", fake_run_map)
197
+ with pytest.raises(SystemExit) as exc:
198
+ from codex_graph.cli import main
199
+ main()
200
+ assert exc.value.code == 0
201
+ assert called["root"] == str(tmp_path)
183
202
 
184
203
 
185
204
  class TestExistingPromptPathUnaffected:
@@ -275,7 +294,7 @@ class TestDoctorDispatch:
275
294
  def test_doctor_empty_root_fails(self, tmp_path, monkeypatch, capsys):
276
295
  from codex_graph import doctor
277
296
 
278
- monkeypatch.setattr(doctor.shutil, "which", lambda _: None)
297
+ monkeypatch.setattr(doctor, "find_graphify", lambda: None)
279
298
  monkeypatch.setattr(sys, "argv", ["graphnav", "doctor", "--root", str(tmp_path)])
280
299
  with pytest.raises(SystemExit) as exc:
281
300
  from codex_graph.cli import main
@@ -27,7 +27,7 @@ def fresh_memo():
27
27
 
28
28
  @pytest.fixture
29
29
  def fake_graphify(monkeypatch):
30
- monkeypatch.setattr(doctor.shutil, "which", lambda _: "/usr/bin/graphify")
30
+ monkeypatch.setattr(doctor, "find_graphify", lambda: "/usr/bin/graphify")
31
31
  monkeypatch.setattr(
32
32
  doctor.subprocess, "run",
33
33
  lambda *a, **k: subprocess.CompletedProcess(a, 0, stdout="graphify 0.9", stderr=""),
@@ -61,7 +61,7 @@ class TestDoctorAllPass:
61
61
 
62
62
  class TestDoctorGraphifyMissing:
63
63
  def test_missing_binary_fails(self, healthy_repo, monkeypatch, capsys):
64
- monkeypatch.setattr(doctor.shutil, "which", lambda _: None)
64
+ monkeypatch.setattr(doctor, "find_graphify", lambda: None)
65
65
  rc = run_doctor(str(healthy_repo))
66
66
  out = capsys.readouterr().out
67
67
  assert rc == 1
@@ -28,6 +28,7 @@ from codex_graph.multirepo import (
28
28
  build_playbook_text,
29
29
  detect_services,
30
30
  partition_graph,
31
+ resolve_services,
31
32
  run_extract,
32
33
  run_map,
33
34
  run_watch,
@@ -188,6 +189,27 @@ class TestDetectServices:
188
189
  assert result[0].graph_path == str(d / "graphify-out" / "graph.json")
189
190
  assert result[0].bridges_to == []
190
191
 
192
+ def test_resolve_falls_back_to_whole_repo_when_no_subdir_services(self, tmp_path):
193
+ (tmp_path / "app.py").write_text("def main():\n return 1\n")
194
+ services, single = resolve_services(str(tmp_path), ["pyproject.toml"])
195
+ assert single is True
196
+ assert len(services) == 1
197
+ assert services[0].abs_path == str(tmp_path)
198
+ assert services[0].graph_path == str(tmp_path / "graphify-out" / "graph.json")
199
+
200
+ def test_resolve_prefers_subdir_services(self, tmp_path):
201
+ d = tmp_path / "svc-a"
202
+ d.mkdir()
203
+ (d / "pyproject.toml").touch()
204
+ services, single = resolve_services(str(tmp_path), ["pyproject.toml"])
205
+ assert single is False
206
+ assert [s.name for s in services] == ["svc-a"]
207
+
208
+ def test_resolve_empty_dir_returns_nothing(self, tmp_path):
209
+ services, single = resolve_services(str(tmp_path), ["pyproject.toml"])
210
+ assert services == []
211
+ assert single is False
212
+
191
213
  def test_services_returned_in_sorted_order(self, tmp_path):
192
214
  for name in ("zebra", "alpha", "middle"):
193
215
  d = tmp_path / name
@@ -1177,11 +1199,11 @@ class TestRunMap:
1177
1199
  monkeypatch.setattr("codex_graph.multirepo.shutil.which", lambda _: "/graphify")
1178
1200
  roots_seen = []
1179
1201
 
1180
- def fake_detect(root, markers, extra_skip_dirs=None):
1202
+ def fake_resolve(root, markers, extra_skip_dirs=None):
1181
1203
  roots_seen.append(root)
1182
- return []
1204
+ return [], False
1183
1205
 
1184
- monkeypatch.setattr("codex_graph.multirepo.detect_services", fake_detect)
1206
+ monkeypatch.setattr("codex_graph.multirepo.resolve_services", fake_resolve)
1185
1207
  run_map(".", MonoConfig())
1186
1208
  assert os.path.isabs(roots_seen[0])
1187
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