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
ast_java.py CHANGED
@@ -1868,6 +1868,11 @@ def _collect_outgoing_calls(
1868
1868
  file_rel: str,
1869
1869
  ) -> list[OutgoingCallDecl]:
1870
1870
  del project_root
1871
+ from java_ontology import ( # deferred: java_ontology imports ast_java
1872
+ CLIENT_KIND_FEIGN_METHOD,
1873
+ CLIENT_KIND_REST_TEMPLATE,
1874
+ CLIENT_KIND_WEB_CLIENT,
1875
+ )
1871
1876
  out: list[OutgoingCallDecl] = []
1872
1877
  method_fqn = f"{type_fqn}#{method_decl.signature}"
1873
1878
  type_mods = _find_modifiers_child(type_node) if type_node is not None else None
@@ -1899,7 +1904,7 @@ def _collect_outgoing_calls(
1899
1904
  OutgoingCallDecl(
1900
1905
  method_fqn=method_fqn,
1901
1906
  method_sig=method_decl.signature,
1902
- client_kind="feign_method",
1907
+ client_kind=CLIENT_KIND_FEIGN_METHOD,
1903
1908
  channel="http",
1904
1909
  feign_target_name=feign_target_name,
1905
1910
  feign_target_url=feign_target_url,
@@ -1998,7 +2003,7 @@ def _collect_outgoing_calls(
1998
2003
  OutgoingCallDecl(
1999
2004
  method_fqn=method_fqn,
2000
2005
  method_sig=method_decl.signature,
2001
- client_kind="rest_template",
2006
+ client_kind=CLIENT_KIND_REST_TEMPLATE,
2002
2007
  channel="http",
2003
2008
  feign_target_name="",
2004
2009
  feign_target_url="",
@@ -2051,7 +2056,7 @@ def _collect_outgoing_calls(
2051
2056
  OutgoingCallDecl(
2052
2057
  method_fqn=method_fqn,
2053
2058
  method_sig=method_decl.signature,
2054
- client_kind="web_client",
2059
+ client_kind=CLIENT_KIND_WEB_CLIENT,
2055
2060
  channel="http",
2056
2061
  feign_target_name="",
2057
2062
  feign_target_url="",
build_ast_graph.py CHANGED
@@ -66,7 +66,13 @@ from graph_enrich import (
66
66
  symbol_id,
67
67
  )
68
68
  from path_filtering import LayeredIgnore, iter_java_source_files
69
- from java_ontology import VALID_CLIENT_KINDS, VALID_HTTP_CALL_MATCHES, VALID_PRODUCER_KINDS
69
+ from java_ontology import (
70
+ CLIENT_KIND_FEIGN_METHOD,
71
+ CLIENT_KIND_REST_TEMPLATE,
72
+ VALID_CLIENT_KINDS,
73
+ VALID_HTTP_CALL_MATCHES,
74
+ VALID_PRODUCER_KINDS,
75
+ )
70
76
 
71
77
  log = logging.getLogger(__name__)
72
78
 
@@ -472,10 +478,26 @@ class IncrementalResult:
472
478
  elapsed_sec: float
473
479
 
474
480
 
481
+ # --- Builder-owned files in the index dir (single source of truth) ---------------
482
+ # Every artifact the graph builder writes next to code_graph.lbug. The lifecycle
483
+ # CLI's `erase` clears all of these from one list so the builder and erase cannot
484
+ # drift (issues #349 / #350): previously erase hardcoded ".graph_hashes.json" only
485
+ # and left the crash marker (.graph_increment_in_progress) and the atomic-write
486
+ # temp (.graph_hashes.json.tmp) behind on disk.
487
+ GRAPH_HASHES_FILENAME = ".graph_hashes.json"
488
+ GRAPH_HASHES_TMP_FILENAME = ".graph_hashes.json.tmp"
489
+ GRAPH_INCREMENT_MARKER_FILENAME = ".graph_increment_in_progress"
490
+ BUILDER_OWNED_INDEX_FILES: tuple[str, ...] = (
491
+ GRAPH_HASHES_FILENAME,
492
+ GRAPH_HASHES_TMP_FILENAME,
493
+ GRAPH_INCREMENT_MARKER_FILENAME,
494
+ )
495
+
496
+
475
497
  class FileHashTracker:
476
498
  """Track content hashes for incremental graph rebuild."""
477
499
  def __init__(self, index_dir: Path):
478
- self._path = index_dir / ".graph_hashes.json"
500
+ self._path = index_dir / GRAPH_HASHES_FILENAME
479
501
  self._hashes: dict[str, str] = {} # rel_path -> sha256_hex
480
502
 
481
503
  def load(self) -> None:
@@ -491,7 +513,7 @@ class FileHashTracker:
491
513
 
492
514
  def save(self) -> None:
493
515
  """Persist hashes to disk atomically (write .tmp, rename)."""
494
- tmp_path = self._path.with_suffix(".json.tmp")
516
+ tmp_path = self._path.parent / GRAPH_HASHES_TMP_FILENAME
495
517
  try:
496
518
  with open(tmp_path, "w", encoding="utf-8") as f:
497
519
  json.dump(self._hashes, f, sort_keys=True)
@@ -576,7 +598,7 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
576
598
  query = f"""
577
599
  MATCH (s:Symbol)
578
600
  {where}
579
- RETURN s.kind, s.fqn, s.name, s.filename, s.module, s.microservice, s.id
601
+ RETURN s.kind, s.fqn, s.name, s.filename, s.module, s.microservice, s.id, s.role
580
602
  """
581
603
  result = conn.execute(query, params)
582
604
  while result.has_next():
@@ -585,6 +607,7 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
585
607
  module = row[4] if len(row) > 4 else ""
586
608
  microservice = row[5] if len(row) > 5 else ""
587
609
  node_id = row[6] if len(row) > 6 else ""
610
+ role = row[7] if len(row) > 7 else ""
588
611
 
589
612
  decl = TypeDecl(name, kind, fqn)
590
613
  package = fqn[: -(len(name) + 1)] if fqn.endswith("." + name) else ""
@@ -602,6 +625,10 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
602
625
  tables.types[fqn] = entry
603
626
  tables.by_simple_name.setdefault(name, []).append(entry)
604
627
  tables.by_package.setdefault(package, []).append(entry)
628
+ # Seed the persisted role so the annotation-less stub is not recomputed to
629
+ # the default during node staging (issue #352 divergence #2).
630
+ if role:
631
+ tables.type_role_by_node_id[node_id] = role
605
632
 
606
633
 
607
634
  def _load_existing_members(conn: ladybug.Connection, tables: GraphTables, exclude_files: set[str] | None = None) -> None:
@@ -2382,7 +2409,7 @@ def pass5_imperative_edges(
2382
2409
  )
2383
2410
  rid = ""
2384
2411
  strategy = call.resolution_strategy
2385
- if call.client_kind == "feign_method":
2412
+ if call.client_kind == CLIENT_KIND_FEIGN_METHOD:
2386
2413
  exposing = next((e for e in tables.exposes_rows if e.symbol_id == member.node_id), None)
2387
2414
  if exposing is not None:
2388
2415
  rid = exposing.route_id
@@ -2585,7 +2612,7 @@ def _match_call_edge(
2585
2612
  return "unresolved", []
2586
2613
 
2587
2614
  candidates: list[RouteRow] = []
2588
- if call.client_kind == "feign_method":
2615
+ if call.client_kind == CLIENT_KIND_FEIGN_METHOD:
2589
2616
  # Prefer endpoint matching by target service + path/method for Feign declarations.
2590
2617
  path_value = call.path_template_call
2591
2618
  method_value = call.method_call
@@ -2714,7 +2741,7 @@ def pass6_match_edges(
2714
2741
  if src_route is None and member is not None:
2715
2742
  # Recover feign caller hints from persisted caller-side Client declarations.
2716
2743
  for client in client_hints_by_member.get(member.node_id, ()):
2717
- if client.client_kind != "feign_method":
2744
+ if client.client_kind != CLIENT_KIND_FEIGN_METHOD:
2718
2745
  continue
2719
2746
  path_template, path_regex = _normalize_path(client.path)
2720
2747
  src_route = RouteRow(
@@ -2750,7 +2777,7 @@ def pass6_match_edges(
2750
2777
  call = OutgoingCallDecl(
2751
2778
  method_fqn=f"{member.parent_fqn}#{member.decl.signature}" if member else "",
2752
2779
  method_sig=member.decl.signature if member else "",
2753
- client_kind="feign_method" if _feign_like else "rest_template",
2780
+ client_kind=CLIENT_KIND_FEIGN_METHOD if _feign_like else CLIENT_KIND_REST_TEMPLATE,
2754
2781
  channel="http",
2755
2782
  feign_target_name=src_route.feign_name if src_route else "",
2756
2783
  feign_target_url=src_route.feign_url if src_route else "",
@@ -3207,15 +3234,24 @@ def _write_nodes_impl(
3207
3234
  ))
3208
3235
  # types
3209
3236
  for entry in tables.types.values():
3210
- if entry.loaded_from_db:
3211
- stub_ids.add(entry.node_id)
3212
3237
  d = entry.decl
3213
3238
  role, capabilities = resolve_role_and_capabilities(
3214
3239
  d,
3215
3240
  overrides=overrides,
3216
3241
  meta_chain=mch,
3217
3242
  )
3218
- tables.type_role_by_node_id[entry.node_id] = role
3243
+ if entry.loaded_from_db:
3244
+ stub_ids.add(entry.node_id)
3245
+ # Out-of-scope stub: its annotation-less decl collapses role to the
3246
+ # default. The real node's role was persisted at index time and seeded
3247
+ # into type_role_by_node_id by _load_existing_types; trust it so CALLS
3248
+ # edges into this type keep the correct callee_declaring_role (#352).
3249
+ # The staged row is filtered out of the write via stub_ids, so its
3250
+ # capabilities placeholder never reaches the graph.
3251
+ role = tables.type_role_by_node_id.get(entry.node_id, role)
3252
+ capabilities = []
3253
+ else:
3254
+ tables.type_role_by_node_id[entry.node_id] = role
3219
3255
  rows.append(_node_row(
3220
3256
  id=entry.node_id, kind=d.kind, name=d.name, fqn=d.fqn,
3221
3257
  package=entry.package,
@@ -3424,13 +3460,15 @@ def _write_edges(conn: ladybug.Connection, tables: GraphTables, _file_by_node_id
3424
3460
  _bulk_copy(conn, "OVERRIDES", _REL_OVERRIDES_COLUMNS, overrides_rows)
3425
3461
 
3426
3462
  # Stage CALLS rows with dedup and callee_declaring_role materialization
3427
- seen_calls: set[tuple[str, str, int, int]] = set()
3463
+ seen_calls: set[tuple[str, str, int, int, int]] = set()
3428
3464
  calls_rows: list[dict] = []
3429
3465
  member_by_id = {m.node_id: m for m in tables.members}
3430
3466
  for row in tables.calls_rows:
3431
3467
  if row.src_id not in valid_ids or row.dst_id not in valid_ids:
3432
3468
  continue
3433
- key = (row.src_id, row.dst_id, row.arg_count, row.call_site_line)
3469
+ # Include call_site_byte so two call sites of the same method on the same
3470
+ # source line (same arg_count) are kept as distinct edges (issue #359).
3471
+ key = (row.src_id, row.dst_id, row.arg_count, row.call_site_line, row.call_site_byte)
3434
3472
  if key in seen_calls:
3435
3473
  continue
3436
3474
  seen_calls.add(key)
@@ -3606,10 +3644,15 @@ def _write_routes_and_exposes(conn: ladybug.Connection, tables: GraphTables, _fi
3606
3644
 
3607
3645
 
3608
3646
  def _write_meta(conn: ladybug.Connection, tables: GraphTables, source_root: Path) -> None:
3609
- seen_calls: set[tuple[str, str, int, int]] = set()
3647
+ # Dedup key MUST match _write_edges (build_ast_graph.py, _REL_CALLS writer): the
3648
+ # 5-tuple includes call_site_byte so two call sites of the same method on the
3649
+ # same source line are counted separately. A previous version used the 4-tuple
3650
+ # here, which made counts['calls'] (678) diverge from the real CALLS edge count
3651
+ # (684) that _write_edges actually persisted — describe/stats then undercounted.
3652
+ seen_calls: set[tuple[str, str, int, int, int]] = set()
3610
3653
  calls_unique = 0
3611
3654
  for row in tables.calls_rows:
3612
- key = (row.src_id, row.dst_id, row.arg_count, row.call_site_line)
3655
+ key = (row.src_id, row.dst_id, row.arg_count, row.call_site_line, row.call_site_byte)
3613
3656
  if key not in seen_calls:
3614
3657
  seen_calls.add(key)
3615
3658
  calls_unique += 1
@@ -3773,6 +3816,7 @@ def incremental_rebuild(
3773
3816
  if verbose:
3774
3817
  _verbose_stderr_line(f"[increment] ontology version {version} < 17; falling back to full rebuild")
3775
3818
  conn.close()
3819
+ db.close()
3776
3820
  del conn, db
3777
3821
  return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
3778
3822
  except Exception as e:
@@ -3780,6 +3824,7 @@ def incremental_rebuild(
3780
3824
  _verbose_stderr_line(f"[increment] failed to read ontology version: {e}; falling back to full rebuild")
3781
3825
  try:
3782
3826
  conn.close()
3827
+ db.close()
3783
3828
  except Exception:
3784
3829
  pass
3785
3830
  del conn, db
@@ -3798,6 +3843,7 @@ def incremental_rebuild(
3798
3843
  if verbose:
3799
3844
  _verbose_stderr_line("[increment] no changes detected; no-op")
3800
3845
  conn.close()
3846
+ db.close()
3801
3847
  return IncrementalResult(
3802
3848
  mode="incremental",
3803
3849
  files_changed=0,
@@ -3811,11 +3857,12 @@ def incremental_rebuild(
3811
3857
  _verbose_stderr_line(f"[increment] detected {len(added)} added, {len(changed)} changed, {len(removed)} removed files")
3812
3858
 
3813
3859
  # Step 2: Crash marker check
3814
- crash_marker_path = index_dir / ".graph_increment_in_progress"
3860
+ crash_marker_path = index_dir / GRAPH_INCREMENT_MARKER_FILENAME
3815
3861
  if crash_marker_path.exists():
3816
3862
  if verbose:
3817
3863
  _verbose_stderr_line("[increment] crash marker exists; falling back to full rebuild")
3818
3864
  conn.close()
3865
+ db.close()
3819
3866
  crash_marker_path.unlink(missing_ok=True)
3820
3867
  return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
3821
3868
 
@@ -3850,6 +3897,7 @@ def incremental_rebuild(
3850
3897
  if verbose:
3851
3898
  _verbose_stderr_line(f"[increment] dependent expansion cap ({expansion_cap}) exceeded ({len(scope_files)} files); falling back to full rebuild")
3852
3899
  conn.close()
3900
+ db.close()
3853
3901
  crash_marker_path.unlink(missing_ok=True)
3854
3902
  return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
3855
3903
 
@@ -3896,6 +3944,11 @@ def incremental_rebuild(
3896
3944
  # Rebuild full tables for global pass 5-6 (pass1 populates members from scratch)
3897
3945
  tables_for_global = GraphTables()
3898
3946
  global_asts = pass1_parse(source_root, tables_for_global, verbose=verbose)
3947
+ # pass4 (routes/EXPOSES) must run on the global pass5/6 tables too (issue
3948
+ # #352): pass5 links Feign HTTP_CALLS to routes via exposes_rows, and pass6
3949
+ # matches against routes_rows. Without pass4 both stay empty and the
3950
+ # HTTP_CALLS match outcome drifts from a full rebuild. Mirrors main().
3951
+ pass4_routes(tables_for_global, global_asts, source_root=source_root, verbose=verbose)
3899
3952
 
3900
3953
  pass5_imperative_edges(tables_for_global, global_asts, source_root=source_root, verbose=verbose)
3901
3954
 
@@ -3929,6 +3982,7 @@ def incremental_rebuild(
3929
3982
  crash_marker_path.unlink(missing_ok=True)
3930
3983
 
3931
3984
  conn.close()
3985
+ db.close()
3932
3986
 
3933
3987
  elapsed = time.time() - t_start
3934
3988
  if verbose:
@@ -3948,6 +4002,7 @@ def incremental_rebuild(
3948
4002
  if verbose:
3949
4003
  _verbose_stderr_line(f"[increment] error during incremental rebuild: {e}; falling back to full rebuild")
3950
4004
  conn.close()
4005
+ db.close()
3951
4006
  crash_marker_path.unlink(missing_ok=True)
3952
4007
  return _fallback_to_full(source_root, ladybug_path, verbose, t_start)
3953
4008
 
@@ -4152,6 +4207,7 @@ def write_ladybug(
4152
4207
  _verbose_stderr_line(f"[graph] writing · routes/exposes written in {time.time() - t2:.2f}s")
4153
4208
  _write_meta(conn, tables, source_root)
4154
4209
  conn.close()
4210
+ db.close()
4155
4211
  _init_hash_tracker(source_root, db_path)
4156
4212
 
4157
4213
 
graph_enrich.py CHANGED
@@ -43,6 +43,7 @@ from ast_java import (
43
43
  _TYPE_ANN_TO_CAPABILITY,
44
44
  )
45
45
  from java_ontology import (
46
+ CLIENT_KIND_REST_TEMPLATE,
46
47
  VALID_CAPABILITIES,
47
48
  VALID_CLIENT_KINDS,
48
49
  VALID_PRODUCER_KINDS,
@@ -1301,7 +1302,7 @@ def resolve_http_client_for_method(
1301
1302
  hint = overrides.annotation_to_http_client_hint.get("CodebaseHttpClient")
1302
1303
  if hint is None:
1303
1304
  hint = HttpClientHint(
1304
- client_kind=anchor.client_kind if anchor else "rest_template",
1305
+ client_kind=anchor.client_kind if anchor else CLIENT_KIND_REST_TEMPLATE,
1305
1306
  target_service=anchor.feign_target_name if anchor else "",
1306
1307
  path=anchor.path_template_call if anchor else "",
1307
1308
  method=anchor.method_call if anchor else "",
graph_types.py ADDED
@@ -0,0 +1,133 @@
1
+ """Shared graph types and helpers used by mcp_v2 and resolve_service.
2
+
3
+ This is the neutral, acyclic shared module. It must NOT import from
4
+ ``mcp_v2`` or ``resolve_service`` — both of those import FROM here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from ladybug_queries import LadybugGraph
14
+ from mcp_hints import generate_hints
15
+
16
+ __all__ = [
17
+ "NodeRef",
18
+ "StructuredHint",
19
+ "set_hints_enabled",
20
+ "_hints_or_skip",
21
+ "_node_kind_from_id",
22
+ "_resolve_node_kind",
23
+ "_node_ref_from_row",
24
+ "_to_structured_hints",
25
+ ]
26
+
27
+
28
+ class NodeRef(BaseModel):
29
+ id: str
30
+ kind: Literal["symbol", "route", "client", "producer", "unresolved_call_site"]
31
+ fqn: str
32
+ name: str | None = None
33
+ symbol_kind: str | None = None
34
+ microservice: str | None = None
35
+ module: str | None = None
36
+ role: str | None = None
37
+
38
+
39
+ class StructuredHint(BaseModel):
40
+ label: str = ""
41
+ tool: Literal["search", "find", "describe", "neighbors", "resolve"]
42
+ args: dict[str, Any]
43
+ actionable: bool = True
44
+ reason: str = ""
45
+
46
+
47
+ # Module-level flag set by server.py at startup from resolved config.
48
+ # Single source of truth — both mcp_v2 and resolve_service read this via
49
+ # _hints_or_skip, and server.py sets it via set_hints_enabled (re-exported
50
+ # by mcp_v2 for back-comat).
51
+ _hints_enabled: bool = True
52
+
53
+
54
+ def set_hints_enabled(enabled: bool) -> None:
55
+ global _hints_enabled
56
+ _hints_enabled = enabled
57
+
58
+
59
+ def _hints_or_skip(tool: str, payload: dict) -> tuple[list, list]:
60
+ return generate_hints(tool, payload) if _hints_enabled else ([], [])
61
+
62
+
63
+ def _node_kind_from_id(
64
+ id_str: str,
65
+ ) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
66
+ if id_str.startswith("ucs:"):
67
+ return "unresolved_call_site"
68
+ if id_str.startswith("sym:"):
69
+ return "symbol"
70
+ if id_str.startswith("route:") or id_str.startswith("r:"):
71
+ return "route"
72
+ if id_str.startswith("client:") or id_str.startswith("c:"):
73
+ return "client"
74
+ if id_str.startswith("producer:") or id_str.startswith("p:"):
75
+ return "producer"
76
+ raise ValueError(f"Unknown id prefix for `{id_str}`")
77
+
78
+
79
+ def _resolve_node_kind(
80
+ graph: LadybugGraph,
81
+ node_id: str,
82
+ ) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
83
+ try:
84
+ return _node_kind_from_id(node_id)
85
+ except ValueError:
86
+ pass
87
+ if graph._rows("MATCH (n:Symbol) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
88
+ return "symbol"
89
+ if graph._rows("MATCH (n:Route) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
90
+ return "route"
91
+ if graph._rows("MATCH (n:Client) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
92
+ return "client"
93
+ if graph._rows("MATCH (n:Producer) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
94
+ return "producer"
95
+ raise ValueError(f"Unknown id prefix for `{node_id}`")
96
+
97
+
98
+ def _node_ref_from_row(kind: Literal["symbol", "route", "client", "producer"], row: dict[str, Any]) -> NodeRef:
99
+ symbol_kind: str | None = None
100
+ if kind == "symbol":
101
+ fqn = str(row.get("fqn") or "")
102
+ role = str(row.get("role") or "") or None
103
+ symbol_kind_val = str(row.get("symbol_kind") or row.get("kind") or "").strip()
104
+ symbol_kind = symbol_kind_val or None
105
+ elif kind == "route":
106
+ method = str(row.get("method") or "")
107
+ path = str(row.get("path_template") or row.get("path") or "")
108
+ fqn = f"{method} {path}".strip()
109
+ role = None
110
+ elif kind == "client":
111
+ method = str(row.get("method") or "")
112
+ target = str(row.get("target_service") or "")
113
+ path = str(row.get("path_template") or row.get("path") or "")
114
+ fqn = f"{target} {method} {path}".strip()
115
+ role = None
116
+ else:
117
+ topic = str(row.get("topic") or "")
118
+ broker = str(row.get("broker") or "")
119
+ fqn = f"{topic} {broker}".strip()
120
+ role = None
121
+ return NodeRef(
122
+ id=str(row.get("id") or ""),
123
+ kind=kind,
124
+ fqn=fqn,
125
+ symbol_kind=symbol_kind,
126
+ microservice=str(row.get("microservice") or "") or None,
127
+ module=str(row.get("module") or "") or None,
128
+ role=role,
129
+ )
130
+
131
+
132
+ def _to_structured_hints(raw: list[Any]) -> list[StructuredHint]:
133
+ return [StructuredHint(label=h.label, tool=h.tool, args=h.args, actionable=h.actionable, reason=h.reason) for h in raw]
@@ -22,7 +22,15 @@ See https://github.com/HumanBean17/java-codebase-rag/issues/306
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
- import resource
25
+ try:
26
+ # Unix-only: the ``resource`` module does not exist on Windows. Importing it
27
+ # unconditionally at module scope crashes on Windows, which (because
28
+ # ``cli.py`` and ``server.py`` both import ``raise_fd_limit``) made the
29
+ # entire CLI and MCP server fail to start there. Guard the import so the
30
+ # module loads everywhere; the function below no-ops when it's absent.
31
+ import resource
32
+ except ImportError: # pragma: no cover - Windows lacks the resource module
33
+ resource = None # type: ignore[assignment]
26
34
 
27
35
  # Safe ceiling well above LanceDB's appetite, comfortably below macOS libc
28
36
  # quirks. The hard limit caps it further if lower (locked-down servers).
@@ -35,7 +43,7 @@ def raise_fd_limit(cap: int = _DEFAULT_CAP) -> None:
35
43
  Best-effort and silent: never raises. No-op where ``RLIMIT_NOFILE`` is
36
44
  unsupported (Windows) or where the soft limit already meets ``min(hard, cap)``.
37
45
  """
38
- if not hasattr(resource, "RLIMIT_NOFILE"):
46
+ if resource is None or not hasattr(resource, "RLIMIT_NOFILE"):
39
47
  return
40
48
  soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
41
49
  target = min(hard, cap)
@@ -0,0 +1,32 @@
1
+ """Force stdout/stderr to UTF-8 so non-ASCII glyphs never crash the CLI.
2
+
3
+ The text renderers emit Unicode glyphs — ``↑``/``↓`` (hierarchy tree headers
4
+ in ``jrag_render``), ``✓`` (success markers in ``cli_format``), ``→``/``…``
5
+ (listing/role lines). On Windows, ``sys.stdout``/``sys.stderr`` default to the
6
+ system ANSI codepage (cp1252 on en-US Windows), which can't encode those
7
+ characters, so ``print()`` raises ``UnicodeEncodeError`` and the process exits
8
+ non-zero. Unix platforms already default to UTF-8, so this is a no-op there.
9
+
10
+ Called from the console-script entry points (``_console_script_main``), not
11
+ from in-process ``main()`` callers, so a test that drives ``main()`` directly
12
+ keeps whatever stdout the host wired up.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+
19
+
20
+ def force_utf8_stdio() -> None:
21
+ """Reconfigure ``sys.stdout``/``sys.stderr`` to UTF-8.
22
+
23
+ Best-effort and silent: never raises. No-op where a stream lacks
24
+ ``reconfigure`` (streams replaced by capture frameworks that don't expose
25
+ it). ``errors="replace"`` is a last-resort safety net so a hostile console
26
+ can never crash a run; under UTF-8 every codepoint encodes cleanly, so
27
+ replacement never actually fires for the glyphs we emit.
28
+ """
29
+ for stream in (sys.stdout, sys.stderr):
30
+ reconfigure = getattr(stream, "reconfigure", None)
31
+ if reconfigure is not None:
32
+ reconfigure(encoding="utf-8", errors="replace")