graphnav 1.2.3__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.3/graphnav.egg-info → graphnav-1.3.0}/PKG-INFO +1 -1
- {graphnav-1.2.3 → graphnav-1.3.0}/README.md +4 -2
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/cli.py +3 -1
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/config.py +8 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/mcp_server.py +15 -3
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/multirepo.py +97 -3
- {graphnav-1.2.3 → graphnav-1.3.0/graphnav.egg-info}/PKG-INFO +1 -1
- {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/SOURCES.txt +1 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/pyproject.toml +1 -1
- graphnav-1.3.0/tests/test_auto_rebuild.py +140 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_cli.py +1 -1
- {graphnav-1.2.3 → graphnav-1.3.0}/LICENSE +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/__init__.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/doctor.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/graph_cache.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/graph_nav.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/graph_query.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/runner.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/dependency_links.txt +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/entry_points.txt +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/requires.txt +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/top_level.txt +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/setup.cfg +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_config.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_doctor.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_graph_cache.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_graph_nav.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_graph_query.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_mcp_server.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_multirepo.py +0 -0
- {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_robustness.py +0 -0
- {graphnav-1.2.3 → 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,7 +139,7 @@ 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
|
|
|
@@ -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,6 +130,7 @@ 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
|
|
|
@@ -163,6 +165,12 @@ def _coerce_types(cfg: Config, warnings: list[str]) -> None:
|
|
|
163
165
|
if isinstance(w, bool) or not isinstance(w, (int, float)):
|
|
164
166
|
warnings.append(f"query.edge_relation_weights.{rel} has invalid value {w!r} — ignoring")
|
|
165
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)
|
|
166
174
|
for section, key in (("mono", "marker_files"), ("mono", "extra_skip_dirs"), ("graph", "skip_patterns"), ("codex", "extra_args")):
|
|
167
175
|
value = getattr(getattr(cfg, section), key)
|
|
168
176
|
if not isinstance(value, list) or any(not isinstance(v, str) for v in value):
|
|
@@ -8,7 +8,11 @@ from codex_graph import GraphNotFoundError
|
|
|
8
8
|
from codex_graph.config import QueryConfig, load_config
|
|
9
9
|
from codex_graph.graph_cache import DEFAULT_PACK_SKIP_PATTERNS, load_bundle
|
|
10
10
|
from codex_graph.graph_nav import GraphNav
|
|
11
|
-
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
|
+
)
|
|
12
16
|
|
|
13
17
|
MAX_REGION_LINES = 200
|
|
14
18
|
|
|
@@ -32,10 +36,12 @@ class GraphTools:
|
|
|
32
36
|
root: str,
|
|
33
37
|
skip_patterns: list[str] | None = None,
|
|
34
38
|
query_cfg: QueryConfig | None = None,
|
|
39
|
+
auto_rebuild: bool = True,
|
|
35
40
|
):
|
|
36
41
|
self.root = os.path.abspath(root)
|
|
37
42
|
self.skip_patterns = skip_patterns or list(DEFAULT_PACK_SKIP_PATTERNS)
|
|
38
43
|
self.query_cfg = query_cfg or QueryConfig()
|
|
44
|
+
self.auto_rebuild = auto_rebuild
|
|
39
45
|
self.graph_path = _overarching_graph_path(self.root)
|
|
40
46
|
|
|
41
47
|
@property
|
|
@@ -53,10 +59,11 @@ class GraphTools:
|
|
|
53
59
|
def graph_context(self, task: str) -> str:
|
|
54
60
|
return build_context_pack_inline(
|
|
55
61
|
root=self.root, task=task, skip_patterns=self.skip_patterns,
|
|
56
|
-
query_cfg=self.query_cfg,
|
|
62
|
+
query_cfg=self.query_cfg, auto_rebuild=self.auto_rebuild,
|
|
57
63
|
)
|
|
58
64
|
|
|
59
65
|
def graph_find(self, query: str) -> str:
|
|
66
|
+
maybe_auto_rebuild(self.root, enabled=self.auto_rebuild)
|
|
60
67
|
if self.nav is None:
|
|
61
68
|
return _NO_GRAPH
|
|
62
69
|
hits = self.nav.find_symbols(query, k=8)
|
|
@@ -69,6 +76,7 @@ class GraphTools:
|
|
|
69
76
|
return "\n".join(lines)
|
|
70
77
|
|
|
71
78
|
def graph_neighbors(self, symbol: str) -> str:
|
|
79
|
+
maybe_auto_rebuild(self.root, enabled=self.auto_rebuild)
|
|
72
80
|
if self.nav is None:
|
|
73
81
|
return _NO_GRAPH
|
|
74
82
|
r = self.nav.neighbors(symbol)
|
|
@@ -96,6 +104,7 @@ class GraphTools:
|
|
|
96
104
|
return f"error: {exc}"
|
|
97
105
|
|
|
98
106
|
def impact(self, symbol: str) -> str:
|
|
107
|
+
maybe_auto_rebuild(self.root, enabled=self.auto_rebuild)
|
|
99
108
|
if self.nav is None:
|
|
100
109
|
return _NO_GRAPH
|
|
101
110
|
r = self.nav.neighbors(symbol)
|
|
@@ -130,7 +139,10 @@ def serve(root: str = ".", config_path: str | None = None) -> int:
|
|
|
130
139
|
return 1
|
|
131
140
|
|
|
132
141
|
cfg = load_config(config_path)
|
|
133
|
-
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
|
+
)
|
|
134
146
|
server = FastMCP("graphnav")
|
|
135
147
|
|
|
136
148
|
@server.tool()
|
|
@@ -308,6 +308,81 @@ def run_extract(
|
|
|
308
308
|
return _stream_proc(proc, timeout)
|
|
309
309
|
|
|
310
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
|
+
|
|
311
386
|
def _overarching_graph_path(root: str) -> str:
|
|
312
387
|
return os.path.join(root, "graphify-out", "graph.json")
|
|
313
388
|
|
|
@@ -666,9 +741,18 @@ def build_context_pack(
|
|
|
666
741
|
from codex_graph.graph_query import query_files
|
|
667
742
|
|
|
668
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)
|
|
669
747
|
overarching_path = _overarching_graph_path(root)
|
|
670
748
|
if not os.path.exists(overarching_path):
|
|
671
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
|
+
)
|
|
672
756
|
return (
|
|
673
757
|
f"# Context for: {task}\n\n"
|
|
674
758
|
f"No knowledge graph found at {rel}.\n"
|
|
@@ -679,8 +763,6 @@ def build_context_pack(
|
|
|
679
763
|
skip_patterns = list(DEFAULT_PACK_SKIP_PATTERNS)
|
|
680
764
|
if query_cfg is None:
|
|
681
765
|
query_cfg = QueryConfig()
|
|
682
|
-
if mono_cfg is None:
|
|
683
|
-
mono_cfg = MonoConfig()
|
|
684
766
|
|
|
685
767
|
degraded = False
|
|
686
768
|
try:
|
|
@@ -708,6 +790,8 @@ def build_context_pack(
|
|
|
708
790
|
note = staleness_note(root)
|
|
709
791
|
if note:
|
|
710
792
|
out_lines += [note, ""]
|
|
793
|
+
if rebuild_started:
|
|
794
|
+
out_lines += ["_Source files changed — automatic graph rebuild started in the background._", ""]
|
|
711
795
|
if degraded:
|
|
712
796
|
out_lines.append(
|
|
713
797
|
"_Knowledge graph could not be read (corrupt or invalid graph.json) — "
|
|
@@ -787,14 +871,22 @@ def _extract_code_windows(abs_path, lines_wanted, before=2, after=14, max_lines=
|
|
|
787
871
|
|
|
788
872
|
|
|
789
873
|
def build_context_pack_inline(
|
|
790
|
-
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,
|
|
791
876
|
):
|
|
792
877
|
from codex_graph.graph_cache import DEFAULT_PACK_SKIP_PATTERNS, load_bundle
|
|
793
878
|
from codex_graph.graph_query import query_files
|
|
794
879
|
|
|
795
880
|
root = os.path.abspath(root)
|
|
881
|
+
rebuild_started = maybe_auto_rebuild(root, enabled=auto_rebuild)
|
|
796
882
|
overarching_path = _overarching_graph_path(root)
|
|
797
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
|
+
)
|
|
798
890
|
return f"# Context for: {task}\n\nNo knowledge graph found.\n"
|
|
799
891
|
if skip_patterns is None:
|
|
800
892
|
skip_patterns = list(DEFAULT_PACK_SKIP_PATTERNS)
|
|
@@ -827,6 +919,8 @@ def build_context_pack_inline(
|
|
|
827
919
|
note = staleness_note(root)
|
|
828
920
|
if note:
|
|
829
921
|
out += [note, ""]
|
|
922
|
+
if rebuild_started:
|
|
923
|
+
out += ["_Source files changed — automatic graph rebuild started in the background._", ""]
|
|
830
924
|
out.append(
|
|
831
925
|
"## Relevant code (extracted from the knowledge graph — already in context, do not re-open these files)"
|
|
832
926
|
)
|
|
@@ -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 ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|