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.
Files changed (32) hide show
  1. {graphnav-1.2.3/graphnav.egg-info → graphnav-1.3.0}/PKG-INFO +1 -1
  2. {graphnav-1.2.3 → graphnav-1.3.0}/README.md +4 -2
  3. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/cli.py +3 -1
  4. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/config.py +8 -0
  5. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/mcp_server.py +15 -3
  6. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/multirepo.py +97 -3
  7. {graphnav-1.2.3 → graphnav-1.3.0/graphnav.egg-info}/PKG-INFO +1 -1
  8. {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/SOURCES.txt +1 -0
  9. {graphnav-1.2.3 → graphnav-1.3.0}/pyproject.toml +1 -1
  10. graphnav-1.3.0/tests/test_auto_rebuild.py +140 -0
  11. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_cli.py +1 -1
  12. {graphnav-1.2.3 → graphnav-1.3.0}/LICENSE +0 -0
  13. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/__init__.py +0 -0
  14. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/doctor.py +0 -0
  15. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/graph_cache.py +0 -0
  16. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/graph_nav.py +0 -0
  17. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/graph_query.py +0 -0
  18. {graphnav-1.2.3 → graphnav-1.3.0}/codex_graph/runner.py +0 -0
  19. {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/dependency_links.txt +0 -0
  20. {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/entry_points.txt +0 -0
  21. {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/requires.txt +0 -0
  22. {graphnav-1.2.3 → graphnav-1.3.0}/graphnav.egg-info/top_level.txt +0 -0
  23. {graphnav-1.2.3 → graphnav-1.3.0}/setup.cfg +0 -0
  24. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_config.py +0 -0
  25. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_doctor.py +0 -0
  26. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_graph_cache.py +0 -0
  27. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_graph_nav.py +0 -0
  28. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_graph_query.py +0 -0
  29. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_mcp_server.py +0 -0
  30. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_multirepo.py +0 -0
  31. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_robustness.py +0 -0
  32. {graphnav-1.2.3 → graphnav-1.3.0}/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphnav
3
- Version: 1.2.3
3
+ Version: 1.3.0
4
4
  Summary: Knowledge-graph context injection for AI coding agents in monorepos
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -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 # keep the graph live as you edit
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 _overarching_graph_path, build_context_pack_inline
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(os.path.abspath(root), cfg.graph.skip_patterns, query_cfg=cfg.query)
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
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphnav
3
- Version: 1.2.3
3
+ Version: 1.3.0
4
4
  Summary: Knowledge-graph context injection for AI coding agents in monorepos
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphnav"
7
- version = "1.2.3"
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"
@@ -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