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