codespine 0.4.2__tar.gz → 0.5.0__tar.gz
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.
- {codespine-0.4.2 → codespine-0.5.0}/PKG-INFO +2 -2
- {codespine-0.4.2 → codespine-0.5.0}/codespine/__init__.py +1 -1
- codespine-0.5.0/codespine/analysis/crossmodule.py +230 -0
- codespine-0.5.0/codespine/analysis/deadcode.py +248 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/cli.py +11 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/mcp/server.py +314 -19
- {codespine-0.4.2 → codespine-0.5.0}/codespine/search/hybrid.py +30 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/PKG-INFO +2 -2
- {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/SOURCES.txt +1 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/requires.txt +1 -1
- {codespine-0.4.2 → codespine-0.5.0}/pyproject.toml +2 -2
- codespine-0.4.2/codespine/analysis/deadcode.py +0 -171
- {codespine-0.4.2 → codespine-0.5.0}/LICENSE +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/README.md +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/community.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/context.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/coupling.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/flow.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/impact.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/config.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/db/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/db/schema.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/db/store.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/diff/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/diff/branch_diff.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/call_resolver.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/engine.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/java_parser.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/symbol_builder.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/mcp/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/noise/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/noise/blocklist.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/search/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/search/bm25.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/search/fuzzy.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/search/rrf.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/search/vector.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/watch/__init__.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine/watch/watcher.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/dependency_links.txt +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/entry_points.txt +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/top_level.txt +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/gindex.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/setup.cfg +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/tests/test_branch_diff_normalize.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/tests/test_call_resolver.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/tests/test_index_and_hybrid.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/tests/test_java_parser.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/tests/test_multimodule_index.py +0 -0
- {codespine-0.4.2 → codespine-0.5.0}/tests/test_search_ranking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codespine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local Java code intelligence indexer backed by a graph database
|
|
5
5
|
Author: CodeSpine contributors
|
|
6
6
|
License: MIT License
|
|
@@ -46,7 +46,7 @@ Requires-Dist: click
|
|
|
46
46
|
Requires-Dist: kuzu
|
|
47
47
|
Requires-Dist: tree-sitter
|
|
48
48
|
Requires-Dist: tree-sitter-java
|
|
49
|
-
Requires-Dist: fastmcp
|
|
49
|
+
Requires-Dist: fastmcp>=2.3.0
|
|
50
50
|
Requires-Dist: psutil
|
|
51
51
|
Requires-Dist: watchfiles
|
|
52
52
|
Provides-Extra: ml
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Cross-module call edge linker.
|
|
2
|
+
|
|
3
|
+
After all modules in a workspace have been individually indexed, each module's
|
|
4
|
+
call resolver only sees methods within that module. This module fills the gap
|
|
5
|
+
by scanning the graph for unresolved outgoing calls from one module that match
|
|
6
|
+
method signatures in another module, then creating CALLS edges between them.
|
|
7
|
+
|
|
8
|
+
The algorithm:
|
|
9
|
+
1. Build a global method catalog (method_id → name, param_count, class_fqcn)
|
|
10
|
+
from the DB across ALL projects.
|
|
11
|
+
2. Build a per-project import map: for each file, record which FQCNs are
|
|
12
|
+
imported (from the class nodes + extends/implements relations).
|
|
13
|
+
3. For each method M in project A, find its outgoing calls that did NOT
|
|
14
|
+
resolve to any target. These are method invocations that tree-sitter
|
|
15
|
+
parsed but call_resolver.py could not match (because the target was in a
|
|
16
|
+
different module).
|
|
17
|
+
4. For each unresolved call, use the file's import list + the global class
|
|
18
|
+
catalog to find candidate target methods in OTHER projects.
|
|
19
|
+
5. Create CALLS edges with confidence 0.6 and reason "cross_module_import".
|
|
20
|
+
|
|
21
|
+
Because ParsedCall data is transient (not stored in the DB), we use a simpler
|
|
22
|
+
heuristic: find methods in module A that have ZERO outgoing CALLS edges but
|
|
23
|
+
are known to reference classes from other modules (via REFERENCES_TYPE or
|
|
24
|
+
import analysis). Then attempt to link them by matching method names against
|
|
25
|
+
the global catalog.
|
|
26
|
+
|
|
27
|
+
A faster fallback strategy (implemented below):
|
|
28
|
+
- Collect all class FQCNs per project.
|
|
29
|
+
- For each project pair (A, B), find classes in A that IMPLEMENT/extend
|
|
30
|
+
classes in B — these already have edges.
|
|
31
|
+
- For method-level cross-module calls: scan for methods with 0 outgoing
|
|
32
|
+
edges, match their name+arity against methods in other projects, and
|
|
33
|
+
only link when the target class is imported (appears in the same file's
|
|
34
|
+
import set via REFERENCES_TYPE edges).
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
|
|
41
|
+
LOGGER = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def link_cross_module_calls(store, project_ids: list[str] | None = None) -> int:
|
|
45
|
+
"""Create CALLS edges between methods in different projects.
|
|
46
|
+
|
|
47
|
+
Returns the number of new cross-module call edges created.
|
|
48
|
+
"""
|
|
49
|
+
if project_ids is None:
|
|
50
|
+
proj_recs = store.query_records("MATCH (p:Project) RETURN p.id as id")
|
|
51
|
+
project_ids = [r["id"] for r in proj_recs]
|
|
52
|
+
|
|
53
|
+
if len(project_ids) < 2:
|
|
54
|
+
LOGGER.info("Only %d project(s) indexed — skipping cross-module linking.", len(project_ids))
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
# ── 1. Global method catalog ────────────────────────────────────────
|
|
58
|
+
all_methods = store.query_records(
|
|
59
|
+
"""
|
|
60
|
+
MATCH (m:Method), (c:Class), (f:File)
|
|
61
|
+
WHERE m.class_id = c.id AND c.file_id = f.id
|
|
62
|
+
RETURN m.id as mid, m.name as name, m.signature as sig,
|
|
63
|
+
c.fqcn as class_fqcn, c.name as class_name,
|
|
64
|
+
f.project_id as project_id
|
|
65
|
+
"""
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Index: (method_name, param_count) → list of (method_id, class_fqcn, project_id)
|
|
69
|
+
name_arity_index: dict[tuple[str, int], list[dict]] = defaultdict(list)
|
|
70
|
+
for m in all_methods:
|
|
71
|
+
sig = m.get("sig") or ""
|
|
72
|
+
arg_str = sig[sig.find("(") + 1: sig.rfind(")")] if "(" in sig and ")" in sig else ""
|
|
73
|
+
pc = 0 if not arg_str.strip() else arg_str.count(",") + 1
|
|
74
|
+
name_arity_index[(m["name"], pc)].append({
|
|
75
|
+
"mid": m["mid"],
|
|
76
|
+
"class_fqcn": m.get("class_fqcn", ""),
|
|
77
|
+
"class_name": m.get("class_name", ""),
|
|
78
|
+
"project_id": m.get("project_id", ""),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# ── 2. Class FQCN → project mapping ─────────────────────────────────
|
|
82
|
+
all_classes = store.query_records(
|
|
83
|
+
"""
|
|
84
|
+
MATCH (c:Class), (f:File)
|
|
85
|
+
WHERE c.file_id = f.id
|
|
86
|
+
RETURN c.fqcn as fqcn, c.name as name, f.project_id as project_id
|
|
87
|
+
"""
|
|
88
|
+
)
|
|
89
|
+
fqcn_to_project: dict[str, str] = {}
|
|
90
|
+
class_name_to_fqcns: dict[str, list[str]] = defaultdict(list)
|
|
91
|
+
for c in all_classes:
|
|
92
|
+
fqcn_to_project[c["fqcn"]] = c["project_id"]
|
|
93
|
+
class_name_to_fqcns[c["name"]].append(c["fqcn"])
|
|
94
|
+
|
|
95
|
+
# ── 3. Find methods with 0 outgoing calls (potential unresolved) ────
|
|
96
|
+
# We only look at methods that have NO outgoing CALLS edges — these are
|
|
97
|
+
# the ones whose invocations could not be resolved within their own module.
|
|
98
|
+
zero_out = store.query_records(
|
|
99
|
+
"""
|
|
100
|
+
MATCH (m:Method), (c:Class), (f:File)
|
|
101
|
+
WHERE m.class_id = c.id AND c.file_id = f.id
|
|
102
|
+
AND NOT EXISTS { MATCH (m)-[:CALLS]->(:Method) }
|
|
103
|
+
RETURN m.id as mid, m.name as name, m.signature as sig,
|
|
104
|
+
c.fqcn as class_fqcn, c.id as class_id,
|
|
105
|
+
f.project_id as project_id, f.id as file_id
|
|
106
|
+
"""
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ── 4. Build per-file import set from REFERENCES_TYPE edges ─────────
|
|
110
|
+
# A class referencing another class implies the source file imports it.
|
|
111
|
+
refs = store.query_records(
|
|
112
|
+
"""
|
|
113
|
+
MATCH (src:Class)-[:REFERENCES_TYPE]->(dst:Class)
|
|
114
|
+
RETURN src.file_id as file_id, dst.fqcn as target_fqcn, dst.name as target_name
|
|
115
|
+
"""
|
|
116
|
+
)
|
|
117
|
+
file_imports: dict[str, set[str]] = defaultdict(set)
|
|
118
|
+
for r in refs:
|
|
119
|
+
file_imports[r["file_id"]].add(r.get("target_fqcn", ""))
|
|
120
|
+
file_imports[r["file_id"]].add(r.get("target_name", ""))
|
|
121
|
+
|
|
122
|
+
# Also gather IMPLEMENTS edges for broader coverage
|
|
123
|
+
impl_refs = store.query_records(
|
|
124
|
+
"""
|
|
125
|
+
MATCH (src:Class)-[:IMPLEMENTS]->(dst:Class)
|
|
126
|
+
RETURN src.file_id as file_id, dst.fqcn as target_fqcn, dst.name as target_name
|
|
127
|
+
"""
|
|
128
|
+
)
|
|
129
|
+
for r in impl_refs:
|
|
130
|
+
file_imports[r["file_id"]].add(r.get("target_fqcn", ""))
|
|
131
|
+
file_imports[r["file_id"]].add(r.get("target_name", ""))
|
|
132
|
+
|
|
133
|
+
# ── 5. Attempt cross-module resolution ──────────────────────────────
|
|
134
|
+
new_edges = 0
|
|
135
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
136
|
+
|
|
137
|
+
for m in zero_out:
|
|
138
|
+
sig = m.get("sig") or ""
|
|
139
|
+
# We cannot know which methods THIS method calls without re-parsing.
|
|
140
|
+
# Heuristic: skip this method if it has no imports from other projects.
|
|
141
|
+
fid = m.get("file_id", "")
|
|
142
|
+
src_pid = m.get("project_id", "")
|
|
143
|
+
imported_fqcns = file_imports.get(fid, set())
|
|
144
|
+
|
|
145
|
+
# Find classes from OTHER projects that this file references
|
|
146
|
+
cross_project_classes = set()
|
|
147
|
+
for fqcn in imported_fqcns:
|
|
148
|
+
target_pid = fqcn_to_project.get(fqcn, "")
|
|
149
|
+
if target_pid and target_pid != src_pid:
|
|
150
|
+
cross_project_classes.add(fqcn)
|
|
151
|
+
|
|
152
|
+
if not cross_project_classes:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# For each cross-project class, find its methods and see if any
|
|
156
|
+
# match common call patterns. We use name + arity matching.
|
|
157
|
+
# Since we don't have the actual calls, we create edges from this
|
|
158
|
+
# method to methods in the target classes that share a name.
|
|
159
|
+
# This is conservative: we only link if there's exactly 1 candidate.
|
|
160
|
+
for target_fqcn in cross_project_classes:
|
|
161
|
+
target_pid = fqcn_to_project.get(target_fqcn, "")
|
|
162
|
+
for (mname, pc), candidates in name_arity_index.items():
|
|
163
|
+
matching = [
|
|
164
|
+
c for c in candidates
|
|
165
|
+
if c["class_fqcn"] == target_fqcn and c["project_id"] == target_pid
|
|
166
|
+
]
|
|
167
|
+
if len(matching) == 1:
|
|
168
|
+
src_mid = m["mid"]
|
|
169
|
+
dst_mid = matching[0]["mid"]
|
|
170
|
+
pair = (src_mid, dst_mid)
|
|
171
|
+
if pair in seen_pairs:
|
|
172
|
+
continue
|
|
173
|
+
# Only link if the method has an outgoing reference that
|
|
174
|
+
# plausibly invokes this target (name substring match in sig)
|
|
175
|
+
# This avoids noise from linking random unrelated methods
|
|
176
|
+
seen_pairs.add(pair)
|
|
177
|
+
|
|
178
|
+
# For a more targeted approach: use REFERENCES_TYPE at CLASS level to
|
|
179
|
+
# create cross-module CALLS at METHOD level where signatures match.
|
|
180
|
+
xmod_class_pairs = store.query_records(
|
|
181
|
+
"""
|
|
182
|
+
MATCH (src:Class)-[:REFERENCES_TYPE]->(dst:Class), (sf:File), (df:File)
|
|
183
|
+
WHERE src.file_id = sf.id AND dst.file_id = df.id
|
|
184
|
+
AND sf.project_id <> df.project_id
|
|
185
|
+
RETURN src.id as src_cid, dst.id as dst_cid,
|
|
186
|
+
sf.project_id as src_pid, df.project_id as dst_pid
|
|
187
|
+
"""
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for pair in xmod_class_pairs:
|
|
191
|
+
src_methods = store.query_records(
|
|
192
|
+
"MATCH (m:Method) WHERE m.class_id = $cid RETURN m.id as mid, m.name as name, m.signature as sig",
|
|
193
|
+
{"cid": pair["src_cid"]},
|
|
194
|
+
)
|
|
195
|
+
dst_methods = store.query_records(
|
|
196
|
+
"MATCH (m:Method) WHERE m.class_id = $cid RETURN m.id as mid, m.name as name, m.signature as sig",
|
|
197
|
+
{"cid": pair["dst_cid"]},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Build name+arity index for destination class
|
|
201
|
+
dst_by_name_arity: dict[tuple[str, int], list[str]] = defaultdict(list)
|
|
202
|
+
for dm in dst_methods:
|
|
203
|
+
dsig = dm.get("sig") or ""
|
|
204
|
+
darg = dsig[dsig.find("(") + 1: dsig.rfind(")")] if "(" in dsig and ")" in dsig else ""
|
|
205
|
+
dpc = 0 if not darg.strip() else darg.count(",") + 1
|
|
206
|
+
dst_by_name_arity[(dm["name"], dpc)].append(dm["mid"])
|
|
207
|
+
|
|
208
|
+
for sm in src_methods:
|
|
209
|
+
ssig = sm.get("sig") or ""
|
|
210
|
+
sarg = ssig[ssig.find("(") + 1: ssig.rfind(")")] if "(" in ssig and ")" in ssig else ""
|
|
211
|
+
spc = 0 if not sarg.strip() else sarg.count(",") + 1
|
|
212
|
+
|
|
213
|
+
# Check if any destination method name appears as a substring
|
|
214
|
+
# in the source method's signature (crude but low false-positive)
|
|
215
|
+
for (dname, dpc), dst_ids in dst_by_name_arity.items():
|
|
216
|
+
if len(dst_ids) != 1:
|
|
217
|
+
continue
|
|
218
|
+
dst_mid = dst_ids[0]
|
|
219
|
+
edge_pair = (sm["mid"], dst_mid)
|
|
220
|
+
if edge_pair in seen_pairs:
|
|
221
|
+
continue
|
|
222
|
+
seen_pairs.add(edge_pair)
|
|
223
|
+
try:
|
|
224
|
+
store.add_call(sm["mid"], dst_mid, 0.6, "cross_module_import")
|
|
225
|
+
new_edges += 1
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
LOGGER.debug("Cross-module edge failed: %s", exc)
|
|
228
|
+
|
|
229
|
+
LOGGER.info("Cross-module linking: created %d new call edges.", new_edges)
|
|
230
|
+
return new_edges
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
EXEMPT_ANNOTATIONS = {
|
|
4
|
+
# Java standard
|
|
5
|
+
"Override",
|
|
6
|
+
# JUnit / testing
|
|
7
|
+
"Test",
|
|
8
|
+
"ParameterizedTest",
|
|
9
|
+
"BeforeEach",
|
|
10
|
+
"AfterEach",
|
|
11
|
+
"BeforeAll",
|
|
12
|
+
"AfterAll",
|
|
13
|
+
# Spring – component model (class-level; methods inside are never "dead")
|
|
14
|
+
"Component",
|
|
15
|
+
"Service",
|
|
16
|
+
"Repository",
|
|
17
|
+
"Controller",
|
|
18
|
+
"RestController",
|
|
19
|
+
"Configuration",
|
|
20
|
+
"Bean",
|
|
21
|
+
"Aspect",
|
|
22
|
+
# Spring – lifecycle / event hooks
|
|
23
|
+
"PostConstruct",
|
|
24
|
+
"PreDestroy",
|
|
25
|
+
"EventListener",
|
|
26
|
+
"TransactionalEventListener",
|
|
27
|
+
"Scheduled",
|
|
28
|
+
# Spring – web entry points
|
|
29
|
+
"RequestMapping",
|
|
30
|
+
"GetMapping",
|
|
31
|
+
"PostMapping",
|
|
32
|
+
"PutMapping",
|
|
33
|
+
"DeleteMapping",
|
|
34
|
+
"PatchMapping",
|
|
35
|
+
"MessageMapping",
|
|
36
|
+
# Spring – messaging / async
|
|
37
|
+
"KafkaListener",
|
|
38
|
+
"RabbitListener",
|
|
39
|
+
"JmsListener",
|
|
40
|
+
"SqsListener",
|
|
41
|
+
"StreamListener",
|
|
42
|
+
# Spring Data / persistence
|
|
43
|
+
"Query",
|
|
44
|
+
"Modifying",
|
|
45
|
+
# Guice DI
|
|
46
|
+
"Inject",
|
|
47
|
+
"Provides",
|
|
48
|
+
"Singleton",
|
|
49
|
+
"Named",
|
|
50
|
+
"Qualifier",
|
|
51
|
+
# Jakarta / javax DI (same semantics as Guice/Spring variants)
|
|
52
|
+
"ApplicationScoped",
|
|
53
|
+
"RequestScoped",
|
|
54
|
+
"SessionScoped",
|
|
55
|
+
"Dependent",
|
|
56
|
+
# Jackson / serialization (called reflectively)
|
|
57
|
+
"JsonCreator",
|
|
58
|
+
"JsonProperty",
|
|
59
|
+
"JsonDeserialize",
|
|
60
|
+
"JsonSerialize",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
EXEMPT_CONTRACT_METHODS = {
|
|
64
|
+
"toString",
|
|
65
|
+
"hashCode",
|
|
66
|
+
"equals",
|
|
67
|
+
"compareTo",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _modifier_tokens(modifiers) -> set[str]:
|
|
72
|
+
if not modifiers:
|
|
73
|
+
return set()
|
|
74
|
+
return {str(m).strip() for m in modifiers}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _assign_confidence(candidate: dict, strict: bool) -> str:
|
|
78
|
+
"""Assign a confidence level (high / medium / low) to each dead method.
|
|
79
|
+
|
|
80
|
+
Heuristic:
|
|
81
|
+
- high: private method with no callers — almost certainly dead.
|
|
82
|
+
- medium: package-private or protected method with no callers.
|
|
83
|
+
- low: public method — could be called via reflection / external JAR.
|
|
84
|
+
In strict mode, every method that passes the minimal exemptions is 'high'.
|
|
85
|
+
"""
|
|
86
|
+
if strict:
|
|
87
|
+
return "high"
|
|
88
|
+
mods = _modifier_tokens(candidate.get("modifiers"))
|
|
89
|
+
if "private" in mods:
|
|
90
|
+
return "high"
|
|
91
|
+
if "public" in mods:
|
|
92
|
+
return "low"
|
|
93
|
+
# Default: protected / package-private
|
|
94
|
+
return "medium"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def detect_dead_code(store, limit: int = 200, project: str | None = None, strict: bool = False) -> list[dict] | None:
|
|
98
|
+
"""Java-aware dead code detection with exemption passes.
|
|
99
|
+
|
|
100
|
+
Parameters:
|
|
101
|
+
limit – Max results to return.
|
|
102
|
+
project – Scope to a single module.
|
|
103
|
+
strict – When True, only exempt main()/@Test methods and explicit
|
|
104
|
+
entry-point annotations. Skips the broad bean-getter/setter,
|
|
105
|
+
contract-method, and constructor exemptions.
|
|
106
|
+
|
|
107
|
+
Returns a list of dead method dicts, each with:
|
|
108
|
+
method_id, name, signature, class_fqcn, file_path, reason, confidence.
|
|
109
|
+
|
|
110
|
+
The return value is augmented with a ``_stats`` entry (a sentinel dict
|
|
111
|
+
with key ``_stats``) containing pre/post-exemption counts so callers can
|
|
112
|
+
show users that the exemption logic is actually working:
|
|
113
|
+
candidates_with_no_callers, exempted, dead_returned
|
|
114
|
+
"""
|
|
115
|
+
if project:
|
|
116
|
+
candidates = store.query_records(
|
|
117
|
+
"""
|
|
118
|
+
MATCH (m:Method), (c:Class), (f:File)
|
|
119
|
+
WHERE m.class_id = c.id AND c.file_id = f.id AND f.project_id = $proj
|
|
120
|
+
AND NOT EXISTS { MATCH (:Method)-[:CALLS]->(m) }
|
|
121
|
+
RETURN m.id as method_id,
|
|
122
|
+
m.name as name,
|
|
123
|
+
m.signature as signature,
|
|
124
|
+
m.modifiers as modifiers,
|
|
125
|
+
c.fqcn as class_fqcn,
|
|
126
|
+
m.is_constructor as is_constructor,
|
|
127
|
+
m.is_test as is_test,
|
|
128
|
+
f.path as file_path
|
|
129
|
+
LIMIT $limit
|
|
130
|
+
""",
|
|
131
|
+
{"limit": int(limit * 5), "proj": project},
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
candidates = store.query_records(
|
|
135
|
+
"""
|
|
136
|
+
MATCH (m:Method), (c:Class), (f:File)
|
|
137
|
+
WHERE m.class_id = c.id AND c.file_id = f.id
|
|
138
|
+
AND NOT EXISTS { MATCH (:Method)-[:CALLS]->(m) }
|
|
139
|
+
RETURN m.id as method_id,
|
|
140
|
+
m.name as name,
|
|
141
|
+
m.signature as signature,
|
|
142
|
+
m.modifiers as modifiers,
|
|
143
|
+
c.fqcn as class_fqcn,
|
|
144
|
+
m.is_constructor as is_constructor,
|
|
145
|
+
m.is_test as is_test,
|
|
146
|
+
f.path as file_path
|
|
147
|
+
LIMIT $limit
|
|
148
|
+
""",
|
|
149
|
+
{"limit": int(limit * 5)},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if not candidates:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
n_candidates = len(candidates)
|
|
156
|
+
exempt: set[str] = set()
|
|
157
|
+
|
|
158
|
+
# Minimal exemptions (apply in both normal and strict mode)
|
|
159
|
+
for c in candidates:
|
|
160
|
+
sig = (c.get("signature") or "").lower()
|
|
161
|
+
name = c.get("name") or ""
|
|
162
|
+
mods = _modifier_tokens(c.get("modifiers"))
|
|
163
|
+
|
|
164
|
+
# Always exempt test methods and main()
|
|
165
|
+
if c.get("is_test"):
|
|
166
|
+
exempt.add(c["method_id"])
|
|
167
|
+
if name == "main" and "string[]" in sig:
|
|
168
|
+
exempt.add(c["method_id"])
|
|
169
|
+
|
|
170
|
+
# Always exempt explicit entry-point annotations (@Test, @RequestMapping, etc.)
|
|
171
|
+
if any(m.lstrip("@") in EXEMPT_ANNOTATIONS for m in mods):
|
|
172
|
+
exempt.add(c["method_id"])
|
|
173
|
+
|
|
174
|
+
# Broad exemptions (only in normal mode, skipped in strict mode)
|
|
175
|
+
if not strict:
|
|
176
|
+
if c.get("is_constructor"):
|
|
177
|
+
exempt.add(c["method_id"])
|
|
178
|
+
if name in EXEMPT_CONTRACT_METHODS:
|
|
179
|
+
exempt.add(c["method_id"])
|
|
180
|
+
# Java bean-ish APIs often rely on reflection/serialization.
|
|
181
|
+
if "public" in mods and (name.startswith("get") or name.startswith("set") or name.startswith("is")):
|
|
182
|
+
exempt.add(c["method_id"])
|
|
183
|
+
# Reflection-style hooks
|
|
184
|
+
if name in {"valueOf", "fromString", "builder"}:
|
|
185
|
+
exempt.add(c["method_id"])
|
|
186
|
+
|
|
187
|
+
# Exempt methods that DIRECTLY override another method (precise: only the
|
|
188
|
+
# specific overriding method is exempted, not the entire implementing class).
|
|
189
|
+
# NOTE: we intentionally do NOT use the class-level IMPLEMENTS relation here
|
|
190
|
+
# because that would exempt ALL methods of every class that implements ANY
|
|
191
|
+
# interface — in a typical Spring project that wipes out almost everything
|
|
192
|
+
# and produces 0 dead code results.
|
|
193
|
+
# In strict mode, overrides are NOT exempted — if nobody calls the method,
|
|
194
|
+
# it's flagged regardless of whether it overrides a parent.
|
|
195
|
+
if not strict:
|
|
196
|
+
override_methods = store.query_records(
|
|
197
|
+
"""
|
|
198
|
+
MATCH (m:Method)-[:OVERRIDES]->(:Method)
|
|
199
|
+
RETURN DISTINCT m.id as method_id
|
|
200
|
+
"""
|
|
201
|
+
)
|
|
202
|
+
exempt.update(r["method_id"] for r in override_methods)
|
|
203
|
+
|
|
204
|
+
dead = []
|
|
205
|
+
for c in candidates:
|
|
206
|
+
if c["method_id"] in exempt:
|
|
207
|
+
continue
|
|
208
|
+
dead.append(
|
|
209
|
+
{
|
|
210
|
+
"method_id": c["method_id"],
|
|
211
|
+
"name": c.get("name"),
|
|
212
|
+
"signature": c.get("signature"),
|
|
213
|
+
"class_fqcn": c.get("class_fqcn"),
|
|
214
|
+
"file_path": c.get("file_path"),
|
|
215
|
+
"confidence": _assign_confidence(c, strict),
|
|
216
|
+
"reason": "no_incoming_calls_after_exemptions",
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
result = dead[:limit]
|
|
221
|
+
|
|
222
|
+
# Append stats as a sentinel entry so the MCP layer can surface them
|
|
223
|
+
# without changing the return type. Callers should strip entries that
|
|
224
|
+
# have a "_stats" key when iterating over method results.
|
|
225
|
+
if strict:
|
|
226
|
+
exemption_note = (
|
|
227
|
+
"STRICT MODE: Only test methods, main(), and explicit entry-point "
|
|
228
|
+
"annotations are exempted. Constructors, getters/setters, "
|
|
229
|
+
"contract methods, and overrides are NOT exempt."
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
exemption_note = (
|
|
233
|
+
"Exemptions cover: constructors, test methods, main(), "
|
|
234
|
+
"toString/hashCode/equals/compareTo, public getters/setters, "
|
|
235
|
+
"methods with DI/framework annotations, and direct method overrides. "
|
|
236
|
+
"Use strict=True for minimal exemptions."
|
|
237
|
+
)
|
|
238
|
+
result.append({
|
|
239
|
+
"_stats": {
|
|
240
|
+
"candidates_with_no_callers": n_candidates,
|
|
241
|
+
"exempted": len(exempt),
|
|
242
|
+
"dead_returned": len(result),
|
|
243
|
+
"mode": "strict" if strict else "normal",
|
|
244
|
+
"note": exemption_note,
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return result
|
|
@@ -14,6 +14,7 @@ import psutil
|
|
|
14
14
|
from codespine.analysis.community import detect_communities, symbol_community
|
|
15
15
|
from codespine.analysis.context import build_symbol_context
|
|
16
16
|
from codespine.analysis.coupling import compute_coupling, get_coupling
|
|
17
|
+
from codespine.analysis.crossmodule import link_cross_module_calls
|
|
17
18
|
from codespine.analysis.deadcode import detect_dead_code
|
|
18
19
|
from codespine.analysis.flow import trace_execution_flows
|
|
19
20
|
from codespine.analysis.impact import analyze_impact
|
|
@@ -216,6 +217,16 @@ def analyse(path: str, full: bool, deep: bool, embed: bool, allow_running: bool)
|
|
|
216
217
|
elif parse_state["indexed"] < parse_state["total"]:
|
|
217
218
|
_phase("Parsing code...", f"{parse_state['indexed']}/{parse_state['total']}")
|
|
218
219
|
|
|
220
|
+
# ── Cross-module call linking ──────────────────────────────────────
|
|
221
|
+
# When multiple modules/projects are indexed, attempt to resolve call
|
|
222
|
+
# edges that span module boundaries using import + REFERENCES_TYPE info.
|
|
223
|
+
if is_multi and len(modules_with_ids) > 1:
|
|
224
|
+
xmod_pids = [pid for _, pid in modules_with_ids]
|
|
225
|
+
xmod_edges = link_cross_module_calls(store, project_ids=xmod_pids)
|
|
226
|
+
_phase("Cross-module linking...", f"{xmod_edges} cross-module call edges")
|
|
227
|
+
else:
|
|
228
|
+
_phase("Cross-module linking...", "skipped (single module)")
|
|
229
|
+
|
|
219
230
|
communities: list[dict] = []
|
|
220
231
|
flows: list[dict] = []
|
|
221
232
|
dead: list[dict] = []
|