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.
- {graphnav-1.2.2/graphnav.egg-info → graphnav-1.3.0}/PKG-INFO +1 -1
- {graphnav-1.2.2 → graphnav-1.3.0}/README.md +4 -2
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/cli.py +27 -6
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/config.py +57 -2
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/doctor.py +5 -3
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/graph_cache.py +1 -1
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/graph_nav.py +17 -8
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/graph_query.py +1 -1
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/mcp_server.py +17 -4
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/multirepo.py +170 -23
- {graphnav-1.2.2 → graphnav-1.3.0/graphnav.egg-info}/PKG-INFO +1 -1
- {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/SOURCES.txt +2 -0
- graphnav-1.3.0/graphnav.egg-info/entry_points.txt +2 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/pyproject.toml +2 -2
- graphnav-1.3.0/tests/test_auto_rebuild.py +140 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_cli.py +1 -1
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_multirepo.py +3 -4
- graphnav-1.3.0/tests/test_robustness.py +201 -0
- graphnav-1.2.2/graphnav.egg-info/entry_points.txt +0 -2
- {graphnav-1.2.2 → graphnav-1.3.0}/LICENSE +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/__init__.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/codex_graph/runner.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/dependency_links.txt +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/requires.txt +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/graphnav.egg-info/top_level.txt +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/setup.cfg +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_config.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_doctor.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_graph_cache.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_graph_nav.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_graph_query.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_mcp_server.py +0 -0
- {graphnav-1.2.2 → graphnav-1.3.0}/tests/test_runner.py +0 -0
|
@@ -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 #
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphnav"
|
|
7
|
-
version = "1.
|
|
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:
|
|
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
|
|
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
|