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.
- {graphnav-1.2.1/graphnav.egg-info → graphnav-1.2.3}/PKG-INFO +1 -1
- {graphnav-1.2.1 → graphnav-1.2.3}/README.md +36 -21
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/cli.py +47 -22
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/config.py +49 -2
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/doctor.py +5 -3
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/graph_cache.py +1 -1
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/graph_nav.py +17 -8
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/graph_query.py +1 -1
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/mcp_server.py +2 -1
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/multirepo.py +138 -38
- {graphnav-1.2.1 → graphnav-1.2.3/graphnav.egg-info}/PKG-INFO +1 -1
- {graphnav-1.2.1 → 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.1 → graphnav-1.2.3}/pyproject.toml +2 -2
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_cli.py +23 -4
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_doctor.py +2 -2
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_multirepo.py +25 -3
- graphnav-1.2.3/tests/test_robustness.py +201 -0
- graphnav-1.2.1/graphnav.egg-info/entry_points.txt +0 -2
- {graphnav-1.2.1 → graphnav-1.2.3}/LICENSE +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/__init__.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/codex_graph/runner.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/dependency_links.txt +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/requires.txt +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/graphnav.egg-info/top_level.txt +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/setup.cfg +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_config.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_graph_cache.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_graph_nav.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_graph_query.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_mcp_server.py +0 -0
- {graphnav-1.2.1 → graphnav-1.2.3}/tests/test_runner.py +0 -0
|
@@ -27,13 +27,22 @@ GraphNav solves this by:
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Setup is one command
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
pip install graphnav
|
|
34
|
+
graphnav
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
54
|
+
services, single = multirepo.resolve_services(root, cfg.mono.marker_files, cfg.mono.extra_skip_dirs)
|
|
55
55
|
if not services:
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
227
|
-
"
|
|
228
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -17,24 +18,80 @@ def find_graphify() -> str | None:
|
|
|
17
18
|
path = shutil.which("graphify")
|
|
18
19
|
if path:
|
|
19
20
|
return path
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
111
|
-
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]
|
|
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
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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 =
|
|
916
|
+
services, single = resolve_services(root, mono_cfg.marker_files, mono_cfg.extra_skip_dirs)
|
|
844
917
|
if not services:
|
|
845
|
-
print(f"No
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
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"\
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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 =
|
|
975
|
+
services, single = resolve_services(root, mono_cfg.marker_files, mono_cfg.extra_skip_dirs)
|
|
891
976
|
if not services:
|
|
892
|
-
print(f"No
|
|
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
|
|
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
|
-
|
|
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 —
|
|
941
|
-
|
|
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:
|
|
@@ -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"]
|
|
@@ -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
|
|
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
|
-
|
|
182
|
-
assert "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|