java-codebase-rag 0.9.7__py3-none-any.whl → 0.10.1__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 (34) hide show
  1. java_codebase_rag/analysis/pr_analysis.py +33 -3
  2. java_codebase_rag/ast/ast_java.py +2 -1
  3. java_codebase_rag/cli.py +23 -8
  4. java_codebase_rag/config.py +68 -1
  5. java_codebase_rag/graph/build_ast_graph.py +123 -4
  6. java_codebase_rag/graph/graph_types.py +109 -22
  7. java_codebase_rag/graph/ladybug_queries.py +45 -2
  8. java_codebase_rag/index/java_index_flow_lancedb.py +10 -16
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +3 -1
  10. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +3 -1
  11. java_codebase_rag/jrag.py +627 -661
  12. java_codebase_rag/jrag_render.py +160 -3
  13. java_codebase_rag/lance_optimize.py +11 -12
  14. java_codebase_rag/mcp/mcp_v2.py +2 -1
  15. java_codebase_rag/pipeline.py +47 -1
  16. java_codebase_rag/read_payloads.py +781 -0
  17. java_codebase_rag/search/search_lancedb.py +138 -6
  18. java_codebase_rag/search/search_lexical.py +140 -30
  19. java_codebase_rag/search/search_scoring.py +108 -0
  20. java_codebase_rag/watch/__init__.py +0 -0
  21. java_codebase_rag/watch/client.py +230 -0
  22. java_codebase_rag/watch/daemon.py +368 -0
  23. java_codebase_rag/watch/lock.py +201 -0
  24. java_codebase_rag/watch/paths.py +76 -0
  25. java_codebase_rag/watch/protocol.py +122 -0
  26. java_codebase_rag/watch/server.py +273 -0
  27. java_codebase_rag/watch/warm.py +105 -0
  28. java_codebase_rag/watch/watcher.py +352 -0
  29. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/METADATA +30 -31
  30. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/RECORD +34 -24
  31. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/WHEEL +0 -0
  32. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/entry_points.txt +0 -0
  33. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/licenses/LICENSE +0 -0
  34. {java_codebase_rag-0.9.7.dist-info → java_codebase_rag-0.10.1.dist-info}/top_level.txt +0 -0
@@ -10,12 +10,13 @@ invariant.
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
+ import json
13
14
  from typing import Any
14
15
 
15
16
  from java_codebase_rag.absence.absence_types import AbsenceDiagnosis
16
17
  from java_codebase_rag.jrag_envelope import Envelope, project_envelope, simple_name
17
18
 
18
- __all__ = ["render", "tiered_name", "display_name"]
19
+ __all__ = ["render", "tiered_name", "display_name", "count_results", "has_results"]
19
20
 
20
21
 
21
22
  # Edge labels that carry a ``confidence`` column (CALLS-family). ``conf:`` is
@@ -125,6 +126,23 @@ def _next_action_lines(envelope: Envelope) -> list[str]:
125
126
  return [f"next: {hint}" for hint in envelope.agent_next_actions[:2]]
126
127
 
127
128
 
129
+ def _is_dynamic_topic_ref(topic: str) -> bool:
130
+ """True when a producer ``topic`` string is a bare Java identifier — a
131
+ variable or method-call name the indexer captured because it could not
132
+ resolve the destination at index time — rather than a real topic name.
133
+
134
+ Real topics are dotted (``banking.chat.audit``) or CONSTANT references
135
+ (``ChatTopics.ESCALATION``, ``OPERATOR_NOTIFICATIONS``); both contain a
136
+ ``.`` or ``_``. A single lowercase/identifier token (``topic``,
137
+ ``distributionTopic``) is a runtime reference. Used by :func:`display_name`
138
+ only when the producer is also ``resolved=False`` — a resolved bare token is
139
+ treated as a (rare) real single-word topic.
140
+ """
141
+ if "." in topic or "_" in topic:
142
+ return False
143
+ return bool(topic) and topic[0].islower() and topic.isalnum()
144
+
145
+
128
146
  def display_name(node: dict[str, Any]) -> str:
129
147
  """Best short label for a node across all kinds (symbol + route/client/producer).
130
148
 
@@ -175,6 +193,15 @@ def display_name(node: dict[str, Any]) -> str:
175
193
  base = member_fqn.rsplit(".", 1)[-1]
176
194
  topic = str(node.get("topic") or "").strip()
177
195
  if topic:
196
+ # An unresolved topic that is a bare Java identifier is a runtime
197
+ # reference the indexer could not resolve (e.g. the variable
198
+ # ``topic``, or ``getKafka().getDistributionTopic()`` reduced to
199
+ # ``distributionTopic``). Printing it verbatim would show
200
+ # ``→ topic`` and mislead the agent into treating the variable name
201
+ # as the Kafka destination; surface it as dynamic instead. Real
202
+ # topic names (dotted / CONSTANT) stay verbatim.
203
+ if node.get("resolved") is False and _is_dynamic_topic_ref(topic):
204
+ return f"{base} → (dynamic topic)"
178
205
  return f"{base} → {topic}"
179
206
  target = str(node.get("target_service") or "").strip()
180
207
  if target:
@@ -619,8 +646,24 @@ def _render_inspect_block(node: dict[str, Any], indent: int) -> list[str]:
619
646
  out.extend(_render_inspect_block(val, indent + 1))
620
647
  elif _is_dict_list(val):
621
648
  out.append(f"{pad}{key}:")
649
+ item_pad = " " * (indent + 1)
650
+ # Rich items (>2 keys: e.g. ``topics`` producers) render one kv per
651
+ # line for readability — a wall of comma-joined keys is unreadable
652
+ # and hides the composed ``file`` location. Short sample items
653
+ # (``route_sample`` / ``client_sample``) stay on a single line.
654
+ rich = any(len(it) > 2 for it in val)
622
655
  for item in val:
623
- out.append(f"{pad} - {_inspect_inline(item)}")
656
+ if rich:
657
+ # Render at indent+2 so continuation kvs align under the
658
+ # first kv (which sits after the `- ` marker at indent+1).
659
+ item_lines = _render_inspect_block(item, indent + 2)
660
+ if not item_lines:
661
+ out.append(f"{item_pad}- {{}}")
662
+ else:
663
+ out.append(f"{item_pad}- {item_lines[0].lstrip()}")
664
+ out.extend(item_lines[1:])
665
+ else:
666
+ out.append(f"{item_pad}- {_inspect_inline(item)}")
624
667
  else:
625
668
  out.append(f"{pad}{key}: {_inspect_inline(val)}")
626
669
  return out
@@ -717,6 +760,92 @@ def _render_text_shape(envelope: Envelope, *, noun: str, shape: str | None, deta
717
760
  return _render_scalar(envelope)
718
761
 
719
762
 
763
+ def count_results(envelope: Envelope, shape: str | None) -> int:
764
+ """How many result items ``envelope`` represents, per its render shape.
765
+
766
+ The single source of truth shared by ``--count`` (output) and ``--exists``
767
+ (output + exit code). Counts the collection the agent thinks of as the
768
+ result, not the raw node dict (a traversal envelope includes the root
769
+ subject in ``nodes`` — counting nodes would report N+1, not N):
770
+
771
+ * ``shape="inspect"`` -> 1 when a subject resolved (else 0). ``inspect``
772
+ /``status``/``map``/``conventions``/``overview`` declare this shape.
773
+ * ``root is not None`` (traversal) -> ``len(edges)``: the callers/callees/
774
+ hierarchy members are the result; the root is the resolved subject.
775
+ * otherwise (listing) -> ``len(nodes)``.
776
+
777
+ Callers that only care about emptiness should use :func:`has_results`, which
778
+ also gates on ``status == "ok"`` (a ``not_found`` / ``error`` envelope has no
779
+ result regardless of any carried nodes/edges).
780
+ """
781
+ if shape == "inspect":
782
+ return 1 if envelope.nodes else 0
783
+ if envelope.root is not None:
784
+ return len(envelope.edges)
785
+ return len(envelope.nodes)
786
+
787
+
788
+ def has_results(envelope: Envelope, shape: str | None) -> bool:
789
+ """True only when the envelope is a non-empty ``ok`` result.
790
+
791
+ ``not_found`` / ``ambiguous`` / ``error`` carry no result even when they
792
+ carry nodes/candidates (e.g. ambiguous candidates are narrowing hints, not
793
+ hits). Used by ``--exists`` for both its output and its exit code.
794
+ """
795
+ return envelope.status == "ok" and count_results(envelope, shape) > 0
796
+
797
+
798
+ def _field_set(fields: str) -> set[str]:
799
+ """Parse a comma-separated ``--fields`` allowlist into a set of trimmed names."""
800
+ return {name.strip() for name in fields.split(",") if name.strip()}
801
+
802
+
803
+ def _project_to_fields(envelope: Envelope, fields: str) -> Envelope:
804
+ """Return a copy of ``envelope`` (at FULL detail) whose nodes keep only the
805
+ requested field names.
806
+
807
+ ``--fields`` is an explicit allowlist that OVERRIDES ``--detail``: project to
808
+ full (so a ``full``-tier field like ``signature`` is available even at the
809
+ default detail), then keep only the requested keys per node. Names absent on
810
+ a node are simply not present. Graph-id fields stay stripped
811
+ (``project_envelope(full)`` already strips them). Edges/candidates are left
812
+ at full; ``--fields`` is documented as a node projection.
813
+
814
+ Delegates the envelope copy to :func:`project_envelope` (the single
815
+ projection seam) and only rewrites ``nodes`` on the already-copied result,
816
+ so the per-field Envelope construction can't drift out of sync as fields are
817
+ added. ``project_envelope`` returns an independent copy and the node dicts
818
+ are rebuilt by the comprehension, so the caller's ``envelope`` is untouched.
819
+ """
820
+ wanted = _field_set(fields)
821
+ projected = project_envelope(envelope, "full")
822
+ projected.nodes = {
823
+ nid: {k: v for k, v in node.items() if k in wanted}
824
+ for nid, node in projected.nodes.items()
825
+ }
826
+ return projected
827
+
828
+
829
+ def _render_count(envelope: Envelope, *, fmt: str, shape: str | None) -> str:
830
+ """``--count`` output: bare integer (text) or ``{"status","count"}`` (json).
831
+
832
+ Non-``ok`` envelopes count as 0 (a miss / error has no result items). Text is
833
+ the bare count — "only the result count", script-friendly for ``$(...)``.
834
+ """
835
+ n = count_results(envelope, shape) if envelope.status == "ok" else 0
836
+ if fmt == "json":
837
+ return json.dumps({"status": envelope.status, "count": n})
838
+ return str(n)
839
+
840
+
841
+ def _render_exists(envelope: Envelope, *, fmt: str, shape: str | None) -> str:
842
+ """``--exists`` output: ``true``/``false`` (text) or ``{"status","exists"}`` (json)."""
843
+ exists = has_results(envelope, shape)
844
+ if fmt == "json":
845
+ return json.dumps({"status": envelope.status, "exists": exists})
846
+ return "true" if exists else "false"
847
+
848
+
720
849
  def render(
721
850
  envelope: Envelope,
722
851
  *,
@@ -725,6 +854,9 @@ def render(
725
854
  noun: str = "",
726
855
  next_offset: int | None = None,
727
856
  shape: str | None = None,
857
+ count: bool = False,
858
+ exists: bool = False,
859
+ fields: str | None = None,
728
860
  ) -> str:
729
861
  """Dispatch on ``fmt`` (text default; json emits the projected envelope).
730
862
 
@@ -750,8 +882,33 @@ def render(
750
882
  ``nodes``/``noun`` -> listing, else scalar. Listing nodes frequently carry
751
883
  dict-valued fields after ``.model_dump()``, so inspect is NEVER inferred
752
884
  from node contents - only an explicit ``shape="inspect"`` routes there.
885
+
886
+ Output-shaping flags (issue #376), orthogonal to the command that produced
887
+ the envelope and honored on both text and json:
888
+
889
+ * ``exists=True`` -> ``true``/``false`` (or ``{"status","exists"}``). Takes
890
+ precedence over ``count`` (a gate is more specific than a tally).
891
+ * ``count=True`` -> the bare result count (or ``{"status","count"}``); see
892
+ :func:`count_results` for what is counted per shape.
893
+ * ``fields`` -> comma-separated node-field allowlist that OVERRIDES
894
+ ``--detail`` (project to full, then keep only the named fields). Applies
895
+ only to ``status="ok"`` output; composes with the normal render, not with
896
+ ``count``/``exists``. A whitespace/comma-only allowlist is treated as
897
+ not given (falls back to the normal projection). Primarily a JSON lever
898
+ (text rendering still labels rows from whatever identity fields survive
899
+ the allowlist).
900
+
901
+ The exit-code side of ``--exists`` is decided by the caller (``jrag._emit``)
902
+ via :func:`has_results` — render() only shapes output.
753
903
  """
754
- projected = project_envelope(envelope, detail)
904
+ if exists:
905
+ return _render_exists(envelope, fmt=fmt, shape=shape)
906
+ if count:
907
+ return _render_count(envelope, fmt=fmt, shape=shape)
908
+ if fields and envelope.status == "ok" and _field_set(fields):
909
+ projected = _project_to_fields(envelope, fields)
910
+ else:
911
+ projected = project_envelope(envelope, detail)
755
912
  if fmt == "json":
756
913
  return projected.to_json()
757
914
  body = _render_text_shape(projected, noun=noun, shape=shape, detail=detail)
@@ -1,21 +1,20 @@
1
1
  """Serialized post-flow LanceDB optimize with commit-conflict retry.
2
2
 
3
- cocoindex 1.0.7 schedules ``table.optimize()`` (a LanceDB **Rewrite**/compaction
4
- transaction) as a *background* ``asyncio`` task that races concurrent
5
- ``table.delete()`` (**Delete**) transactions emitted by later mutation batches.
6
- LanceDB does not allow a Rewrite to commit concurrently with a Delete
7
- (upstream lancedb#1504 — "We do not support concurrent deletes right now"),
8
- which surfaces as a flood of::
3
+ Historically (cocoindex 1.0.7) this existed because cocoindex scheduled
4
+ ``table.optimize()`` (a LanceDB **Rewrite**/compaction) as a *background*
5
+ ``asyncio`` task that raced concurrent ``table.delete()`` (**Delete**)
6
+ transactions — LanceDB does not allow a Rewrite to commit concurrently with a
7
+ Delete (upstream lancedb#1504), surfacing as a flood of::
9
8
 
10
9
  RuntimeError: lance error: Retryable commit conflict for version N: \
11
10
  This Rewrite transaction was preempted by concurrent transaction Delete ...
12
11
 
13
- To eliminate the race, the flow (``java_index_flow_lancedb.py``) disables the
14
- in-flight background optimize entirely by raising
15
- ``num_transactions_before_optimize`` to a value that is effectively never
16
- reached. This module then performs a *single*, serialized optimize after the
17
- flow returns (exit 0 → no concurrent writers), retrying the rare residual
18
- commit conflict that two internal compaction passes can still produce.
12
+ cocoindex >=1.0.15 made optimize **inline and stats-driven** (only compacts
13
+ when small fragments accumulate, inside the merge_insert commit path), so the
14
+ race is gone and the flow no longer disables anything. This module still runs a
15
+ *single*, serialized optimize after the flow returns (exit 0 → no concurrent
16
+ writers) as a clean final compaction + scalar/FTS index build, retrying the rare
17
+ residual commit conflict that two internal compaction passes can still produce.
19
18
  """
20
19
  from __future__ import annotations
21
20
 
@@ -1136,7 +1136,8 @@ def find_v2(
1136
1136
  )
1137
1137
  params["lim"] = fetch_cap
1138
1138
  rows = g._rows( # noqa: SLF001
1139
- f"MATCH (s:Symbol) {where} RETURN s.id AS id, s.fqn AS fqn, s.microservice AS microservice, "
1139
+ f"MATCH (s:Symbol) {where} RETURN s.id AS id, s.fqn AS fqn, s.name AS name, "
1140
+ "s.filename AS filename, s.start_line AS start_line, s.microservice AS microservice, "
1140
1141
  "s.module AS module, s.role AS role, s.kind AS symbol_kind, s.generated AS generated, s.generated_by AS generated_by ORDER BY s.fqn LIMIT $lim",
1141
1142
  params,
1142
1143
  )
@@ -133,14 +133,60 @@ def _popen_capturing_stderr(
133
133
  t_err = threading.Thread(target=drain_err, name="stream-stderr", daemon=True)
134
134
  t_out.start()
135
135
  t_err.start()
136
+ # Wait on the CHILD before joining the drain threads. ``Popen.wait()`` is
137
+ # interruptible by Ctrl+C — the underlying ``os.waitpid`` returns EINTR and
138
+ # CPython raises ``KeyboardInterrupt`` — whereas ``Thread.join()`` blocks on
139
+ # an internal lock whose infinite-timeout acquire CPython never polls for
140
+ # signals. Joining *first* therefore made the whole indexing step ignore
141
+ # Ctrl+C until the child happened to close its pipes; cocoindex's teardown
142
+ # (and any flow-server grandchild it spawned) can hold them open for a long
143
+ # time, so an install/reprocess could not be aborted. The daemon drain
144
+ # threads keep the child's stdout/stderr pipes empty while we wait, so the
145
+ # child never blocks on a full pipe.
146
+ try:
147
+ code = proc.wait()
148
+ except BaseException:
149
+ # Ctrl+C or any other abort: best-effort, NON-BLOCKING child teardown
150
+ # so a process we spawned does not outlive us, then re-raise WITHOUT
151
+ # joining the drain threads. They may be blocked on a pipe the child
152
+ # still owns, and a join here would re-introduce the very hang this
153
+ # reordering fixes. The threads are daemons, so they vanish at exit.
154
+ _abort_child(proc)
155
+ raise
156
+ # Normal exit: the child is gone, its pipes hit EOF, and the drain threads
157
+ # return promptly — safe to join here. (If a pipe-inheriting grandchild
158
+ # lingered past the child's exit, these joins would block until it too
159
+ # closed its write ends — on Ctrl+C the shared-process-group SIGINT reaches
160
+ # it and closes them. This window is the short teardown-after-indexing phase,
161
+ # not the long indexing phase the wait-first reorder already made
162
+ # interruptible.)
136
163
  t_out.join()
137
164
  t_err.join()
138
165
  if filt is not None:
139
166
  filt.flush()
140
- code = proc.wait()
141
167
  return out_buf.decode(errors="replace"), err_buf.decode(errors="replace"), code
142
168
 
143
169
 
170
+ def _abort_child(proc: subprocess.Popen[bytes]) -> None:
171
+ """Best-effort, NON-BLOCKING teardown of a spawned child on an abort path.
172
+
173
+ Runs when ``proc.wait()`` raised (Ctrl+C, or any other exception). Sends
174
+ SIGTERM and returns immediately — this path exists to let the operator exit
175
+ *promptly*, and waiting for the child would defeat that. On Ctrl+C the child
176
+ already received SIGINT (same process group); this just guarantees teardown
177
+ for non-signal aborts, after which the child finishes shutting down on its
178
+ own. ``OSError`` (incl. ``ProcessLookupError`` — already-dead / zombie /
179
+ reaped) is swallowed — the only caller re-raises regardless.
180
+ """
181
+ fn = getattr(proc, "terminate", None)
182
+ if fn is None:
183
+ return
184
+ try:
185
+ fn()
186
+ except OSError:
187
+ pass
188
+
189
+
144
190
  def run_cocoindex_update(
145
191
  env: dict[str, str],
146
192
  *,