java-codebase-rag 0.9.4__py3-none-any.whl → 0.9.6__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.
- java_codebase_rag/absence/__init__.py +0 -0
- java_codebase_rag/absence/absence_diagnosis.py +700 -0
- java_codebase_rag/absence/absence_types.py +124 -0
- java_codebase_rag/absence/absence_vocab.py +455 -0
- java_codebase_rag/analysis/__init__.py +0 -0
- pr_analysis.py → java_codebase_rag/analysis/pr_analysis.py +1 -1
- resolve_service.py → java_codebase_rag/analysis/resolve_service.py +73 -6
- java_codebase_rag/ast/__init__.py +0 -0
- ast_java.py → java_codebase_rag/ast/ast_java.py +5 -5
- java_codebase_rag/cli.py +13 -18
- java_codebase_rag/config.py +116 -0
- java_codebase_rag/graph/__init__.py +0 -0
- build_ast_graph.py → java_codebase_rag/graph/build_ast_graph.py +89 -11
- graph_enrich.py → java_codebase_rag/graph/graph_enrich.py +248 -3
- graph_types.py → java_codebase_rag/graph/graph_types.py +6 -2
- java_ontology.py → java_codebase_rag/graph/java_ontology.py +1 -1
- ladybug_queries.py → java_codebase_rag/graph/ladybug_queries.py +6 -6
- java_codebase_rag/index/__init__.py +0 -0
- java_index_flow_lancedb.py → java_codebase_rag/index/java_index_flow_lancedb.py +30 -10
- java_codebase_rag/install_data/__init__.py +0 -0
- java_codebase_rag/jrag.py +71 -16
- java_codebase_rag/jrag_envelope.py +13 -4
- java_codebase_rag/jrag_hints.py +1 -1
- java_codebase_rag/jrag_render.py +67 -3
- java_codebase_rag/mcp/__init__.py +0 -0
- mcp_hints.py → java_codebase_rag/mcp/mcp_hints.py +1 -1
- mcp_v2.py → java_codebase_rag/mcp/mcp_v2.py +280 -81
- server.py → java_codebase_rag/mcp/server.py +138 -54
- java_codebase_rag/pipeline.py +26 -7
- java_codebase_rag/search/__init__.py +0 -0
- search_lancedb.py → java_codebase_rag/search/search_lancedb.py +53 -314
- java_codebase_rag/search/search_lexical.py +329 -0
- java_codebase_rag/search/search_scoring.py +338 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/METADATA +2 -2
- java_codebase_rag-0.9.6.dist-info/RECORD +57 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/entry_points.txt +1 -1
- java_codebase_rag-0.9.6.dist-info/top_level.txt +1 -0
- java_codebase_rag-0.9.4.dist-info/RECORD +0 -44
- java_codebase_rag-0.9.4.dist-info/top_level.txt +0 -19
- /brownfield_events.py → /java_codebase_rag/ast/brownfield_events.py +0 -0
- /chunk_heuristics.py → /java_codebase_rag/ast/chunk_heuristics.py +0 -0
- /path_filtering.py → /java_codebase_rag/graph/path_filtering.py +0 -0
- /java_index_v1_common.py → /java_codebase_rag/index/java_index_v1_common.py +0 -0
- /index_common.py → /java_codebase_rag/search/index_common.py +0 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/WHEEL +0 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Absence diagnosis data transfer objects.
|
|
2
|
+
|
|
3
|
+
These types define the contract for explaining empty MCP tool results.
|
|
4
|
+
Later PRs (ABS-2, ABS-3) populate these fields; ABS-0 only declares them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from java_codebase_rag.graph.graph_types import NodeRef
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AbsenceVerdict",
|
|
15
|
+
"AbsenceCause",
|
|
16
|
+
"ExternalReason",
|
|
17
|
+
"AbsenceProof",
|
|
18
|
+
"ExternalIdentity",
|
|
19
|
+
"VocabularyContext",
|
|
20
|
+
"FilterRelaxationDim",
|
|
21
|
+
"FilterRelaxation",
|
|
22
|
+
"AbsenceDiagnosis",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Literal types for verdicts and causes
|
|
26
|
+
AbsenceVerdict = Literal["refine_query", "not_in_project", "external_dependency", "correct_empty"]
|
|
27
|
+
AbsenceCause = Literal["identifier_miss", "nl_miss", "filter_miss", "external", "meaningful_empty"]
|
|
28
|
+
ExternalReason = Literal["prefix", "phantom", "unresolved-call"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AbsenceProof(BaseModel):
|
|
32
|
+
"""Evidence backing a hard 'not_in_project' verdict.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
nearest_distance: Distance to the closest symbol found (0-1)
|
|
36
|
+
symbol_count_scanned: Total symbols examined during search
|
|
37
|
+
thresholds_applied: The similarity thresholds used in the decision
|
|
38
|
+
query_shape: Shape of the original query (currently only "identifier")
|
|
39
|
+
"""
|
|
40
|
+
nearest_distance: float
|
|
41
|
+
symbol_count_scanned: int
|
|
42
|
+
thresholds_applied: dict[str, float]
|
|
43
|
+
query_shape: Literal["identifier"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExternalIdentity(BaseModel):
|
|
47
|
+
"""Identifies an external dependency that caused the empty result.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
fqn: Fully qualified name of the external symbol
|
|
51
|
+
reason: Why we believe this is external (prefix, phantom, unresolved call)
|
|
52
|
+
source: Optional source name (e.g., "maven", "gradle")
|
|
53
|
+
"""
|
|
54
|
+
fqn: str
|
|
55
|
+
reason: ExternalReason
|
|
56
|
+
source: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class VocabularyContext(BaseModel):
|
|
60
|
+
"""Project vocabulary statistics to inform query refinement.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
top_modules: Most frequent modules with counts
|
|
64
|
+
top_microservices: Most frequent microservices with counts
|
|
65
|
+
roles_present: Symbol roles present with counts
|
|
66
|
+
frequent_name_tokens: Common tokens in symbol names
|
|
67
|
+
"""
|
|
68
|
+
top_modules: list[tuple[str, int]]
|
|
69
|
+
top_microservices: list[tuple[str, int]]
|
|
70
|
+
roles_present: list[tuple[str, int]]
|
|
71
|
+
frequent_name_tokens: list[str]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FilterRelaxationDim(BaseModel):
|
|
75
|
+
"""Relaxation analysis for a single filter dimension.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
dimension: The filter dimension (e.g., "microservice", "role")
|
|
79
|
+
constrained_value: The value that constrained results
|
|
80
|
+
matches_under_relaxation: Results if this dimension were relaxed
|
|
81
|
+
suggested_value: Optional alternative value to try
|
|
82
|
+
"""
|
|
83
|
+
dimension: str
|
|
84
|
+
constrained_value: str | None
|
|
85
|
+
matches_under_relaxation: int
|
|
86
|
+
suggested_value: str | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FilterRelaxation(BaseModel):
|
|
90
|
+
"""Analysis of how relaxing filters would affect results.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
per_dimension: List of relaxation options per dimension
|
|
94
|
+
"""
|
|
95
|
+
per_dimension: list[FilterRelaxationDim]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AbsenceDiagnosis(BaseModel):
|
|
99
|
+
"""Explains why an MCP tool returned no results.
|
|
100
|
+
|
|
101
|
+
This is the main DTO that the 5 MCP output models optionally carry.
|
|
102
|
+
In PR-ABS-0, the `absence` field stays None everywhere — later PRs
|
|
103
|
+
populate it with diagnosis logic.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
verdict: High-level judgment on the empty result
|
|
107
|
+
cause: Specific cause that led to this verdict
|
|
108
|
+
message: Human-readable explanation
|
|
109
|
+
closest_symbols: Symbols closest to the query (if any)
|
|
110
|
+
distances: Corresponding distance values
|
|
111
|
+
proof: Evidence for not_in_project verdict
|
|
112
|
+
external_identity: External dependency info
|
|
113
|
+
vocabulary_context: Project vocabulary for refinement
|
|
114
|
+
filter_relaxation: Filter relaxation suggestions
|
|
115
|
+
"""
|
|
116
|
+
verdict: AbsenceVerdict
|
|
117
|
+
cause: AbsenceCause
|
|
118
|
+
message: str
|
|
119
|
+
closest_symbols: list[NodeRef] = Field(default_factory=list)
|
|
120
|
+
distances: list[float] = Field(default_factory=list)
|
|
121
|
+
proof: AbsenceProof | None = None
|
|
122
|
+
external_identity: ExternalIdentity | None = None
|
|
123
|
+
vocabulary_context: VocabularyContext | None = None
|
|
124
|
+
filter_relaxation: FilterRelaxation | None = None
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Vocabulary index for absence diagnosis (PR-ABS-1).
|
|
2
|
+
|
|
3
|
+
A VocabularyIndex builds a search-optimized projection from a LadybugGraph's
|
|
4
|
+
Symbol nodes, persisting as a versioned JSON sidecar. It provides bounded-time
|
|
5
|
+
lookup for did-you-mean candidates and external membership checks.
|
|
6
|
+
|
|
7
|
+
Consumed by PR-ABS-2 (diagnosis ranking) and PR-ABS-3 (MCP tools).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SymbolRecord",
|
|
22
|
+
"VocabularyIndex",
|
|
23
|
+
"VocabIndexStale",
|
|
24
|
+
"get_vocabulary_index",
|
|
25
|
+
"reset_cache",
|
|
26
|
+
"VOCAB_INDEX_FILENAME",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
VOCAB_INDEX_FILENAME = "vocab_index.json"
|
|
30
|
+
|
|
31
|
+
# Sidecar schema version. Bump when the on-disk JSON shape changes; load() rejects
|
|
32
|
+
# a mismatch as stale (→ rebuild) so an old-format sidecar is never misread.
|
|
33
|
+
FORMAT_VERSION = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VocabIndexStale(Exception):
|
|
37
|
+
"""Raised when loading a vocab index with stale ontology_version."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SymbolRecord:
|
|
44
|
+
"""A single symbol record from the graph.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
node_id: Ladybug node ID
|
|
48
|
+
fqn: Fully qualified name
|
|
49
|
+
simple_name: Simple name (last segment)
|
|
50
|
+
normalized_name: Lowercased simple name with signatures stripped
|
|
51
|
+
kind: Symbol kind (class, method, field, etc.)
|
|
52
|
+
module: Maven module (if available)
|
|
53
|
+
microservice: Microservice label (if available)
|
|
54
|
+
role: Symbol role (Controller, Service, Repository, etc.)
|
|
55
|
+
resolved: Whether the symbol resolved to a source location
|
|
56
|
+
"""
|
|
57
|
+
node_id: str
|
|
58
|
+
fqn: str
|
|
59
|
+
simple_name: str
|
|
60
|
+
normalized_name: str
|
|
61
|
+
kind: str
|
|
62
|
+
module: str | None
|
|
63
|
+
microservice: str | None
|
|
64
|
+
role: str | None
|
|
65
|
+
resolved: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class VocabularyIndex:
|
|
69
|
+
"""Search-optimized vocabulary index built from LadybugGraph Symbol nodes.
|
|
70
|
+
|
|
71
|
+
The index stores a flat list of SymbolRecords and an n-gram inverted index
|
|
72
|
+
mapping q-grams to record indexes. This allows bounded-time lookup for
|
|
73
|
+
did-you-mean candidates without scanning the entire vocabulary.
|
|
74
|
+
|
|
75
|
+
Built at the end of graph build; persisted as a sidecar JSON; lazily rebuilt
|
|
76
|
+
if missing or stale (ontology_version mismatch).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
records: list[SymbolRecord],
|
|
82
|
+
ngram_index: dict[str, list[int]],
|
|
83
|
+
q: int,
|
|
84
|
+
_name_index: dict[str, list[int]] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.records = records
|
|
87
|
+
self.ngram_index = ngram_index
|
|
88
|
+
self.q = q
|
|
89
|
+
# Build name index for O(1) exact lookups (key: normalized_name -> record indices)
|
|
90
|
+
if _name_index is None:
|
|
91
|
+
self._name_index: dict[str, list[int]] = {}
|
|
92
|
+
for idx, record in enumerate(records):
|
|
93
|
+
norm = record.normalized_name
|
|
94
|
+
if norm not in self._name_index:
|
|
95
|
+
self._name_index[norm] = []
|
|
96
|
+
self._name_index[norm].append(idx)
|
|
97
|
+
else:
|
|
98
|
+
self._name_index = _name_index
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def symbol_count(self) -> int:
|
|
102
|
+
return len(self.records)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def build(cls, graph: Any, *, q: int) -> "VocabularyIndex":
|
|
106
|
+
"""Build a vocabulary index from a LadybugGraph.
|
|
107
|
+
|
|
108
|
+
Enumerates all Symbol nodes, builds SymbolRecords with normalized names,
|
|
109
|
+
and constructs a q-gram inverted index for candidate lookup.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
graph: LadybugGraph instance
|
|
113
|
+
q: N-gram length (typically 3)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
VocabularyIndex ready for queries
|
|
117
|
+
"""
|
|
118
|
+
# Query all Symbol nodes with proper column aliases
|
|
119
|
+
query = """
|
|
120
|
+
MATCH (s:Symbol)
|
|
121
|
+
RETURN s.id AS id, s.kind AS kind, s.name AS name, s.fqn AS fqn,
|
|
122
|
+
s.package AS package, s.module AS module, s.microservice AS microservice,
|
|
123
|
+
s.filename AS filename, s.start_line AS start_line, s.end_line AS end_line,
|
|
124
|
+
s.start_byte AS start_byte, s.end_byte AS end_byte, s.modifiers AS modifiers,
|
|
125
|
+
s.annotations AS annotations, s.capabilities AS capabilities, s.role AS role,
|
|
126
|
+
s.signature AS signature, s.parent_id AS parent_id, s.resolved AS resolved
|
|
127
|
+
"""
|
|
128
|
+
rows = graph._rows(query, {})
|
|
129
|
+
|
|
130
|
+
records: list[SymbolRecord] = []
|
|
131
|
+
for row in rows:
|
|
132
|
+
record = _row_to_symbol_record(row)
|
|
133
|
+
records.append(record)
|
|
134
|
+
|
|
135
|
+
# Build n-gram index from normalized names
|
|
136
|
+
ngram_index: dict[str, list[int]] = {}
|
|
137
|
+
for idx, record in enumerate(records):
|
|
138
|
+
grams = _qgrams(record.normalized_name, q)
|
|
139
|
+
for gram in grams:
|
|
140
|
+
if gram not in ngram_index:
|
|
141
|
+
ngram_index[gram] = []
|
|
142
|
+
ngram_index[gram].append(idx)
|
|
143
|
+
|
|
144
|
+
return cls(records=records, ngram_index=ngram_index, q=q)
|
|
145
|
+
|
|
146
|
+
def save(self, path: Path, *, ontology_version: int) -> None:
|
|
147
|
+
"""Save the vocabulary index to a JSON sidecar.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
path: Destination path for the sidecar
|
|
151
|
+
ontology_version: Current graph ontology version (for staleness detection)
|
|
152
|
+
"""
|
|
153
|
+
import time
|
|
154
|
+
|
|
155
|
+
data = {
|
|
156
|
+
"format_version": FORMAT_VERSION,
|
|
157
|
+
"ontology_version": ontology_version,
|
|
158
|
+
"built_at": int(time.time()),
|
|
159
|
+
"symbol_count": self.symbol_count,
|
|
160
|
+
"q": self.q,
|
|
161
|
+
"records": [
|
|
162
|
+
{
|
|
163
|
+
"node_id": r.node_id,
|
|
164
|
+
"fqn": r.fqn,
|
|
165
|
+
"simple_name": r.simple_name,
|
|
166
|
+
"normalized_name": r.normalized_name,
|
|
167
|
+
"kind": r.kind,
|
|
168
|
+
"module": r.module,
|
|
169
|
+
"microservice": r.microservice,
|
|
170
|
+
"role": r.role,
|
|
171
|
+
"resolved": r.resolved,
|
|
172
|
+
}
|
|
173
|
+
for r in self.records
|
|
174
|
+
],
|
|
175
|
+
"ngrams": self.ngram_index,
|
|
176
|
+
# _name_index is intentionally NOT persisted: it is derivable from
|
|
177
|
+
# records and rebuilt in __init__ on load (single source of truth,
|
|
178
|
+
# no sidecar bloat).
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
# Atomic write — dump to a temp sibling, then os.replace onto the target.
|
|
183
|
+
# A crash mid-write leaves either the previous complete file or the new
|
|
184
|
+
# complete file, never a truncated/corrupt sidecar (readers see one or
|
|
185
|
+
# the other atomically; os.replace is atomic on the same filesystem).
|
|
186
|
+
tmp_path = path.with_name(path.name + ".tmp")
|
|
187
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
188
|
+
json.dump(data, f, ensure_ascii=False)
|
|
189
|
+
os.replace(tmp_path, path)
|
|
190
|
+
|
|
191
|
+
log.debug(f"VocabularyIndex saved to {path} ({self.symbol_count} symbols)")
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def load(cls, path: Path) -> "VocabularyIndex":
|
|
195
|
+
"""Load a vocabulary index from a JSON sidecar.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
path: Path to the sidecar file
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
VocabularyIndex
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
VocabIndexStale: If sidecar format_version or ontology_version
|
|
205
|
+
doesn't match expected
|
|
206
|
+
"""
|
|
207
|
+
from java_codebase_rag.ast.ast_java import ONTOLOGY_VERSION
|
|
208
|
+
|
|
209
|
+
with open(path, encoding="utf-8") as f:
|
|
210
|
+
data = json.load(f)
|
|
211
|
+
|
|
212
|
+
# Check format version (sidecar JSON schema) first.
|
|
213
|
+
if data.get("format_version") != FORMAT_VERSION:
|
|
214
|
+
raise VocabIndexStale(
|
|
215
|
+
f"Vocab index format_version {data.get('format_version')} "
|
|
216
|
+
f"does not match expected {FORMAT_VERSION}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Check ontology version (graph schema the index was built against).
|
|
220
|
+
if data.get("ontology_version") != ONTOLOGY_VERSION:
|
|
221
|
+
raise VocabIndexStale(
|
|
222
|
+
f"Vocab index ontology version {data.get('ontology_version')} "
|
|
223
|
+
f"does not match expected {ONTOLOGY_VERSION}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
records = [
|
|
227
|
+
SymbolRecord(
|
|
228
|
+
node_id=r["node_id"],
|
|
229
|
+
fqn=r["fqn"],
|
|
230
|
+
simple_name=r["simple_name"],
|
|
231
|
+
normalized_name=r["normalized_name"],
|
|
232
|
+
kind=r["kind"],
|
|
233
|
+
module=r.get("module"),
|
|
234
|
+
microservice=r.get("microservice"),
|
|
235
|
+
role=r.get("role"),
|
|
236
|
+
resolved=r["resolved"],
|
|
237
|
+
)
|
|
238
|
+
for r in data["records"]
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
# _name_index is rebuilt from records in __init__ (not persisted).
|
|
242
|
+
return cls(
|
|
243
|
+
records=records,
|
|
244
|
+
ngram_index=data["ngrams"],
|
|
245
|
+
q=data["q"],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def lookup(self, name: str, *, limit: int) -> list[SymbolRecord]:
|
|
249
|
+
"""Lookup candidate records by name using n-gram overlap.
|
|
250
|
+
|
|
251
|
+
This returns candidate records ONLY; ranking by similarity is done
|
|
252
|
+
in PR-ABS-2 (absence_diagnosis module) to avoid circular imports.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
name: Query name (can be typoed)
|
|
256
|
+
limit: Maximum number of candidates to return
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of candidate SymbolRecord (up to limit), ordered by n-gram overlap count
|
|
260
|
+
"""
|
|
261
|
+
# First, check for exact match on simple_name using O(1) dict lookup (fast path)
|
|
262
|
+
# Return ALL matching records since overloaded names matter
|
|
263
|
+
normalized = _normalize_name(name)
|
|
264
|
+
if normalized in self._name_index:
|
|
265
|
+
exact_matches = [self.records[idx] for idx in self._name_index[normalized]]
|
|
266
|
+
# Filter to only those where simple_name matches exactly (case-sensitive)
|
|
267
|
+
exact_simple_matches = [r for r in exact_matches if r.simple_name == name]
|
|
268
|
+
if exact_simple_matches:
|
|
269
|
+
log.debug(f"lookup({name}): exact match found")
|
|
270
|
+
return exact_simple_matches
|
|
271
|
+
|
|
272
|
+
# No exact match, use n-gram overlap
|
|
273
|
+
# Extract q-grams from query
|
|
274
|
+
grams = _qgrams(normalized, self.q)
|
|
275
|
+
|
|
276
|
+
# Count n-gram matches per record index
|
|
277
|
+
match_counts: dict[int, int] = {}
|
|
278
|
+
for gram in grams:
|
|
279
|
+
if gram in self.ngram_index:
|
|
280
|
+
for idx in self.ngram_index[gram]:
|
|
281
|
+
match_counts[idx] = match_counts.get(idx, 0) + 1
|
|
282
|
+
|
|
283
|
+
# Sort by match count (descending) to get candidates with most overlap first
|
|
284
|
+
sorted_idxs = sorted(match_counts.keys(), key=lambda idx: match_counts[idx], reverse=True)
|
|
285
|
+
|
|
286
|
+
# Debug logging
|
|
287
|
+
log.debug(f"lookup({name}): normalized={normalized}, grams={grams[:5]}, candidates={len(sorted_idxs)}")
|
|
288
|
+
|
|
289
|
+
# Return top candidates
|
|
290
|
+
candidates = [self.records[idx] for idx in sorted_idxs[:limit]]
|
|
291
|
+
return candidates
|
|
292
|
+
|
|
293
|
+
def is_external(self, name: str) -> tuple[bool, str | None]:
|
|
294
|
+
"""Check if a name refers to an external symbol.
|
|
295
|
+
|
|
296
|
+
Returns (is_external, reason) where reason is one of:
|
|
297
|
+
- "prefix": FQN matches an external library prefix (java.*, javax.*, etc.)
|
|
298
|
+
- "phantom": Symbol exists in graph but is unresolved (phantom)
|
|
299
|
+
- None: Symbol is a real project symbol
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
name: Simple name or FQN to check
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
(is_external, reason) tuple
|
|
306
|
+
"""
|
|
307
|
+
from java_codebase_rag.graph.ladybug_queries import _is_external_fqn, _EXTERNAL_PREFIXES
|
|
308
|
+
|
|
309
|
+
# First, check if it's an external prefix (highest priority)
|
|
310
|
+
if _is_external_fqn(name):
|
|
311
|
+
return (True, "prefix")
|
|
312
|
+
|
|
313
|
+
# Also check simple name against external prefixes
|
|
314
|
+
for prefix in _EXTERNAL_PREFIXES:
|
|
315
|
+
if name.startswith(prefix):
|
|
316
|
+
return (True, "prefix")
|
|
317
|
+
|
|
318
|
+
# Check if name matches any record in our vocabulary using O(1) dict lookup
|
|
319
|
+
normalized = _normalize_name(name)
|
|
320
|
+
matching_indices = self._name_index.get(normalized, [])
|
|
321
|
+
matching_record = None
|
|
322
|
+
for idx in matching_indices:
|
|
323
|
+
rec = self.records[idx]
|
|
324
|
+
if rec.simple_name == name or rec.fqn == name:
|
|
325
|
+
matching_record = rec
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
if matching_record:
|
|
329
|
+
# If the symbol is unresolved, it's a phantom
|
|
330
|
+
if not matching_record.resolved:
|
|
331
|
+
return (True, "phantom")
|
|
332
|
+
# Otherwise it's a real project symbol
|
|
333
|
+
return (False, None)
|
|
334
|
+
|
|
335
|
+
# Not found and doesn't look external
|
|
336
|
+
return (False, None)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Module-level cache for get_vocabulary_index
|
|
340
|
+
_vocab_cache: dict[str, VocabularyIndex] = {}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_vocabulary_index(graph: Any, cfg: Any) -> VocabularyIndex:
|
|
344
|
+
"""Get or build a vocabulary index for the given graph.
|
|
345
|
+
|
|
346
|
+
This is the primary entry point for the diagnosis layer (PR-ABS-2) and
|
|
347
|
+
tools (PR-ABS-3). It implements lazy backfill: tries to load from sidecar,
|
|
348
|
+
builds from graph on miss/stale, and caches the result.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
graph: LadybugGraph instance
|
|
352
|
+
cfg: ResolvedOperatorConfig instance
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
VocabularyIndex (cached or newly built)
|
|
356
|
+
"""
|
|
357
|
+
from java_codebase_rag.ast.ast_java import ONTOLOGY_VERSION
|
|
358
|
+
|
|
359
|
+
# Determine graph db path for cache key
|
|
360
|
+
db_path = graph.db_path if hasattr(graph, 'db_path') else str(cfg.ladybug_path)
|
|
361
|
+
sidecar_path = Path(db_path).parent / VOCAB_INDEX_FILENAME
|
|
362
|
+
|
|
363
|
+
# Check cache
|
|
364
|
+
if db_path in _vocab_cache:
|
|
365
|
+
return _vocab_cache[db_path]
|
|
366
|
+
|
|
367
|
+
# Try loading from sidecar
|
|
368
|
+
try:
|
|
369
|
+
index = VocabularyIndex.load(sidecar_path)
|
|
370
|
+
_vocab_cache[db_path] = index
|
|
371
|
+
log.debug(f"Loaded vocabulary index from {sidecar_path}")
|
|
372
|
+
return index
|
|
373
|
+
except Exception as e:
|
|
374
|
+
# Stale (VocabIndexStale), missing (FileNotFoundError), or corrupt
|
|
375
|
+
# (JSONDecodeError/KeyError) — all subsumed by Exception; rebuild.
|
|
376
|
+
log.debug(f"Vocab index missing/stale/corrupt ({e}), rebuilding from graph")
|
|
377
|
+
|
|
378
|
+
# Build from graph
|
|
379
|
+
index = VocabularyIndex.build(graph, q=cfg.absence_ngram_q)
|
|
380
|
+
|
|
381
|
+
# Save to sidecar (best-effort)
|
|
382
|
+
try:
|
|
383
|
+
index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION)
|
|
384
|
+
except Exception as save_err:
|
|
385
|
+
log.warning(f"Failed to save vocab index to {sidecar_path}: {save_err}")
|
|
386
|
+
|
|
387
|
+
# Cache and return
|
|
388
|
+
_vocab_cache[db_path] = index
|
|
389
|
+
return index
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def reset_cache() -> None:
|
|
393
|
+
"""Reset the module-level vocabulary index cache.
|
|
394
|
+
|
|
395
|
+
Exposed for tests that need to simulate a fresh start or different graph paths.
|
|
396
|
+
"""
|
|
397
|
+
global _vocab_cache
|
|
398
|
+
_vocab_cache = {}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---- Helper functions ----
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _row_to_symbol_record(row: dict[str, Any]) -> SymbolRecord:
|
|
405
|
+
"""Convert a Ladybug row to a SymbolRecord."""
|
|
406
|
+
from java_codebase_rag.graph.ladybug_queries import _type_part_fqn
|
|
407
|
+
|
|
408
|
+
fqn = row.get("fqn") or ""
|
|
409
|
+
name = row.get("name") or ""
|
|
410
|
+
|
|
411
|
+
return SymbolRecord(
|
|
412
|
+
node_id=row.get("id") or "",
|
|
413
|
+
fqn=fqn,
|
|
414
|
+
simple_name=name,
|
|
415
|
+
normalized_name=_normalize_name(name),
|
|
416
|
+
kind=row.get("kind") or "",
|
|
417
|
+
module=row.get("module"),
|
|
418
|
+
microservice=row.get("microservice"),
|
|
419
|
+
role=row.get("role"),
|
|
420
|
+
resolved=bool(row.get("resolved", True)),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _normalize_name(name: str) -> str:
|
|
425
|
+
"""Normalize a symbol name for n-gram indexing.
|
|
426
|
+
|
|
427
|
+
Strips:
|
|
428
|
+
- Generic signatures (e.g., List<String> → List)
|
|
429
|
+
- Method signatures (e.g., method(Param) → method)
|
|
430
|
+
- Parentheses and angle brackets
|
|
431
|
+
|
|
432
|
+
Returns lowercase result.
|
|
433
|
+
"""
|
|
434
|
+
# Remove generic signatures
|
|
435
|
+
normalized = name.split("<")[0]
|
|
436
|
+
# Remove method signatures
|
|
437
|
+
normalized = normalized.split("(")[0]
|
|
438
|
+
# Remove hash suffix (e.g., method#signature)
|
|
439
|
+
normalized = normalized.split("#")[0]
|
|
440
|
+
return normalized.lower()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _qgrams(text: str, q: int) -> list[str]:
|
|
444
|
+
"""Extract q-grams from text.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
text: Input string
|
|
448
|
+
q: Gram length
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
List of q-grams (substrings of length q)
|
|
452
|
+
"""
|
|
453
|
+
if len(text) < q:
|
|
454
|
+
return [text] if text else []
|
|
455
|
+
return [text[i:i + q] for i in range(len(text) - q + 1)]
|
|
File without changes
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
from unidiff import PatchSet
|
|
13
13
|
from unidiff.errors import UnidiffParseError
|
|
14
14
|
|
|
15
|
-
from ladybug_queries import SymbolHit, find_symbols_in_file_range, _row_to_symbol
|
|
15
|
+
from java_codebase_rag.graph.ladybug_queries import SymbolHit, find_symbols_in_file_range, _row_to_symbol
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@dataclass
|