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.
Files changed (52) hide show
  1. {codespine-0.4.2 → codespine-0.5.0}/PKG-INFO +2 -2
  2. {codespine-0.4.2 → codespine-0.5.0}/codespine/__init__.py +1 -1
  3. codespine-0.5.0/codespine/analysis/crossmodule.py +230 -0
  4. codespine-0.5.0/codespine/analysis/deadcode.py +248 -0
  5. {codespine-0.4.2 → codespine-0.5.0}/codespine/cli.py +11 -0
  6. {codespine-0.4.2 → codespine-0.5.0}/codespine/mcp/server.py +314 -19
  7. {codespine-0.4.2 → codespine-0.5.0}/codespine/search/hybrid.py +30 -0
  8. {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/PKG-INFO +2 -2
  9. {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/SOURCES.txt +1 -0
  10. {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/requires.txt +1 -1
  11. {codespine-0.4.2 → codespine-0.5.0}/pyproject.toml +2 -2
  12. codespine-0.4.2/codespine/analysis/deadcode.py +0 -171
  13. {codespine-0.4.2 → codespine-0.5.0}/LICENSE +0 -0
  14. {codespine-0.4.2 → codespine-0.5.0}/README.md +0 -0
  15. {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/__init__.py +0 -0
  16. {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/community.py +0 -0
  17. {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/context.py +0 -0
  18. {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/coupling.py +0 -0
  19. {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/flow.py +0 -0
  20. {codespine-0.4.2 → codespine-0.5.0}/codespine/analysis/impact.py +0 -0
  21. {codespine-0.4.2 → codespine-0.5.0}/codespine/config.py +0 -0
  22. {codespine-0.4.2 → codespine-0.5.0}/codespine/db/__init__.py +0 -0
  23. {codespine-0.4.2 → codespine-0.5.0}/codespine/db/schema.py +0 -0
  24. {codespine-0.4.2 → codespine-0.5.0}/codespine/db/store.py +0 -0
  25. {codespine-0.4.2 → codespine-0.5.0}/codespine/diff/__init__.py +0 -0
  26. {codespine-0.4.2 → codespine-0.5.0}/codespine/diff/branch_diff.py +0 -0
  27. {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/__init__.py +0 -0
  28. {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/call_resolver.py +0 -0
  29. {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/engine.py +0 -0
  30. {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/java_parser.py +0 -0
  31. {codespine-0.4.2 → codespine-0.5.0}/codespine/indexer/symbol_builder.py +0 -0
  32. {codespine-0.4.2 → codespine-0.5.0}/codespine/mcp/__init__.py +0 -0
  33. {codespine-0.4.2 → codespine-0.5.0}/codespine/noise/__init__.py +0 -0
  34. {codespine-0.4.2 → codespine-0.5.0}/codespine/noise/blocklist.py +0 -0
  35. {codespine-0.4.2 → codespine-0.5.0}/codespine/search/__init__.py +0 -0
  36. {codespine-0.4.2 → codespine-0.5.0}/codespine/search/bm25.py +0 -0
  37. {codespine-0.4.2 → codespine-0.5.0}/codespine/search/fuzzy.py +0 -0
  38. {codespine-0.4.2 → codespine-0.5.0}/codespine/search/rrf.py +0 -0
  39. {codespine-0.4.2 → codespine-0.5.0}/codespine/search/vector.py +0 -0
  40. {codespine-0.4.2 → codespine-0.5.0}/codespine/watch/__init__.py +0 -0
  41. {codespine-0.4.2 → codespine-0.5.0}/codespine/watch/watcher.py +0 -0
  42. {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/dependency_links.txt +0 -0
  43. {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/entry_points.txt +0 -0
  44. {codespine-0.4.2 → codespine-0.5.0}/codespine.egg-info/top_level.txt +0 -0
  45. {codespine-0.4.2 → codespine-0.5.0}/gindex.py +0 -0
  46. {codespine-0.4.2 → codespine-0.5.0}/setup.cfg +0 -0
  47. {codespine-0.4.2 → codespine-0.5.0}/tests/test_branch_diff_normalize.py +0 -0
  48. {codespine-0.4.2 → codespine-0.5.0}/tests/test_call_resolver.py +0 -0
  49. {codespine-0.4.2 → codespine-0.5.0}/tests/test_index_and_hybrid.py +0 -0
  50. {codespine-0.4.2 → codespine-0.5.0}/tests/test_java_parser.py +0 -0
  51. {codespine-0.4.2 → codespine-0.5.0}/tests/test_multimodule_index.py +0 -0
  52. {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.4.2
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
@@ -1,4 +1,4 @@
1
1
  """CodeSpine package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.4.2"
4
+ __version__ = "0.5.0"
@@ -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] = []