java-codebase-rag 0.6.6__py3-none-any.whl → 0.8.0__py3-none-any.whl

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 (33) hide show
  1. ast_java.py +8 -3
  2. build_ast_graph.py +72 -16
  3. graph_enrich.py +2 -1
  4. graph_types.py +133 -0
  5. java_codebase_rag/_fdlimit.py +10 -2
  6. java_codebase_rag/_stdio.py +32 -0
  7. java_codebase_rag/cli.py +135 -24
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
  13. java_codebase_rag/installer.py +438 -103
  14. java_codebase_rag/jrag.py +4300 -0
  15. java_codebase_rag/jrag_envelope.py +1085 -0
  16. java_codebase_rag/jrag_hints.py +204 -0
  17. java_codebase_rag/jrag_render.py +688 -0
  18. java_codebase_rag/pipeline.py +20 -0
  19. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
  20. java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
  21. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
  22. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
  23. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
  24. java_index_flow_lancedb.py +34 -19
  25. java_ontology.py +12 -0
  26. ladybug_queries.py +233 -52
  27. mcp_hints.py +6 -6
  28. mcp_v2.py +205 -617
  29. resolve_service.py +649 -0
  30. search_lancedb.py +10 -1
  31. server.py +20 -12
  32. java_codebase_rag-0.6.6.dist-info/RECORD +0 -34
  33. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/licenses/LICENSE +0 -0
java_codebase_rag/cli.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- # Heavy imports (`server`, `pr_analysis`, `path_filtering.LayeredIgnore`) stay lazy
4
- # inside handlers so `java-codebase-rag --help` stays fast.
3
+ # Heavy imports (`server`, `pr_analysis`, `path_filtering.LayeredIgnore`,
4
+ # `build_ast_graph`) stay lazy inside handlers so `java-codebase-rag --help` stays fast.
5
5
 
6
6
  import argparse
7
7
  import asyncio
@@ -15,15 +15,25 @@ from pathlib import Path
15
15
  from typing import Any, Callable
16
16
 
17
17
  from java_codebase_rag.config import (
18
+ OPERATOR_OWNED_INDEX_FILES,
18
19
  ResolvedOperatorConfig,
19
20
  describe_path_sizes,
20
21
  emit_legacy_env_hints_if_present,
21
22
  emit_legacy_yaml_hint_if_needed,
22
23
  index_dir_has_existing_artifacts,
23
24
  resolve_operator_config,
25
+ write_config_source_pointer,
24
26
  )
25
27
  from java_codebase_rag._fdlimit import raise_fd_limit
26
- from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_cocoindex_drop, run_cocoindex_update, run_incremental_graph
28
+ from java_codebase_rag.pipeline import (
29
+ clip,
30
+ is_cocoindex_preflight_blocker,
31
+ is_graph_preflight_blocker,
32
+ run_build_ast_graph,
33
+ run_cocoindex_drop,
34
+ run_cocoindex_update,
35
+ run_incremental_graph,
36
+ )
27
37
  from java_ontology import VALID_UNRESOLVED_CALL_REASONS
28
38
 
29
39
  LADYBUG_INCREMENTAL_TRACKING_ISSUE_URL = "https://github.com/HumanBean17/java-codebase-rag/issues/73"
@@ -61,6 +71,12 @@ def _reprocess_drift_graph_only_line(index_dir: Path) -> str:
61
71
  )
62
72
 
63
73
 
74
+ _VECTORS_SKIPPED_GRAPH_ONLY = (
75
+ "java-codebase-rag: vectors skipped — vector stack not installed on this platform "
76
+ "(graph-only mode). The graph is built/refreshed; semantic search is unavailable."
77
+ )
78
+
79
+
64
80
  def _reprocess_exit_code(payload: dict[str, Any]) -> int:
65
81
  if payload.get("success"):
66
82
  return 0
@@ -70,16 +86,17 @@ def _reprocess_exit_code(payload: dict[str, Any]) -> int:
70
86
  return 1
71
87
 
72
88
 
73
- # Preflight detection must stay aligned with stub CompletedProcess shapes in
74
- # java_codebase_rag/pipeline.py (missing cocoindex / flow / build_ast_graph.py).
89
+ # Preflight detection delegates to pipeline.is_cocoindex_preflight_blocker /
90
+ # is_graph_preflight_blocker, which are co-located with the stub CompletedProcess shapes
91
+ # they must match (missing cocoindex / flow / build_ast_graph.py).
75
92
  def _is_cocoindex_preflight_blocker(coco: Any) -> bool:
76
93
  """True when ``run_cocoindex_update`` returned without spawning cocoindex."""
77
- return bool(coco.returncode in (126, 127) and len(getattr(coco, "args", ()) or ()) <= 1)
94
+ return is_cocoindex_preflight_blocker(coco)
78
95
 
79
96
 
80
97
  def _is_graph_preflight_blocker(g: Any) -> bool:
81
98
  """True when ``run_build_ast_graph`` returned without spawning the builder."""
82
- return bool(g.returncode in (126, 127) and len(getattr(g, "args", ()) or ()) <= 1)
99
+ return is_graph_preflight_blocker(g)
83
100
 
84
101
 
85
102
  def _emit_reprocess_selective_tty(*, mode: str) -> None:
@@ -126,6 +143,21 @@ def _pipeline_footer(subcommand: str, started: float, exit_code: int) -> None:
126
143
  )
127
144
 
128
145
 
146
+ # Subcommands that build/refresh an index and therefore should record which YAML
147
+ # was used (so a later discovery run from a sibling/cwd can relocate it).
148
+ _CONFIG_SOURCE_RECORDING_SUBCOMMANDS = frozenset({"init", "increment", "reprocess"})
149
+
150
+
151
+ def _maybe_record_config_source(
152
+ subcommand: str, cfg: ResolvedOperatorConfig, code: int
153
+ ) -> None:
154
+ """On a successful index build, remember the YAML path inside the index dir."""
155
+ if code == 0 and subcommand in _CONFIG_SOURCE_RECORDING_SUBCOMMANDS:
156
+ write_config_source_pointer(
157
+ index_dir=cfg.index_dir, yaml_config_path=cfg.yaml_config_path
158
+ )
159
+
160
+
129
161
  def _run_with_pipeline_progress(
130
162
  subcommand: str,
131
163
  cfg: ResolvedOperatorConfig,
@@ -143,7 +175,9 @@ def _run_with_pipeline_progress(
143
175
  raw-relays subprocess output).
144
176
  """
145
177
  if quiet or verbose:
146
- return int(work(None))
178
+ code = int(work(None))
179
+ _maybe_record_config_source(subcommand, cfg, code)
180
+ return code
147
181
  from java_codebase_rag.progress import build_index_progress_context
148
182
 
149
183
  # PR-3 owns all three tasks in order: Vectors → Optimize → Graph. The vectors
@@ -183,6 +217,7 @@ def _run_with_pipeline_progress(
183
217
  finally:
184
218
  renderer.stop()
185
219
  _pipeline_footer(subcommand, t0, code)
220
+ _maybe_record_config_source(subcommand, cfg, code)
186
221
 
187
222
 
188
223
  class PipelineProgress:
@@ -321,7 +356,11 @@ def _cmd_init(args: argparse.Namespace) -> int:
321
356
  on_progress=progress.on_progress if progress is not None else None,
322
357
  on_progress_console=progress.console if progress is not None else None,
323
358
  )
324
- if coco.returncode != 0:
359
+ # Graph-only install (cocoindex absent, e.g. macOS Intel): skip the vectors phase
360
+ # and proceed to the graph build rather than failing — the graph layer is the
361
+ # supported surface there. A genuine non-zero cocoindex exit still fails.
362
+ vectors_skipped = _is_cocoindex_preflight_blocker(coco)
363
+ if coco.returncode != 0 and not vectors_skipped:
325
364
  _emit(
326
365
  {
327
366
  "success": False,
@@ -332,6 +371,8 @@ def _cmd_init(args: argparse.Namespace) -> int:
332
371
  }
333
372
  )
334
373
  return 1
374
+ if vectors_skipped:
375
+ print(_VECTORS_SKIPPED_GRAPH_ONLY, file=sys.stderr, flush=True)
335
376
  if not args.quiet:
336
377
  print(file=sys.stderr, flush=True)
337
378
  g = run_build_ast_graph(
@@ -354,7 +395,16 @@ def _cmd_init(args: argparse.Namespace) -> int:
354
395
  }
355
396
  )
356
397
  return 1
357
- _emit({"success": True, "message": "init completed"})
398
+ _emit(
399
+ {
400
+ "success": True,
401
+ "message": (
402
+ "init completed (graph-only; vectors skipped — vector stack not installed)"
403
+ if vectors_skipped
404
+ else "init completed"
405
+ ),
406
+ }
407
+ )
358
408
  return 0
359
409
 
360
410
  return _run_with_pipeline_progress(
@@ -383,7 +433,8 @@ def _cmd_increment(args: argparse.Namespace) -> int:
383
433
  on_progress=progress.on_progress if progress is not None else None,
384
434
  on_progress_console=progress.console if progress is not None else None,
385
435
  )
386
- if coco.returncode != 0:
436
+ vectors_skipped = _is_cocoindex_preflight_blocker(coco)
437
+ if coco.returncode != 0 and not vectors_skipped:
387
438
  _emit(
388
439
  {
389
440
  "success": False,
@@ -394,9 +445,19 @@ def _cmd_increment(args: argparse.Namespace) -> int:
394
445
  }
395
446
  )
396
447
  return 1
448
+ if vectors_skipped:
449
+ print(_VECTORS_SKIPPED_GRAPH_ONLY, file=sys.stderr, flush=True)
397
450
 
398
451
  # If --vectors-only is set, skip graph update
399
452
  if vectors_only:
453
+ if vectors_skipped:
454
+ _emit(
455
+ {
456
+ "success": True,
457
+ "message": "increment skipped: vector stack not installed (graph-only mode)",
458
+ }
459
+ )
460
+ return 0
400
461
  _emit({"success": True, "message": "increment completed (Lance only; graph may be stale — see stderr)"})
401
462
  return 0
402
463
 
@@ -439,7 +500,16 @@ def _cmd_increment(args: argparse.Namespace) -> int:
439
500
  )
440
501
  return 1
441
502
 
442
- _emit({"success": True, "message": "increment completed (Lance + graph updated)"})
503
+ _emit(
504
+ {
505
+ "success": True,
506
+ "message": (
507
+ "increment completed (graph only; vectors skipped — vector stack not installed)"
508
+ if vectors_skipped
509
+ else "increment completed (Lance + graph updated)"
510
+ ),
511
+ }
512
+ )
443
513
  return 0
444
514
 
445
515
  return _run_with_pipeline_progress(
@@ -563,6 +633,7 @@ def _cmd_install(args: argparse.Namespace) -> int:
563
633
  agents=args.agent, # list of str (may be empty)
564
634
  scope=args.scope,
565
635
  model=args.model,
636
+ surface=args.surface,
566
637
  source_root=None, # None means cwd; installer confirms interactively
567
638
  quiet=bool(args.quiet),
568
639
  verbose=bool(args.verbose),
@@ -605,14 +676,25 @@ def _cmd_erase(args: argparse.Namespace) -> int:
605
676
  cfg = _resolved_from_ns(args)
606
677
  _startup_hints(cfg)
607
678
  cfg.apply_to_os_environ()
608
- graph_hashes_path = cfg.ladybug_path.parent / ".graph_hashes.json"
609
- to_describe: list[Path] = [cfg.ladybug_path, cfg.cocoindex_db, graph_hashes_path]
679
+ # Lazy import: build_ast_graph transitively pulls numpy/ladybug/pyarrow/
680
+ # tree_sitter (~54ms), and these filenames are only needed on the erase path.
681
+ # Keeping it out of the top-level import lets `java-codebase-rag --help` (and
682
+ # every other command) stay fast -- see the lazy-import invariant atop this file.
683
+ from build_ast_graph import BUILDER_OWNED_INDEX_FILES
684
+ builder_paths = [cfg.ladybug_path.parent / name for name in BUILDER_OWNED_INDEX_FILES]
685
+ operator_paths = [cfg.index_dir / name for name in OPERATOR_OWNED_INDEX_FILES]
686
+ to_describe: list[Path] = [
687
+ cfg.ladybug_path,
688
+ cfg.cocoindex_db,
689
+ *builder_paths,
690
+ *operator_paths,
691
+ ]
610
692
  if cfg.index_dir.is_dir():
611
693
  try:
612
694
  import lancedb
613
695
 
614
696
  db = lancedb.connect(str(cfg.index_dir.resolve()))
615
- for name in db.table_names():
697
+ for name in db.list_tables():
616
698
  to_describe.append(cfg.index_dir / name)
617
699
  except Exception:
618
700
  pass
@@ -627,7 +709,17 @@ def _cmd_erase(args: argparse.Namespace) -> int:
627
709
  file=sys.stderr,
628
710
  )
629
711
  return 2
630
- ans = input("Delete these paths? [y/N]: ").strip().lower()
712
+ try:
713
+ ans = input("Delete these paths? [y/N]: ").strip().lower()
714
+ except EOFError:
715
+ # Non-interactive stdin that nonetheless reported isatty() == True
716
+ # (the Windows NUL device is a character device, so isatty() lies).
717
+ # Treat it as a refusal instead of crashing with an EOF traceback.
718
+ print(
719
+ "java-codebase-rag erase: non-interactive stdin; pass --yes to confirm.",
720
+ file=sys.stderr,
721
+ )
722
+ return 2
631
723
  if ans not in ("y", "yes"):
632
724
  print("Aborted.", file=sys.stderr)
633
725
  return 2
@@ -643,21 +735,29 @@ def _cmd_erase(args: argparse.Namespace) -> int:
643
735
  )
644
736
  elif drop.returncode != 0:
645
737
  print(clip(drop.stderr, 4000), file=sys.stderr)
646
- # Remove the LadybugDB graph, the cocoindex state store, and the graph
647
- # builder's content-hash store. Each is removed by type (see _rm_any):
648
- # code_graph.lbug is a file here but may be a dir under kuzu, while
649
- # cocoindex.db is a directory a type-blind delete silently no-oped on
650
- # one or the other, and .graph_hashes.json was never targeted at all
651
- # (issue #346).
738
+ # Remove the LadybugDB graph, the cocoindex state store, and every
739
+ # builder-owned bookkeeping file next to code_graph.lbug (the content-hash
740
+ # store, its atomic-write temp, and the incremental crash marker). Each is
741
+ # removed by type (see _rm_any): code_graph.lbug is a file here but may be
742
+ # a dir under kuzu, while cocoindex.db is a directory a type-blind delete
743
+ # silently no-oped on one or the other, and the builder files were never
744
+ # targeted at all (issues #346 / #349 / #350). The list comes from
745
+ # build_ast_graph.BUILDER_OWNED_INDEX_FILES so erase and the builder cannot drift.
652
746
  _rm_any(cfg.ladybug_path)
653
747
  _rm_any(cfg.cocoindex_db)
654
- _rm_any(graph_hashes_path)
748
+ for builder_path in builder_paths:
749
+ _rm_any(builder_path)
750
+ # Operator-owned index files (the config_source pointer recording which
751
+ # YAML built this index) — owned by the CLI/installer, not the graph
752
+ # builder, so removed here rather than via BUILDER_OWNED_INDEX_FILES.
753
+ for operator_path in operator_paths:
754
+ _rm_any(operator_path)
655
755
  if cfg.index_dir.is_dir():
656
756
  try:
657
757
  import lancedb
658
758
 
659
759
  db = lancedb.connect(str(cfg.index_dir.resolve()))
660
- for name in list(db.table_names()):
760
+ for name in list(db.list_tables()):
661
761
  try:
662
762
  db.drop_table(name)
663
763
  except Exception as exc:
@@ -865,6 +965,17 @@ def build_parser() -> argparse.ArgumentParser:
865
965
  default=None,
866
966
  help="Embedding model path or 'auto' (default: auto).",
867
967
  )
968
+ install.add_argument(
969
+ "--surface",
970
+ choices=["mcp", "cli"],
971
+ default=None,
972
+ help=(
973
+ "Agent surface to install: 'mcp' (stdio MCP server + explore-codebase "
974
+ "skill + explorer-rag-enhanced subagent) or 'cli' (jrag console-script "
975
+ "skill + explorer-rag-cli subagent, no MCP entry). Omit to choose "
976
+ "interactively; non-interactive mode defaults to 'mcp'."
977
+ ),
978
+ )
868
979
  _add_verbosity_flags(install)
869
980
  install.set_defaults(handler=_cmd_install)
870
981
 
@@ -18,6 +18,18 @@ SettingSource = Literal["cli", "env", "yaml", "default"]
18
18
  YAML_CONFIG_FILENAMES = (".java-codebase-rag.yml", ".java-codebase-rag.yaml")
19
19
  LEGACY_YAML_FILENAMES = (".lancedb-mcp.yml", ".lancedb-mcp.yaml")
20
20
 
21
+ # Pointer file written into the index dir at index time so discovery can locate
22
+ # a YAML that does not sit beside the index-dir anchor — e.g. a config living in
23
+ # a sibling ``project-context/`` dir when the agent's cwd is inside a
24
+ # microservice (a descendant of the index anchor, a sibling of the config).
25
+ # Contains one line: the absolute path of the YAML used to build the index. A
26
+ # direct YAML at the anchor always wins; the pointer only fires when the anchor
27
+ # has no YAML beside it (see ``_effective_config_dir``).
28
+ CONFIG_SOURCE_FILENAME = "config_source"
29
+ # Operator-owned files inside the index dir that ``erase`` removes. Kept separate
30
+ # from ``build_ast_graph.BUILDER_OWNED_INDEX_FILES`` (builder-owned artifacts).
31
+ OPERATOR_OWNED_INDEX_FILES = (CONFIG_SOURCE_FILENAME,)
32
+
21
33
  ENV_INDEX_DIR = "JAVA_CODEBASE_RAG_INDEX_DIR"
22
34
  # Public operator contract is six names: INDEX_DIR, DEBUG_CONTEXT, RUN_HEAVY, SBERT_MODEL, SBERT_DEVICE, HINTS_ENABLED.
23
35
  # SOURCE_ROOT is still required for MCP / subprocess Java tree resolution (see mcp.json.example); it is not folded into the headline "5".
@@ -202,20 +214,27 @@ def discover_project_root(start: Path) -> Path | None:
202
214
  First match wins (closest to start). Config file takes priority over index
203
215
  directory at the same level. Stops at $HOME inclusive — checks $HOME itself
204
216
  but does not walk past it. Returns None if no marker found.
217
+
218
+ A bare ``.java-codebase-rag/`` index directory at ``$HOME`` is intentionally
219
+ NOT treated as an anchor (issue #357): a stray home-level index (e.g. an
220
+ accidental ``init`` run from home) would otherwise hijack resolution for any
221
+ command run from a ``$HOME`` subdir without its own marker, silently reading
222
+ and writing the home-level index. A config file at ``$HOME`` still anchors.
205
223
  """
206
224
  start = start.resolve()
207
225
  home = Path.home().resolve()
208
226
 
209
227
  current = start
210
228
  while True:
211
- # Config file is the primary anchor
229
+ # Config file is the primary anchor (valid at every level, including $HOME).
212
230
  if find_yaml_config_file(current) is not None:
213
231
  return current
214
- # Index directory is the secondary anchor (supports indexes without config)
215
- if _has_index_dir(current):
232
+ # Index directory is the secondary anchor (supports indexes without config),
233
+ # but NOT at $HOME — see the docstring for the cross-project hijack rationale.
234
+ if current != home and _has_index_dir(current):
216
235
  return current
217
236
 
218
- # Stop if we've reached home (check home itself, but don't walk past it)
237
+ # Stop if we've reached home (config-file check above already handled home)
219
238
  if current == home:
220
239
  return None
221
240
 
@@ -227,6 +246,61 @@ def discover_project_root(start: Path) -> Path | None:
227
246
  current = parent
228
247
 
229
248
 
249
+ _stale_pointer_seen: set[str] = set()
250
+
251
+
252
+ def _config_dir_from_pointer(anchor: Path) -> Path | None:
253
+ """Return the YAML config dir recorded in the index dir's ``config_source`` pointer.
254
+
255
+ Reads ``<anchor>/.java-codebase-rag/config_source`` (one absolute path). If it
256
+ names an existing ``.java-codebase-rag.yml`` / ``.yaml``, returns that file's
257
+ parent directory; otherwise (missing/blank/stale) returns ``None``. Used only
258
+ when the anchor has no direct YAML — see :func:`_effective_config_dir`.
259
+ """
260
+ pointer = anchor / ".java-codebase-rag" / CONFIG_SOURCE_FILENAME
261
+ if not pointer.is_file():
262
+ return None
263
+ try:
264
+ raw = pointer.read_text(encoding="utf-8").strip()
265
+ except OSError:
266
+ return None
267
+ if not raw:
268
+ return None
269
+ target = Path(raw).expanduser()
270
+ if not target.is_absolute():
271
+ # Relative to the anchor (the index-dir parent), not the pointer file.
272
+ target = (anchor / target).resolve()
273
+ if not target.is_file() or target.name not in YAML_CONFIG_FILENAMES:
274
+ key = str(pointer.resolve())
275
+ if key not in _stale_pointer_seen:
276
+ _stale_pointer_seen.add(key)
277
+ print(
278
+ "java-codebase-rag: ignoring stale index pointer "
279
+ f"{pointer} -> {raw} (target missing or not a config file).",
280
+ file=sys.stderr,
281
+ )
282
+ return None
283
+ return target.parent
284
+
285
+
286
+ def _effective_config_dir(config_dir: Path) -> Path:
287
+ """Resolve the directory YAML config fields are relative to.
288
+
289
+ A direct ``.java-codebase-rag.yml`` / ``.yaml`` in ``config_dir`` always wins.
290
+ Otherwise, if ``config_dir`` hosts the ``.java-codebase-rag/`` index dir and
291
+ that index remembers its config via a ``config_source`` pointer, follow it to
292
+ the YAML's directory. This lets a config in a sibling dir (e.g.
293
+ ``project-context/`` beside the Java tree) be found when discovery anchors on
294
+ the index dir from inside a microservice — without an env var or flag, and
295
+ with YAML-relative fields (``index_dir``, ``source_root``, ``embedding.model``)
296
+ resolving against the YAML's home rather than the index anchor. Falls back to
297
+ ``config_dir`` unchanged when neither applies.
298
+ """
299
+ if find_yaml_config_file(config_dir) is not None:
300
+ return config_dir
301
+ return _config_dir_from_pointer(config_dir) or config_dir
302
+
303
+
230
304
  def load_yaml_mapping(source_root: Path) -> dict[str, Any]:
231
305
  path = find_yaml_config_file(source_root)
232
306
  if path is None:
@@ -237,7 +311,17 @@ def load_yaml_mapping(source_root: Path) -> dict[str, Any]:
237
311
  return {}
238
312
  try:
239
313
  data = yaml.safe_load(path.read_text(encoding="utf-8"))
240
- except Exception:
314
+ except (yaml.YAMLError, OSError, UnicodeDecodeError) as exc:
315
+ # Best-effort loader: a missing/unreadable/malformed config must NOT abort
316
+ # startup — return {} and proceed with defaults. Narrowing this to
317
+ # ``yaml.YAMLError`` alone let OSError (chmod 000, stat/read TOCTOU) and
318
+ # UnicodeDecodeError (non-UTF-8 config) propagate to the caller; the broader
319
+ # tuple restores the graceful-degradation contract while still surfacing the
320
+ # problem on stderr.
321
+ print(
322
+ f"java-codebase-rag: could not load config {path}: {exc}; ignoring config.",
323
+ file=sys.stderr,
324
+ )
241
325
  return {}
242
326
  return data if isinstance(data, dict) else {}
243
327
 
@@ -255,6 +339,10 @@ class ResolvedOperatorConfig:
255
339
  embedding_model_source: SettingSource
256
340
  embedding_device_source: SettingSource
257
341
  hints_enabled_source: SettingSource
342
+ # Absolute path of the YAML actually loaded (None when built-in defaults were
343
+ # used with no config file). Recorded into the index dir at index time so a
344
+ # later discovery run from a sibling/cwd can relocate this config.
345
+ yaml_config_path: Path | None = None
258
346
 
259
347
  def apply_to_os_environ(self) -> None:
260
348
  """Make downstream modules (server, ladybug_queries, flows) see a consistent environment.
@@ -394,21 +482,26 @@ def resolve_operator_config(
394
482
  # Phase 1: Find the config file directory
395
483
  if source_root is not None:
396
484
  # CLI flag provided: use it as both config_dir and effective source_root
397
- # (skip YAML source_root check - CLI wins)
485
+ # (skip YAML source_root check - CLI wins). ``_effective_config_dir`` may
486
+ # rebase config_dir to a YAML reached via the index-dir pointer; root is
487
+ # untouched (explicit source_root wins).
398
488
  root = source_root.expanduser().resolve()
399
- config_dir = root
489
+ config_dir = _effective_config_dir(root)
400
490
  yaml_dict = load_yaml_mapping(config_dir)
401
491
  else:
402
492
  # Check env var first
403
493
  env_raw = os.environ.get(ENV_SOURCE_ROOT, "").strip()
404
494
  if env_raw:
405
495
  root = Path(env_raw).expanduser().resolve()
406
- config_dir = root
496
+ config_dir = _effective_config_dir(root)
407
497
  yaml_dict = load_yaml_mapping(config_dir)
408
498
  else:
409
499
  # Walk up to find config dir
410
500
  discovered = discover_project_root(Path.cwd())
411
501
  config_dir = discovered if discovered is not None else Path.cwd().resolve()
502
+ # Follow an index-dir pointer to the real config dir when the anchor
503
+ # has no YAML beside it (e.g. config in a sibling dir).
504
+ config_dir = _effective_config_dir(config_dir)
412
505
  # Load YAML from config dir
413
506
  yaml_dict = load_yaml_mapping(config_dir)
414
507
 
@@ -462,9 +555,35 @@ def resolve_operator_config(
462
555
  embedding_model_source=model_src,
463
556
  embedding_device_source=device_src,
464
557
  hints_enabled_source=hints_src,
558
+ yaml_config_path=find_yaml_config_file(config_dir),
465
559
  )
466
560
 
467
561
 
562
+ def write_config_source_pointer(
563
+ *, index_dir: Path, yaml_config_path: Path | None
564
+ ) -> None:
565
+ """Record the YAML config path inside the index dir (best-effort).
566
+
567
+ Writes ``<index_dir>/config_source`` with the YAML's absolute path so a later
568
+ discovery run that anchors on the index dir (but has no YAML beside it) can
569
+ relocate the config via :func:`_effective_config_dir`. No-op when
570
+ ``yaml_config_path`` is None (pure-default build — nothing to remember). Never
571
+ raises: the pointer is an optimization, not a correctness requirement — a
572
+ missing/unreadable pointer just falls back to built-in defaults.
573
+ """
574
+ if yaml_config_path is None:
575
+ return
576
+ try:
577
+ index_dir.mkdir(parents=True, exist_ok=True)
578
+ content = str(yaml_config_path.resolve()) + "\n"
579
+ target = index_dir / CONFIG_SOURCE_FILENAME
580
+ tmp = index_dir / (CONFIG_SOURCE_FILENAME + ".tmp")
581
+ tmp.write_text(content, encoding="utf-8")
582
+ os.replace(tmp, target)
583
+ except OSError:
584
+ pass
585
+
586
+
468
587
  def index_dir_has_existing_artifacts(index_dir: Path) -> tuple[bool, list[str]]:
469
588
  """True if graph dir or any Lance table already exists under index_dir."""
470
589
  paths: list[str] = []
@@ -476,7 +595,7 @@ def index_dir_has_existing_artifacts(index_dir: Path) -> tuple[bool, list[str]]:
476
595
  import lancedb
477
596
 
478
597
  db = lancedb.connect(str(index_dir.resolve()))
479
- for name in db.table_names():
598
+ for name in db.list_tables():
480
599
  paths.append(str((index_dir / name).resolve()) + " (Lance table)")
481
600
  except Exception:
482
601
  pass