java-codebase-rag 0.6.2__py3-none-any.whl → 0.6.3__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.
@@ -18,10 +18,13 @@ from __future__ import annotations
18
18
 
19
19
  import inspect
20
20
  import os
21
+ import sys
22
+ import threading
21
23
  import uuid
22
24
  from collections.abc import AsyncIterator
23
25
  from contextlib import asynccontextmanager
24
26
  from dataclasses import dataclass
27
+ from fnmatch import fnmatch
25
28
  from pathlib import Path
26
29
  from typing import Annotated, Any
27
30
 
@@ -84,6 +87,138 @@ splitter = RecursiveSplitter()
84
87
  _NUM_TXN_BEFORE_OPTIMIZE = 10**12
85
88
 
86
89
 
90
+ # --- Vectors-phase progress emission (JCIRAG_PROGRESS kind=vectors) -----------
91
+ #
92
+ # The flow runs in a CHILD cocoindex process; it prints structured progress to
93
+ # its stderr and the parent (pipeline._popen_capturing_stderr /
94
+ # cli_progress.accumulate_and_relay_subprocess_streams) parses it via
95
+ # ProgressRelay and feeds the renderer. The flow CANNOT know when all files are
96
+ # done (cocoindex offers no "all files done" hook in the flow), so it emits:
97
+ # - ONE ``total=N status=running`` line from ``app_main`` (approximate
98
+ # pre-walk: matcher includes + LayeredIgnore), and
99
+ # - per-file ``done=k status=running`` ticks (throttled every ~25 files) from
100
+ # ``process_*_file`` (shared atomic counter).
101
+ # The PARENT emits the terminal ``status=done``/``failed`` vectors event on
102
+ # cocoindex exit (drives clamp-on-completion + phase transition to Optimize).
103
+
104
+ # Per-file tick cadence: bound stderr volume on huge trees without making the
105
+ # bar feel stale. Every 25th file (and the modulo boundary is enough — the
106
+ # parent clamps to total on the terminal event anyway).
107
+ _VECTORS_TICK_EVERY = 25
108
+
109
+ # Thread-safe counter: cocoindex may call process_*_file concurrently
110
+ # (mount_each parallelism is implementation-defined). A module-level lock guards
111
+ # both the counter and the emission so two threads never interleave a tick.
112
+ _vectors_done_lock = threading.Lock()
113
+ _vectors_done_count = 0
114
+
115
+
116
+ def _emit_vectors_progress(
117
+ *,
118
+ done: int | None = None,
119
+ total: int | None = None,
120
+ status: str = "running",
121
+ elapsed_s: float | None = None,
122
+ ) -> None:
123
+ """Emit one ``JCIRAG_PROGRESS kind=vectors …`` line to stderr (flushed).
124
+
125
+ Field order is fixed (kind, done, total, status, elapsed_s) so the parser
126
+ and tests can pin substrings. Omitted fields are simply absent.
127
+ """
128
+ fields = ["kind=vectors"]
129
+ if done is not None:
130
+ fields.append(f"done={done}")
131
+ if total is not None:
132
+ fields.append(f"total={total}")
133
+ fields.append(f"status={status}")
134
+ if elapsed_s is not None:
135
+ fields.append(f"elapsed_s={elapsed_s:.2f}")
136
+ print("JCIRAG_PROGRESS " + " ".join(fields), file=sys.stderr, flush=True)
137
+
138
+
139
+ def _tick_vectors_done() -> None:
140
+ """Increment the shared per-file counter and emit a throttled ``done=k`` tick.
141
+
142
+ Called once per successfully-processed file (after the ignore / empty
143
+ early-returns). The tick is emitted every ``_VECTORS_TICK_EVERY`` files so
144
+ stderr volume stays bounded on huge trees; the parent clamps to total on
145
+ the terminal event, so the exact tick cadence is not load-bearing.
146
+ """
147
+ global _vectors_done_count
148
+ with _vectors_done_lock:
149
+ _vectors_done_count += 1
150
+ n = _vectors_done_count
151
+ if n % _VECTORS_TICK_EVERY != 0:
152
+ return
153
+ # Emit under the lock: the docstring above promises the lock guards both
154
+ # the counter AND the emission, so two concurrent ticks can't emit their
155
+ # ``done=N`` lines out of order. Contention is negligible (fires every
156
+ # ~25 files).
157
+ _emit_vectors_progress(done=n, status="running")
158
+
159
+
160
+ def _approximate_vectors_total(project_root: Path) -> int:
161
+ """Reproduce the matchers' include globs + LayeredIgnore for an approximate total.
162
+
163
+ The flow applies two filtering layers: (1) ``PatternFilePathMatcher``
164
+ excludes at walk time via ``LayeredIgnore.cocoindex_excluded_patterns()``,
165
+ then (2) ``LayeredIgnore.is_ignored()`` plus an early-return for empty /
166
+ undecodable files inside each ``process_*_file``. Files that early-return
167
+ never tick, so this pre-walk OVERSTATES the total by the ignored / empty
168
+ count. The parent clamps the bar to 100% on the terminal ``status=done``
169
+ event, so the over-count cannot stall the bar.
170
+
171
+ Mirrors the three ``localfs.walk_dir`` matchers in ``app_main``:
172
+ - ``**/*.java``
173
+ - ``**/src/main/resources/db/migration/*.sql``
174
+ - ``**/src/main/resources/application*.yml`` and ``.yaml``
175
+ """
176
+ ignore = LayeredIgnore(project_root)
177
+ excluded = ignore.cocoindex_excluded_patterns()
178
+
179
+ def _excluded(rel_posix: str) -> bool:
180
+ return any(fnmatch(rel_posix, pat) for pat in excluded)
181
+
182
+ total = 0
183
+ for dirpath, dirnames, filenames in os.walk(project_root):
184
+ # Prune the same universal nuisance dirs as iter_java_source_files /
185
+ # cocoindex walk. (build-output pruning is matcher-dependent in the
186
+ # real walk; for an APPROXIMATE total this cheap prune is sufficient
187
+ # — the clamp absorbs any residual divergence.)
188
+ dirnames[:] = [
189
+ d for d in dirnames if d not in (".git", ".hg", ".svn", "node_modules", ".venv", "venv")
190
+ ]
191
+ for fn in filenames:
192
+ full = Path(dirpath) / fn
193
+ try:
194
+ rel = full.resolve().relative_to(project_root).as_posix()
195
+ except ValueError:
196
+ continue
197
+ if _excluded(rel):
198
+ continue
199
+ # Java: **/*.java
200
+ if fn.endswith(".java"):
201
+ if not ignore.is_ignored(full)[0]:
202
+ total += 1
203
+ continue
204
+ # SQL: **/src/main/resources/db/migration/*.sql
205
+ if fn.endswith(".sql") and "/db/migration/" in rel:
206
+ if not ignore.is_ignored(full)[0]:
207
+ total += 1
208
+ continue
209
+ # YAML: **/src/main/resources/application*.yml / .yaml
210
+ # NOTE: ``fn`` is the bare filename (e.g. ``application-cloud.yml``), so
211
+ # the prefix predicate must be ``fn.startswith("application")`` —
212
+ # ``"/application" in fn`` was always False (no leading slash in a bare
213
+ # name) and under-counted every application YAML, driving the pre-walk
214
+ # total below the actual done count. The ``rel``-based
215
+ # ``"/src/main/resources/"`` gate stays (full path component).
216
+ if fn.endswith((".yml", ".yaml")) and fn.startswith("application") and "/src/main/resources/" in rel:
217
+ if not ignore.is_ignored(full)[0]:
218
+ total += 1
219
+ return total
220
+
221
+
87
222
  @dataclass
88
223
  class JavaLanceChunk:
89
224
  id: str
@@ -187,6 +322,8 @@ async def process_java_file(
187
322
  if not content.strip():
188
323
  return
189
324
 
325
+ _tick_vectors_done()
326
+
190
327
  language = detect_code_language(filename=file.file_path.path.name) or "text"
191
328
  cs, mn, ov = JAVA_CHUNK
192
329
  chunks = splitter.split(
@@ -251,6 +388,8 @@ async def process_sql_file(
251
388
  if not content.strip():
252
389
  return
253
390
 
391
+ _tick_vectors_done()
392
+
254
393
  language = "sql"
255
394
  cs, mn, ov = SQL_CHUNK
256
395
  chunks = splitter.split(
@@ -295,6 +434,8 @@ async def process_yaml_file(
295
434
  if not content.strip():
296
435
  return
297
436
 
437
+ _tick_vectors_done()
438
+
298
439
  ext = file.file_path.path.suffix.lower()
299
440
  language = "yaml" if ext in (".yml", ".yaml") else "text"
300
441
  cs, mn, ov = YAML_CHUNK
@@ -362,6 +503,20 @@ async def app_main() -> None:
362
503
  project_root = coco.use_context(PROJECT_ROOT)
363
504
  _ignore = LayeredIgnore(project_root)
364
505
  _walk_excludes = _ignore.cocoindex_excluded_patterns()
506
+ # Emit ONE approximate total so the parent's renderer can show a determinate
507
+ # bar (clamps to 100% on the terminal vectors event the parent emits on
508
+ # cocoindex exit). Approximate — ignored / empty files over-state it; see
509
+ # ``_approximate_vectors_total``. ``--full-reprocess`` only: on incremental
510
+ # catch-up the @coco.fn(memo=True) cache skips unchanged files, so no total
511
+ # is knowable up front → the parent renders indeterminate from the absence.
512
+ try:
513
+ total = _approximate_vectors_total(project_root)
514
+ if total > 0:
515
+ _emit_vectors_progress(total=total, status="running")
516
+ except Exception:
517
+ # The pre-walk must never break indexing — a failure here just means
518
+ # the parent falls back to indeterminate. Swallow and continue.
519
+ pass
365
520
  java_files = localfs.walk_dir(
366
521
  PROJECT_ROOT,
367
522
  recursive=True,
server.py CHANGED
@@ -13,9 +13,8 @@ import mcp_v2
13
13
  from index_common import SBERT_MODEL
14
14
  from java_codebase_rag.cli_progress import (
15
15
  accumulate_and_relay_subprocess_streams,
16
- emit_vectors_finish,
17
- emit_vectors_start,
18
16
  )
17
+ from java_codebase_rag.progress import ProgressEvent
19
18
  from java_codebase_rag._fdlimit import raise_fd_limit
20
19
  from java_codebase_rag.config import (
21
20
  cocoindex_subprocess_env_defaults,
@@ -113,7 +112,44 @@ class ScopeManager:
113
112
 
114
113
  def _detect_scope(self) -> str | None:
115
114
  from graph_enrich import detect_microservice_from_path
116
- return detect_microservice_from_path(Path.cwd(), self.source_root)
115
+
116
+ candidate = detect_microservice_from_path(Path.cwd(), self.source_root)
117
+ if candidate is None:
118
+ return None
119
+ # Only auto-scope to a microservice that actually has indexed code.
120
+ # detect_microservice_from_path can mislabel a non-microservice
121
+ # top-level child of source_root — most importantly the config/context
122
+ # directory the MCP server is launched from (no build marker, no
123
+ # source) — via its "first path segment under root" fallback. Scoping
124
+ # every query to such a name yields zero matches, so all tools return
125
+ # empty. A real microservice the operator is working in is, by
126
+ # definition, present in the index, so validating against the indexed
127
+ # set cannot suppress a legitimate scope. When the index is unreadable
128
+ # (empty known set) we keep the detected candidate rather than silently
129
+ # disabling auto-scope on a transient graph error.
130
+ known = self._indexed_microservices()
131
+ if known and candidate not in known:
132
+ return None
133
+ return candidate
134
+
135
+ def _indexed_microservices(self) -> set[str]:
136
+ """Microservice names that have indexed type symbols.
137
+
138
+ Graph-only source of truth: the graph is always built alongside Lance,
139
+ and a Lance-only index (no graph) is not a supported state. Any failure
140
+ (graph missing, open error, empty index) returns an empty set, which
141
+ ``_detect_scope`` treats as "cannot validate — keep detection".
142
+ """
143
+ try:
144
+ if not LadybugGraph.exists():
145
+ return set()
146
+ # LadybugGraph.get() opens the DB and runs meta(); it can raise
147
+ # (e.g. RuntimeError on ontology-version mismatch). Caught here ->
148
+ # empty set -> _detect_scope keeps the detected scope.
149
+ counts = LadybugGraph.get().microservice_counts()
150
+ return {name for name in counts if name}
151
+ except Exception:
152
+ return set()
117
153
 
118
154
  def _log_detection(self) -> None:
119
155
  if self.default_scope:
@@ -270,10 +306,20 @@ def list_code_index_tables_payload() -> IndexInfoOutput:
270
306
  )
271
307
 
272
308
 
273
- async def run_refresh_pipeline(*, quiet: bool = False, verbose: bool = True) -> RefreshIndexOutput:
309
+ async def run_refresh_pipeline(
310
+ *,
311
+ quiet: bool = False,
312
+ verbose: bool = True,
313
+ on_progress=None,
314
+ on_progress_console: object | None = None,
315
+ ) -> RefreshIndexOutput:
274
316
  root = _project_root()
275
317
  cocoindex_bin = Path(sys.executable).parent / "cocoindex"
276
318
  if not cocoindex_bin.is_file():
319
+ # 127 pre-spawn: emit a terminal failed vectors event so the renderer's
320
+ # task doesn't hang at running (matches the sync pipeline path).
321
+ if on_progress is not None:
322
+ on_progress(ProgressEvent(kind="vectors", phase=None, pass_=None, done=None, total=None, status="failed", elapsed_s=None))
277
323
  return RefreshIndexOutput(
278
324
  success=False,
279
325
  message=f"cocoindex not found next to Python: {cocoindex_bin}",
@@ -286,6 +332,8 @@ async def run_refresh_pipeline(*, quiet: bool = False, verbose: bool = True) ->
286
332
  if fallback.is_file():
287
333
  flow_path = fallback
288
334
  else:
335
+ if on_progress is not None:
336
+ on_progress(ProgressEvent(kind="vectors", phase=None, pass_=None, done=None, total=None, status="failed", elapsed_s=None))
289
337
  return RefreshIndexOutput(
290
338
  success=False,
291
339
  message=f"java_index_flow_lancedb.py not found under {root} nor {bundle_dir}",
@@ -308,13 +356,14 @@ async def run_refresh_pipeline(*, quiet: bool = False, verbose: bool = True) ->
308
356
  )
309
357
  out_b, err_b = await proc.communicate()
310
358
  except Exception as exc:
359
+ if on_progress is not None:
360
+ on_progress(ProgressEvent(kind="vectors", phase=None, pass_=None, done=None, total=None, status="failed", elapsed_s=None))
311
361
  return RefreshIndexOutput(
312
362
  success=False,
313
363
  message=f"spawn failed: {exc!s}",
314
364
  phases_run=[],
315
365
  )
316
366
  else:
317
- emit_vectors_start()
318
367
  t0 = time.perf_counter()
319
368
  code_c = -1
320
369
  try:
@@ -329,16 +378,30 @@ async def run_refresh_pipeline(*, quiet: bool = False, verbose: bool = True) ->
329
378
  stdout=asyncio.subprocess.PIPE,
330
379
  stderr=asyncio.subprocess.PIPE,
331
380
  )
332
- out_b, err_b = await accumulate_and_relay_subprocess_streams(proc, relay=True, verbose=verbose)
381
+ # The vectors task is fed by the child's per-file ticks + the
382
+ # approximate total line, parsed by the ProgressRelay inside the
383
+ # async drain and routed to on_progress.
384
+ out_b, err_b = await accumulate_and_relay_subprocess_streams(
385
+ proc, relay=True, verbose=verbose,
386
+ on_progress=on_progress, on_progress_console=on_progress_console,
387
+ )
333
388
  code_c = proc.returncode if proc.returncode is not None else -1
334
389
  except Exception as exc:
390
+ if on_progress is not None:
391
+ on_progress(ProgressEvent(kind="vectors", phase=None, pass_=None, done=None, total=None, status="failed", elapsed_s=None))
335
392
  return RefreshIndexOutput(
336
393
  success=False,
337
394
  message=f"spawn failed: {exc!s}",
338
395
  phases_run=[],
339
396
  )
340
397
  finally:
341
- emit_vectors_finish(elapsed_s=time.perf_counter() - t0, exit_code=code_c)
398
+ # The parent emits the terminal vectors event (the flow can't — no
399
+ # "all files done" hook). Drives clamp-on-completion + phase
400
+ # transition to Optimize.
401
+ if on_progress is not None:
402
+ elapsed = time.perf_counter() - t0
403
+ status = "done" if code_c == 0 else "failed"
404
+ on_progress(ProgressEvent(kind="vectors", phase=None, pass_=None, done=None, total=None, status=status, elapsed_s=elapsed))
342
405
  assert proc is not None
343
406
  out = out_b.decode(errors="replace")
344
407
  err = err_b.decode(errors="replace")
@@ -366,7 +429,7 @@ async def run_refresh_pipeline(*, quiet: bool = False, verbose: bool = True) ->
366
429
  idx_dir = Path(idx_raw)
367
430
  else:
368
431
  idx_dir = (root / ".java-codebase-rag").resolve()
369
- await optimize_lance_tables(idx_dir, quiet=quiet)
432
+ await optimize_lance_tables(idx_dir, quiet=quiet, on_progress=on_progress)
370
433
  except Exception as exc:
371
434
  optimize_error = f"lance optimize failed: {exc}"
372
435
  print(f"java-codebase-rag: {optimize_error}", file=sys.stderr)
@@ -394,7 +457,10 @@ async def run_refresh_pipeline(*, quiet: bool = False, verbose: bool = True) ->
394
457
  if quiet:
395
458
  gout_b, gerr_b = await gproc.communicate()
396
459
  else:
397
- gout_b, gerr_b = await accumulate_and_relay_subprocess_streams(gproc, relay=True, verbose=verbose)
460
+ gout_b, gerr_b = await accumulate_and_relay_subprocess_streams(
461
+ gproc, relay=True, verbose=verbose,
462
+ on_progress=on_progress, on_progress_console=on_progress_console,
463
+ )
398
464
  graph_code = gproc.returncode
399
465
  graph_out = gout_b.decode(errors="replace")
400
466
  graph_err = gerr_b.decode(errors="replace")