codespine 0.4.3__tar.gz → 0.5.1__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.3 → codespine-0.5.1}/PKG-INFO +1 -1
- {codespine-0.4.3 → codespine-0.5.1}/codespine/__init__.py +1 -1
- codespine-0.5.1/codespine/analysis/crossmodule.py +173 -0
- codespine-0.5.1/codespine/analysis/deadcode.py +308 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/cli.py +11 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/mcp/server.py +354 -19
- {codespine-0.4.3 → codespine-0.5.1}/codespine/search/hybrid.py +30 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine.egg-info/PKG-INFO +1 -1
- {codespine-0.4.3 → codespine-0.5.1}/codespine.egg-info/SOURCES.txt +1 -0
- {codespine-0.4.3 → codespine-0.5.1}/pyproject.toml +1 -1
- codespine-0.4.3/codespine/analysis/deadcode.py +0 -202
- {codespine-0.4.3 → codespine-0.5.1}/LICENSE +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/README.md +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/analysis/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/analysis/community.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/analysis/context.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/analysis/coupling.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/analysis/flow.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/analysis/impact.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/config.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/db/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/db/schema.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/db/store.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/diff/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/diff/branch_diff.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/indexer/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/indexer/call_resolver.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/indexer/engine.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/indexer/java_parser.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/indexer/symbol_builder.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/mcp/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/noise/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/noise/blocklist.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/search/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/search/bm25.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/search/fuzzy.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/search/rrf.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/search/vector.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/watch/__init__.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine/watch/watcher.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine.egg-info/dependency_links.txt +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine.egg-info/entry_points.txt +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine.egg-info/requires.txt +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/codespine.egg-info/top_level.txt +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/gindex.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/setup.cfg +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/tests/test_branch_diff_normalize.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/tests/test_call_resolver.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/tests/test_index_and_hybrid.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/tests/test_java_parser.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/tests/test_multimodule_index.py +0 -0
- {codespine-0.4.3 → codespine-0.5.1}/tests/test_search_ranking.py +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
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 cross-project class references (REFERENCES_TYPE and
|
|
6
|
+
IMPLEMENTS edges) and creating CALLS edges between methods where the call is
|
|
7
|
+
plausible.
|
|
8
|
+
|
|
9
|
+
Strategy A — Name + arity match (confidence 0.7)
|
|
10
|
+
If src_class references dst_class (cross-project) and both have a method
|
|
11
|
+
with the same name and same parameter count, create a CALLS edge. This
|
|
12
|
+
catches delegation, interface-implementation forwarding, and adapter
|
|
13
|
+
patterns.
|
|
14
|
+
|
|
15
|
+
Strategy B — Type-reference fallback (confidence 0.4)
|
|
16
|
+
For each *public* method in dst_class that received NO name-match edge,
|
|
17
|
+
create ONE low-confidence edge from a representative src method (preferring
|
|
18
|
+
one with zero outgoing calls). This prevents methods that are genuinely
|
|
19
|
+
used cross-module from appearing as dead code.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
|
|
26
|
+
LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _param_count(sig: str) -> int:
|
|
30
|
+
"""Count parameters from a method signature string."""
|
|
31
|
+
if not sig or "(" not in sig or ")" not in sig:
|
|
32
|
+
return 0
|
|
33
|
+
arg_str = sig[sig.find("(") + 1: sig.rfind(")")]
|
|
34
|
+
return 0 if not arg_str.strip() else arg_str.count(",") + 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def link_cross_module_calls(store, project_ids: list[str] | None = None) -> int:
|
|
38
|
+
"""Create CALLS edges between methods in different projects.
|
|
39
|
+
|
|
40
|
+
Returns the number of new cross-module call edges created.
|
|
41
|
+
"""
|
|
42
|
+
if project_ids is None:
|
|
43
|
+
proj_recs = store.query_records("MATCH (p:Project) RETURN p.id as id")
|
|
44
|
+
project_ids = [r["id"] for r in proj_recs]
|
|
45
|
+
|
|
46
|
+
if len(project_ids) < 2:
|
|
47
|
+
LOGGER.info(
|
|
48
|
+
"Only %d project(s) indexed — skipping cross-module linking.",
|
|
49
|
+
len(project_ids),
|
|
50
|
+
)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
# ── 1. Collect cross-project class pairs ──────────────────────────
|
|
54
|
+
ref_pairs = store.query_records(
|
|
55
|
+
"""
|
|
56
|
+
MATCH (src:Class)-[:REFERENCES_TYPE]->(dst:Class), (sf:File), (df:File)
|
|
57
|
+
WHERE src.file_id = sf.id AND dst.file_id = df.id
|
|
58
|
+
AND sf.project_id <> df.project_id
|
|
59
|
+
RETURN DISTINCT src.id as src_cid, dst.id as dst_cid
|
|
60
|
+
"""
|
|
61
|
+
)
|
|
62
|
+
impl_pairs = store.query_records(
|
|
63
|
+
"""
|
|
64
|
+
MATCH (src:Class)-[:IMPLEMENTS]->(dst:Class), (sf:File), (df:File)
|
|
65
|
+
WHERE src.file_id = sf.id AND dst.file_id = df.id
|
|
66
|
+
AND sf.project_id <> df.project_id
|
|
67
|
+
RETURN DISTINCT src.id as src_cid, dst.id as dst_cid
|
|
68
|
+
"""
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
all_pairs: set[tuple[str, str]] = set()
|
|
72
|
+
for p in ref_pairs:
|
|
73
|
+
all_pairs.add((p["src_cid"], p["dst_cid"]))
|
|
74
|
+
for p in impl_pairs:
|
|
75
|
+
all_pairs.add((p["src_cid"], p["dst_cid"]))
|
|
76
|
+
|
|
77
|
+
if not all_pairs:
|
|
78
|
+
LOGGER.info("No cross-project class references found.")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
LOGGER.info(
|
|
82
|
+
"Cross-module: %d cross-project class pair(s) to process.",
|
|
83
|
+
len(all_pairs),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── 2. Process each class pair ────────────────────────────────────
|
|
87
|
+
new_edges = 0
|
|
88
|
+
seen: set[tuple[str, str]] = set()
|
|
89
|
+
|
|
90
|
+
for src_cid, dst_cid in all_pairs:
|
|
91
|
+
src_methods = store.query_records(
|
|
92
|
+
"""MATCH (m:Method) WHERE m.class_id = $cid
|
|
93
|
+
RETURN m.id as mid, m.name as name, m.signature as sig""",
|
|
94
|
+
{"cid": src_cid},
|
|
95
|
+
)
|
|
96
|
+
dst_methods = store.query_records(
|
|
97
|
+
"""MATCH (m:Method) WHERE m.class_id = $cid
|
|
98
|
+
RETURN m.id as mid, m.name as name, m.signature as sig,
|
|
99
|
+
m.modifiers as modifiers, m.is_constructor as is_ctor""",
|
|
100
|
+
{"cid": dst_cid},
|
|
101
|
+
)
|
|
102
|
+
if not src_methods or not dst_methods:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# Build name → methods index for src class
|
|
106
|
+
src_by_name: dict[str, list[dict]] = defaultdict(list)
|
|
107
|
+
for sm in src_methods:
|
|
108
|
+
src_by_name[sm["name"]].append(sm)
|
|
109
|
+
|
|
110
|
+
# ── Strategy A: name + arity matching ─────────────────────────
|
|
111
|
+
matched_dst_mids: set[str] = set()
|
|
112
|
+
|
|
113
|
+
for dm in dst_methods:
|
|
114
|
+
dm_name = dm["name"]
|
|
115
|
+
dm_pc = _param_count(dm.get("sig") or "")
|
|
116
|
+
candidates = src_by_name.get(dm_name, [])
|
|
117
|
+
for sm in candidates:
|
|
118
|
+
sm_pc = _param_count(sm.get("sig") or "")
|
|
119
|
+
if sm_pc == dm_pc:
|
|
120
|
+
pair = (sm["mid"], dm["mid"])
|
|
121
|
+
if pair in seen:
|
|
122
|
+
matched_dst_mids.add(dm["mid"])
|
|
123
|
+
continue
|
|
124
|
+
seen.add(pair)
|
|
125
|
+
try:
|
|
126
|
+
store.add_call(
|
|
127
|
+
sm["mid"], dm["mid"], 0.7, "cross_module_name_match",
|
|
128
|
+
)
|
|
129
|
+
new_edges += 1
|
|
130
|
+
matched_dst_mids.add(dm["mid"])
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
LOGGER.debug("Name-match edge failed: %s", exc)
|
|
133
|
+
|
|
134
|
+
# ── Strategy B: fallback for unmatched public dst methods ─────
|
|
135
|
+
# Find a representative caller: prefer src methods with 0 outgoing calls
|
|
136
|
+
fallback_src = None
|
|
137
|
+
for sm in src_methods:
|
|
138
|
+
out = store.query_records(
|
|
139
|
+
"MATCH (m:Method {id: $mid})-[:CALLS]->(:Method) RETURN count(*) as n",
|
|
140
|
+
{"mid": sm["mid"]},
|
|
141
|
+
)
|
|
142
|
+
if out and out[0]["n"] == 0:
|
|
143
|
+
fallback_src = sm
|
|
144
|
+
break
|
|
145
|
+
if fallback_src is None and src_methods:
|
|
146
|
+
fallback_src = src_methods[0]
|
|
147
|
+
|
|
148
|
+
if fallback_src:
|
|
149
|
+
for dm in dst_methods:
|
|
150
|
+
if dm["mid"] in matched_dst_mids:
|
|
151
|
+
continue
|
|
152
|
+
# Skip constructors and private methods
|
|
153
|
+
if dm.get("is_ctor"):
|
|
154
|
+
continue
|
|
155
|
+
mods = dm.get("modifiers") or []
|
|
156
|
+
mod_strs = {str(m).strip() for m in mods} if mods else set()
|
|
157
|
+
if "private" in mod_strs:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
pair = (fallback_src["mid"], dm["mid"])
|
|
161
|
+
if pair in seen:
|
|
162
|
+
continue
|
|
163
|
+
seen.add(pair)
|
|
164
|
+
try:
|
|
165
|
+
store.add_call(
|
|
166
|
+
fallback_src["mid"], dm["mid"], 0.4, "cross_module_type_ref",
|
|
167
|
+
)
|
|
168
|
+
new_edges += 1
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
LOGGER.debug("Fallback edge failed: %s", exc)
|
|
171
|
+
|
|
172
|
+
LOGGER.info("Cross-module linking: created %d new call edges.", new_edges)
|
|
173
|
+
return new_edges
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
|
|
5
|
+
# ── Annotation sets ──────────────────────────────────────────────────
|
|
6
|
+
# Entry-point annotations — exempt even in strict mode. These represent
|
|
7
|
+
# actual runtime entry points that the framework calls reflectively.
|
|
8
|
+
ENTRY_POINT_ANNOTATIONS = {
|
|
9
|
+
# JUnit / testing
|
|
10
|
+
"Test",
|
|
11
|
+
"ParameterizedTest",
|
|
12
|
+
"BeforeEach",
|
|
13
|
+
"AfterEach",
|
|
14
|
+
"BeforeAll",
|
|
15
|
+
"AfterAll",
|
|
16
|
+
# Spring – web entry points
|
|
17
|
+
"RequestMapping",
|
|
18
|
+
"GetMapping",
|
|
19
|
+
"PostMapping",
|
|
20
|
+
"PutMapping",
|
|
21
|
+
"DeleteMapping",
|
|
22
|
+
"PatchMapping",
|
|
23
|
+
"MessageMapping",
|
|
24
|
+
# Spring – messaging / async entry points
|
|
25
|
+
"KafkaListener",
|
|
26
|
+
"RabbitListener",
|
|
27
|
+
"JmsListener",
|
|
28
|
+
"SqsListener",
|
|
29
|
+
"StreamListener",
|
|
30
|
+
# Spring – lifecycle / event hooks
|
|
31
|
+
"PostConstruct",
|
|
32
|
+
"PreDestroy",
|
|
33
|
+
"EventListener",
|
|
34
|
+
"TransactionalEventListener",
|
|
35
|
+
"Scheduled",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Broad annotations — exempt only in normal mode. These indicate the
|
|
39
|
+
# method is *likely* used via DI / serialisation / reflection, but in a
|
|
40
|
+
# strict audit the user may want to verify that manually.
|
|
41
|
+
BROAD_ANNOTATIONS = {
|
|
42
|
+
# Java standard
|
|
43
|
+
"Override",
|
|
44
|
+
# Spring – component model (class-level; methods inside are never "dead")
|
|
45
|
+
"Component",
|
|
46
|
+
"Service",
|
|
47
|
+
"Repository",
|
|
48
|
+
"Controller",
|
|
49
|
+
"RestController",
|
|
50
|
+
"Configuration",
|
|
51
|
+
"Bean",
|
|
52
|
+
"Aspect",
|
|
53
|
+
# Spring Data / persistence
|
|
54
|
+
"Query",
|
|
55
|
+
"Modifying",
|
|
56
|
+
# Guice DI
|
|
57
|
+
"Inject",
|
|
58
|
+
"Provides",
|
|
59
|
+
"Singleton",
|
|
60
|
+
"Named",
|
|
61
|
+
"Qualifier",
|
|
62
|
+
# Jakarta / javax DI
|
|
63
|
+
"ApplicationScoped",
|
|
64
|
+
"RequestScoped",
|
|
65
|
+
"SessionScoped",
|
|
66
|
+
"Dependent",
|
|
67
|
+
# Jackson / serialization
|
|
68
|
+
"JsonCreator",
|
|
69
|
+
"JsonProperty",
|
|
70
|
+
"JsonDeserialize",
|
|
71
|
+
"JsonSerialize",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Full set used in normal mode
|
|
75
|
+
EXEMPT_ANNOTATIONS = ENTRY_POINT_ANNOTATIONS | BROAD_ANNOTATIONS
|
|
76
|
+
|
|
77
|
+
EXEMPT_CONTRACT_METHODS = {
|
|
78
|
+
"toString",
|
|
79
|
+
"hashCode",
|
|
80
|
+
"equals",
|
|
81
|
+
"compareTo",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _modifier_tokens(modifiers) -> set[str]:
|
|
86
|
+
if not modifiers:
|
|
87
|
+
return set()
|
|
88
|
+
return {str(m).strip() for m in modifiers}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _matched_annotation(mods: set[str], annotation_set: set[str]) -> str | None:
|
|
92
|
+
"""Return the first annotation in *mods* that appears in *annotation_set*, or None."""
|
|
93
|
+
for m in mods:
|
|
94
|
+
bare = m.lstrip("@")
|
|
95
|
+
if bare in annotation_set:
|
|
96
|
+
return bare
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _assign_confidence(candidate: dict, strict: bool) -> str:
|
|
101
|
+
"""Assign a confidence level (high / medium / low) to each dead method.
|
|
102
|
+
|
|
103
|
+
Heuristic:
|
|
104
|
+
- high: private method with no callers — almost certainly dead.
|
|
105
|
+
- medium: package-private or protected method with no callers.
|
|
106
|
+
- low: public method — could be called via reflection / external JAR.
|
|
107
|
+
In strict mode, every method that passes the minimal exemptions is 'high'.
|
|
108
|
+
"""
|
|
109
|
+
if strict:
|
|
110
|
+
return "high"
|
|
111
|
+
mods = _modifier_tokens(candidate.get("modifiers"))
|
|
112
|
+
if "private" in mods:
|
|
113
|
+
return "high"
|
|
114
|
+
if "public" in mods:
|
|
115
|
+
return "low"
|
|
116
|
+
# Default: protected / package-private
|
|
117
|
+
return "medium"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_dead_code(store, limit: int = 200, project: str | None = None, strict: bool = False) -> list[dict] | None:
|
|
121
|
+
"""Java-aware dead code detection with exemption passes.
|
|
122
|
+
|
|
123
|
+
Parameters:
|
|
124
|
+
limit – Max results to return.
|
|
125
|
+
project – Scope to a single module.
|
|
126
|
+
strict – When True, only exempt main()/@Test methods and explicit
|
|
127
|
+
entry-point annotations (RequestMapping, KafkaListener, etc.).
|
|
128
|
+
Skips the broad bean-getter/setter, contract-method,
|
|
129
|
+
constructor, Override, and DI annotation exemptions.
|
|
130
|
+
|
|
131
|
+
Returns a list of dead method dicts, each with:
|
|
132
|
+
method_id, name, signature, class_fqcn, file_path, reason, confidence.
|
|
133
|
+
|
|
134
|
+
The return value is augmented with a ``_stats`` entry (a sentinel dict
|
|
135
|
+
with key ``_stats``) containing pre/post-exemption counts, a breakdown
|
|
136
|
+
of exemption reasons, and a sample of exempted methods so callers can
|
|
137
|
+
validate that the exemption logic is working correctly.
|
|
138
|
+
"""
|
|
139
|
+
if project:
|
|
140
|
+
candidates = store.query_records(
|
|
141
|
+
"""
|
|
142
|
+
MATCH (m:Method), (c:Class), (f:File)
|
|
143
|
+
WHERE m.class_id = c.id AND c.file_id = f.id AND f.project_id = $proj
|
|
144
|
+
AND NOT EXISTS { MATCH (:Method)-[:CALLS]->(m) }
|
|
145
|
+
RETURN m.id as method_id,
|
|
146
|
+
m.name as name,
|
|
147
|
+
m.signature as signature,
|
|
148
|
+
m.modifiers as modifiers,
|
|
149
|
+
c.fqcn as class_fqcn,
|
|
150
|
+
m.is_constructor as is_constructor,
|
|
151
|
+
m.is_test as is_test,
|
|
152
|
+
f.path as file_path
|
|
153
|
+
LIMIT $limit
|
|
154
|
+
""",
|
|
155
|
+
{"limit": int(limit * 5), "proj": project},
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
candidates = store.query_records(
|
|
159
|
+
"""
|
|
160
|
+
MATCH (m:Method), (c:Class), (f:File)
|
|
161
|
+
WHERE m.class_id = c.id AND c.file_id = f.id
|
|
162
|
+
AND NOT EXISTS { MATCH (:Method)-[:CALLS]->(m) }
|
|
163
|
+
RETURN m.id as method_id,
|
|
164
|
+
m.name as name,
|
|
165
|
+
m.signature as signature,
|
|
166
|
+
m.modifiers as modifiers,
|
|
167
|
+
c.fqcn as class_fqcn,
|
|
168
|
+
m.is_constructor as is_constructor,
|
|
169
|
+
m.is_test as is_test,
|
|
170
|
+
f.path as file_path
|
|
171
|
+
LIMIT $limit
|
|
172
|
+
""",
|
|
173
|
+
{"limit": int(limit * 5)},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if not candidates:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
n_candidates = len(candidates)
|
|
180
|
+
|
|
181
|
+
# Track exemptions as {method_id: reason} instead of a plain set
|
|
182
|
+
exempt: dict[str, str] = {}
|
|
183
|
+
|
|
184
|
+
# Choose annotation set based on mode
|
|
185
|
+
annotations_to_check = ENTRY_POINT_ANNOTATIONS if strict else EXEMPT_ANNOTATIONS
|
|
186
|
+
|
|
187
|
+
# ── Exemption passes ──────────────────────────────────────────────
|
|
188
|
+
for c in candidates:
|
|
189
|
+
mid = c["method_id"]
|
|
190
|
+
if mid in exempt:
|
|
191
|
+
continue
|
|
192
|
+
sig = (c.get("signature") or "").lower()
|
|
193
|
+
name = c.get("name") or ""
|
|
194
|
+
mods = _modifier_tokens(c.get("modifiers"))
|
|
195
|
+
|
|
196
|
+
# Always exempt test methods and main()
|
|
197
|
+
if c.get("is_test"):
|
|
198
|
+
exempt[mid] = "test_method"
|
|
199
|
+
continue
|
|
200
|
+
if name == "main" and "string[]" in sig:
|
|
201
|
+
exempt[mid] = "main_method"
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# Exempt methods with entry-point (strict) or all framework (normal) annotations
|
|
205
|
+
matched = _matched_annotation(mods, annotations_to_check)
|
|
206
|
+
if matched:
|
|
207
|
+
exempt[mid] = f"annotation:{matched}"
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
# ── Broad exemptions (only in normal mode) ────────────────────
|
|
211
|
+
if not strict:
|
|
212
|
+
if c.get("is_constructor"):
|
|
213
|
+
exempt[mid] = "constructor"
|
|
214
|
+
continue
|
|
215
|
+
if name in EXEMPT_CONTRACT_METHODS:
|
|
216
|
+
exempt[mid] = f"contract_method:{name}"
|
|
217
|
+
continue
|
|
218
|
+
# Java bean-ish APIs often rely on reflection/serialization.
|
|
219
|
+
if "public" in mods and (
|
|
220
|
+
name.startswith("get") or name.startswith("set") or name.startswith("is")
|
|
221
|
+
):
|
|
222
|
+
exempt[mid] = "bean_accessor"
|
|
223
|
+
continue
|
|
224
|
+
# Reflection-style hooks
|
|
225
|
+
if name in {"valueOf", "fromString", "builder"}:
|
|
226
|
+
exempt[mid] = f"reflection_hook:{name}"
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Exempt methods that DIRECTLY override another method.
|
|
230
|
+
# In strict mode, overrides are NOT exempted — if nobody calls the method,
|
|
231
|
+
# it's flagged regardless of whether it overrides a parent.
|
|
232
|
+
if not strict:
|
|
233
|
+
override_methods = store.query_records(
|
|
234
|
+
"""
|
|
235
|
+
MATCH (m:Method)-[:OVERRIDES]->(:Method)
|
|
236
|
+
RETURN DISTINCT m.id as method_id
|
|
237
|
+
"""
|
|
238
|
+
)
|
|
239
|
+
for r in override_methods:
|
|
240
|
+
mid = r["method_id"]
|
|
241
|
+
if mid not in exempt:
|
|
242
|
+
exempt[mid] = "method_override"
|
|
243
|
+
|
|
244
|
+
# ── Build dead list ───────────────────────────────────────────────
|
|
245
|
+
dead = []
|
|
246
|
+
for c in candidates:
|
|
247
|
+
if c["method_id"] in exempt:
|
|
248
|
+
continue
|
|
249
|
+
dead.append(
|
|
250
|
+
{
|
|
251
|
+
"method_id": c["method_id"],
|
|
252
|
+
"name": c.get("name"),
|
|
253
|
+
"signature": c.get("signature"),
|
|
254
|
+
"class_fqcn": c.get("class_fqcn"),
|
|
255
|
+
"file_path": c.get("file_path"),
|
|
256
|
+
"confidence": _assign_confidence(c, strict),
|
|
257
|
+
"reason": "no_incoming_calls_after_exemptions",
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
result = dead[:limit]
|
|
262
|
+
|
|
263
|
+
# ── Stats with exemption breakdown ────────────────────────────────
|
|
264
|
+
reason_counts: dict[str, int] = defaultdict(int)
|
|
265
|
+
for reason in exempt.values():
|
|
266
|
+
# Group annotation reasons by prefix for readability
|
|
267
|
+
key = reason.split(":")[0] if ":" in reason else reason
|
|
268
|
+
reason_counts[key] += 1
|
|
269
|
+
|
|
270
|
+
# Sample of exempted methods (up to 10) for user inspection
|
|
271
|
+
exempted_sample = []
|
|
272
|
+
for mid, reason in list(exempt.items())[:10]:
|
|
273
|
+
candidate = next((c for c in candidates if c["method_id"] == mid), None)
|
|
274
|
+
if candidate:
|
|
275
|
+
exempted_sample.append({
|
|
276
|
+
"name": candidate.get("name"),
|
|
277
|
+
"signature": candidate.get("signature"),
|
|
278
|
+
"class_fqcn": candidate.get("class_fqcn"),
|
|
279
|
+
"exemption_reason": reason,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
if strict:
|
|
283
|
+
exemption_note = (
|
|
284
|
+
"STRICT MODE: Only test methods, main(), and entry-point "
|
|
285
|
+
"annotations (RequestMapping, KafkaListener, Scheduled, etc.) "
|
|
286
|
+
"are exempted. Constructors, getters/setters, @Override, DI "
|
|
287
|
+
"annotations, and contract methods are NOT exempt."
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
exemption_note = (
|
|
291
|
+
"Exemptions cover: constructors, test methods, main(), "
|
|
292
|
+
"toString/hashCode/equals/compareTo, public getters/setters, "
|
|
293
|
+
"methods with DI/framework annotations, and direct method overrides. "
|
|
294
|
+
"Use strict=True for minimal exemptions."
|
|
295
|
+
)
|
|
296
|
+
result.append({
|
|
297
|
+
"_stats": {
|
|
298
|
+
"candidates_with_no_callers": n_candidates,
|
|
299
|
+
"exempted": len(exempt),
|
|
300
|
+
"dead_returned": len(result),
|
|
301
|
+
"mode": "strict" if strict else "normal",
|
|
302
|
+
"note": exemption_note,
|
|
303
|
+
"exemptions_breakdown": dict(reason_counts),
|
|
304
|
+
"exempted_sample": exempted_sample,
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
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] = []
|