graphnav 1.2.2__tar.gz → 1.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphnav-1.2.2/graphnav.egg-info → graphnav-1.2.3}/PKG-INFO +1 -1
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/cli.py +24 -5
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/config.py +49 -2
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/doctor.py +5 -3
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/graph_cache.py +1 -1
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/graph_nav.py +17 -8
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/graph_query.py +1 -1
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/mcp_server.py +2 -1
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/multirepo.py +73 -20
- {graphnav-1.2.2 → graphnav-1.2.3/graphnav.egg-info}/PKG-INFO +1 -1
- {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/SOURCES.txt +1 -0
- graphnav-1.2.3/graphnav.egg-info/entry_points.txt +2 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/pyproject.toml +2 -2
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_multirepo.py +3 -4
- graphnav-1.2.3/tests/test_robustness.py +201 -0
- graphnav-1.2.2/graphnav.egg-info/entry_points.txt +0 -2
- {graphnav-1.2.2 → graphnav-1.2.3}/LICENSE +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/README.md +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/__init__.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/codex_graph/runner.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/dependency_links.txt +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/requires.txt +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/graphnav.egg-info/top_level.txt +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/setup.cfg +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_cli.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_config.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_doctor.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_graph_cache.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_graph_nav.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_graph_query.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_mcp_server.py +0 -0
- {graphnav-1.2.2 → graphnav-1.2.3}/tests/test_runner.py +0 -0
|
@@ -141,10 +141,17 @@ def _run_graph_query_command(kind: str, argv: list[str]) -> None:
|
|
|
141
141
|
print(tools.impact(args.term))
|
|
142
142
|
sys.exit(0)
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
import json as _json
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
nav = load_bundle(
|
|
148
|
+
graph_path, cfg.graph.skip_patterns,
|
|
149
|
+
relation_weights=cfg.query.edge_relation_weights, repo_root=root,
|
|
150
|
+
).nav
|
|
151
|
+
except (_json.JSONDecodeError, KeyError, OSError) as exc:
|
|
152
|
+
print(f"Error: could not read {graph_path} ({type(exc).__name__}: {exc}).", file=sys.stderr)
|
|
153
|
+
print("Run `graphnav map` to rebuild the graph.", file=sys.stderr)
|
|
154
|
+
sys.exit(2)
|
|
148
155
|
|
|
149
156
|
if kind == "find":
|
|
150
157
|
hits = nav.find_symbols(args.term, k=10)
|
|
@@ -284,6 +291,10 @@ def main() -> None:
|
|
|
284
291
|
except GraphNotFoundError as e:
|
|
285
292
|
print(f"Error: {e}", file=sys.stderr)
|
|
286
293
|
sys.exit(2)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f"Error: could not read {graph_path} ({type(e).__name__}: {e}).", file=sys.stderr)
|
|
296
|
+
print("Run `graphnav map` to rebuild the graph.", file=sys.stderr)
|
|
297
|
+
sys.exit(2)
|
|
287
298
|
|
|
288
299
|
ranked = query_files(
|
|
289
300
|
prompt,
|
|
@@ -317,5 +328,13 @@ def main() -> None:
|
|
|
317
328
|
sys.exit(124)
|
|
318
329
|
|
|
319
330
|
|
|
331
|
+
def entry() -> None:
|
|
332
|
+
try:
|
|
333
|
+
main()
|
|
334
|
+
except KeyboardInterrupt:
|
|
335
|
+
print("\n[graphnav] interrupted", file=sys.stderr)
|
|
336
|
+
sys.exit(130)
|
|
337
|
+
|
|
338
|
+
|
|
320
339
|
if __name__ == "__main__":
|
|
321
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import sys
|
|
5
6
|
|
|
@@ -46,7 +47,7 @@ class GraphTools:
|
|
|
46
47
|
relation_weights=self.query_cfg.edge_relation_weights,
|
|
47
48
|
repo_root=self.root,
|
|
48
49
|
).nav
|
|
49
|
-
except GraphNotFoundError:
|
|
50
|
+
except (GraphNotFoundError, OSError, json.JSONDecodeError, KeyError):
|
|
50
51
|
return None
|
|
51
52
|
|
|
52
53
|
def graph_context(self, task: str) -> str:
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import shutil
|
|
7
|
+
import signal
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
9
10
|
import threading
|
|
@@ -39,14 +40,34 @@ def find_graphify() -> str | None:
|
|
|
39
40
|
return None
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
def _any_subdir_has_marker(
|
|
44
|
+
root: str,
|
|
45
|
+
marker_files: list[str],
|
|
46
|
+
extra_skip_dirs: list[str] | None = None,
|
|
47
|
+
) -> bool:
|
|
48
|
+
skip_dirs = SKIP_DIRS | frozenset(extra_skip_dirs or ())
|
|
49
|
+
try:
|
|
50
|
+
entries = os.listdir(root)
|
|
51
|
+
except OSError:
|
|
52
|
+
return False
|
|
53
|
+
for entry in entries:
|
|
54
|
+
abs_path = os.path.join(root, entry)
|
|
55
|
+
if not os.path.isdir(abs_path) or entry in skip_dirs or entry.startswith("."):
|
|
56
|
+
continue
|
|
57
|
+
if any(os.path.exists(os.path.join(abs_path, m)) for m in marker_files):
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
42
62
|
def resolve_services(
|
|
43
63
|
root: str,
|
|
44
64
|
marker_files: list[str],
|
|
45
65
|
extra_skip_dirs: list[str] | None = None,
|
|
46
66
|
) -> tuple[list[ServiceInfo], bool]:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
147
|
-
env_vars
|
|
175
|
+
for alias, canonical in _KEY_ALIASES.items():
|
|
176
|
+
if alias in env_vars and canonical not in env_vars:
|
|
177
|
+
env_vars[canonical] = env_vars[alias]
|
|
148
178
|
return env_vars
|
|
149
179
|
|
|
150
180
|
|
|
@@ -316,7 +346,7 @@ def write_graph_meta(root: str) -> None:
|
|
|
316
346
|
meta = {"built_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "git_sha": _git_sha(root)}
|
|
317
347
|
path = _graph_meta_path(root)
|
|
318
348
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
319
|
-
with open(path, "w") as f:
|
|
349
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
320
350
|
json.dump(meta, f, indent=2)
|
|
321
351
|
|
|
322
352
|
|
|
@@ -325,7 +355,7 @@ def staleness_note(root: str) -> str:
|
|
|
325
355
|
if not os.path.exists(path):
|
|
326
356
|
return ""
|
|
327
357
|
try:
|
|
328
|
-
with open(path) as f:
|
|
358
|
+
with open(path, encoding="utf-8") as f:
|
|
329
359
|
meta = json.load(f)
|
|
330
360
|
except (OSError, json.JSONDecodeError):
|
|
331
361
|
return ""
|
|
@@ -371,7 +401,7 @@ def partition_graph(
|
|
|
371
401
|
overarching_graph_path: str,
|
|
372
402
|
services: list[ServiceInfo],
|
|
373
403
|
) -> dict[str, int]:
|
|
374
|
-
with open(overarching_graph_path) as f:
|
|
404
|
+
with open(overarching_graph_path, encoding="utf-8") as f:
|
|
375
405
|
graph = json.load(f)
|
|
376
406
|
|
|
377
407
|
service_names = {s.name for s in services}
|
|
@@ -414,7 +444,7 @@ def analyze_bridges(
|
|
|
414
444
|
overarching_graph_path: str,
|
|
415
445
|
services: list[ServiceInfo],
|
|
416
446
|
) -> dict[str, list[BridgeRow]]:
|
|
417
|
-
with open(overarching_graph_path) as f:
|
|
447
|
+
with open(overarching_graph_path, encoding="utf-8") as f:
|
|
418
448
|
graph = json.load(f)
|
|
419
449
|
|
|
420
450
|
service_names = {s.name for s in services}
|
|
@@ -507,7 +537,7 @@ def write_symbols_md(service: ServiceInfo) -> str:
|
|
|
507
537
|
os.makedirs(out_dir, exist_ok=True)
|
|
508
538
|
path = os.path.join(out_dir, "SYMBOLS.md")
|
|
509
539
|
try:
|
|
510
|
-
with open(service.graph_path) as f:
|
|
540
|
+
with open(service.graph_path, encoding="utf-8") as f:
|
|
511
541
|
graph = json.load(f)
|
|
512
542
|
except (OSError, json.JSONDecodeError) as exc:
|
|
513
543
|
_warn(f"could not read {service.graph_path} ({type(exc).__name__}) — symbols index will be empty")
|
|
@@ -589,7 +619,7 @@ def _write_managed_block(path: str, content: str) -> None:
|
|
|
589
619
|
existing = ""
|
|
590
620
|
if os.path.exists(path):
|
|
591
621
|
try:
|
|
592
|
-
with open(path) as f:
|
|
622
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
593
623
|
existing = f.read()
|
|
594
624
|
except OSError:
|
|
595
625
|
existing = ""
|
|
@@ -733,7 +763,7 @@ def build_context_pack(
|
|
|
733
763
|
|
|
734
764
|
def _extract_code_windows(abs_path, lines_wanted, before=2, after=14, max_lines=110):
|
|
735
765
|
try:
|
|
736
|
-
with open(abs_path, errors="replace") as f:
|
|
766
|
+
with open(abs_path, encoding="utf-8", errors="replace") as f:
|
|
737
767
|
src = f.read().splitlines()
|
|
738
768
|
except OSError:
|
|
739
769
|
return ""
|
|
@@ -844,7 +874,10 @@ def build_context_pack_inline(
|
|
|
844
874
|
text = "\n".join(out) + "\n"
|
|
845
875
|
char_budget = max(budget_tokens, 0) * 4
|
|
846
876
|
if char_budget and len(text) > char_budget:
|
|
847
|
-
|
|
877
|
+
truncated = text[:char_budget].rstrip()
|
|
878
|
+
if truncated.count("```") % 2 == 1:
|
|
879
|
+
truncated += "\n```"
|
|
880
|
+
text = truncated + "\n\n_(truncated to budget)_\n"
|
|
848
881
|
return text
|
|
849
882
|
|
|
850
883
|
|
|
@@ -904,7 +937,12 @@ def run_map(
|
|
|
904
937
|
print(" Ensure an API key is available (e.g. ANTHROPIC_API_KEY or ANTHROPIC_KEY in a .env file).", file=sys.stderr)
|
|
905
938
|
return 1
|
|
906
939
|
|
|
907
|
-
|
|
940
|
+
try:
|
|
941
|
+
bridges = _refresh(root, services, overarching_path, single=single)
|
|
942
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
943
|
+
print(f"Error: extracted graph could not be read ({type(exc).__name__}: {exc}).", file=sys.stderr)
|
|
944
|
+
print(" Re-run `graphnav map`; if it persists, delete graphify-out/ and try again.", file=sys.stderr)
|
|
945
|
+
return 1
|
|
908
946
|
total_bridges = sum(len(rows) for rows in bridges.values())
|
|
909
947
|
|
|
910
948
|
print(f"\nSetup complete. Your AI coding agents are now configured for this repo.")
|
|
@@ -950,7 +988,10 @@ def run_watch(
|
|
|
950
988
|
print(f"Error: bootstrap extraction failed (exit {rc}).", file=sys.stderr)
|
|
951
989
|
return 1
|
|
952
990
|
|
|
953
|
-
|
|
991
|
+
try:
|
|
992
|
+
_refresh(root, services, overarching_path, single=single)
|
|
993
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
994
|
+
_warn(f"could not read graph.json ({type(exc).__name__}) — will retry as it updates")
|
|
954
995
|
|
|
955
996
|
def _start_watch() -> subprocess.Popen:
|
|
956
997
|
return subprocess.Popen(
|
|
@@ -960,6 +1001,14 @@ def run_watch(
|
|
|
960
1001
|
env=env,
|
|
961
1002
|
)
|
|
962
1003
|
|
|
1004
|
+
def _sigterm(_signum, _frame):
|
|
1005
|
+
raise KeyboardInterrupt
|
|
1006
|
+
|
|
1007
|
+
try:
|
|
1008
|
+
signal.signal(signal.SIGTERM, _sigterm)
|
|
1009
|
+
except (ValueError, OSError):
|
|
1010
|
+
pass
|
|
1011
|
+
|
|
963
1012
|
watch_proc = _start_watch()
|
|
964
1013
|
backoff = RestartBackoff()
|
|
965
1014
|
backoff.record_start(time.monotonic())
|
|
@@ -985,7 +1034,11 @@ def run_watch(
|
|
|
985
1034
|
pending_mtime = None
|
|
986
1035
|
ts = time.strftime("%H:%M:%S")
|
|
987
1036
|
print(f"[graphnav] {ts} graph updated — refreshing symbols and bridges ...", file=sys.stderr)
|
|
988
|
-
|
|
1037
|
+
try:
|
|
1038
|
+
_refresh(root, services, overarching_path, single=single)
|
|
1039
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1040
|
+
_warn(f"could not read graph.json ({type(exc).__name__}) — will retry on next update")
|
|
1041
|
+
last_mtime = 0.0
|
|
989
1042
|
else:
|
|
990
1043
|
pending_mtime = mtime
|
|
991
1044
|
elif mtime != last_mtime:
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphnav"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.3"
|
|
8
8
|
description = "Knowledge-graph context injection for AI coding agents in monorepos"
|
|
9
9
|
license = "MIT"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
dependencies = ["graphifyy>=0.8", "mcp>=1.2"]
|
|
12
12
|
|
|
13
13
|
[project.scripts]
|
|
14
|
-
graphnav = "codex_graph.cli:
|
|
14
|
+
graphnav = "codex_graph.cli:entry"
|
|
15
15
|
|
|
16
16
|
[project.optional-dependencies]
|
|
17
17
|
dev = ["pytest>=8", "anthropic>=0.40"]
|
|
@@ -1199,12 +1199,11 @@ class TestRunMap:
|
|
|
1199
1199
|
monkeypatch.setattr("codex_graph.multirepo.shutil.which", lambda _: "/graphify")
|
|
1200
1200
|
roots_seen = []
|
|
1201
1201
|
|
|
1202
|
-
def
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|