sourcecode 1.30.13__py3-none-any.whl → 1.30.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.30.13"
3
+ __version__ = "1.30.14"
@@ -381,27 +381,27 @@ def _impact_descriptions(
381
381
 
382
382
  if changed_type in _REPO_ARTIFACT_TYPES:
383
383
  items.append(_impact_item(
384
- f"{domain} persistence may be affected (inferred from path)" if domain else "persistence may be affected (inferred from path)",
385
- f"{changed_class} classified as repository from path",
384
+ f"{domain} persistence layer modified" if domain else "persistence layer modified",
385
+ f"{changed_class} classified as {changed_type} (artifact_type evidence)",
386
386
  certainty,
387
387
  ))
388
388
  elif changed_type in _SERVICE_ARTIFACT_TYPES:
389
389
  if end_state == "DB write":
390
390
  items.append(_impact_item(
391
- f"{domain} persistence may be affected (repository with DB write in path)" if domain else "persistence may be affected (repository with DB write in path)",
392
- f"{changed_class} delegates to repository with DB write",
391
+ f"{domain} service layer: DB write in call path" if domain else "service layer: DB write in call path",
392
+ f"{changed_class} delegates to repository with DB write (code evidence)",
393
393
  certainty,
394
394
  ))
395
395
  else:
396
396
  items.append(_impact_item(
397
- f"{domain} behavior may change" if domain else "behavior may change",
398
- f"{changed_class} is a service in path",
397
+ f"{domain} service layer modified" if domain else "service layer modified",
398
+ f"{changed_class} is a service (artifact_type={changed_type})",
399
399
  certainty,
400
400
  ))
401
401
  else:
402
402
  items.append(_impact_item(
403
- f"{domain} behavior may change" if domain else "behavior may change",
404
- f"{changed_class} is in path",
403
+ f"{domain} component modified" if domain else "component modified",
404
+ f"{changed_class} found in traced call path (artifact_type={changed_type})",
405
405
  certainty,
406
406
  ))
407
407
 
@@ -440,14 +440,14 @@ def _impact_descriptions_for_controller(
440
440
  domain = d
441
441
  break
442
442
  items.append(_impact_item(
443
- f"{domain} persistence may be affected (repository with DB write in path)" if domain else "data persistence may be affected (repository with DB write in path)",
444
- "repository with DB write detected in path",
443
+ f"{domain} persistence layer: repository with DB write in call path" if domain else "persistence layer: repository with DB write in call path",
444
+ "repository with DB write detected in traced call path (code evidence)",
445
445
  certainty,
446
446
  ))
447
447
  else:
448
448
  items.append(_impact_item(
449
- "request handler behavior may change",
450
449
  "controller entry point modified",
450
+ "controller entry point is in changed files (direct diff evidence)",
451
451
  certainty,
452
452
  ))
453
453
 
@@ -539,23 +539,23 @@ def _read_code_signal_evidence(root: Path, file_path: str, artifact_type: str) -
539
539
 
540
540
 
541
541
  _ARTIFACT_CHANGE_EFFECT: dict[str, str] = {
542
- "entrypoint": "may affect application startup, CLI entry, or framework bootstrap all request flows may be affected (inferred from role)",
543
- "controller": "may alter HTTP routing, API contract, or response shape — API consumers may be affected (inferred from role)",
544
- "service": "may change business rules, transaction scope, or orchestration logic callers and dependents may be affected (inferred from role)",
545
- "repository": "may modify persistence queries or data access patterns — verify data consistency and service layer (inferred from role)",
546
- "mapper": "may alter SQL-to-object binding or query templates data shape and repositories may be affected (inferred from role)",
547
- "security": "may change authentication flow, access control rules, or session handling — all secured endpoints may be affected (inferred from role)",
548
- "spring_config": "may modify bean wiring, datasource, or framework-wide settings — wired beans potentially affected (inferred from role)",
549
- "spring_profile": "may change environment-specific overrides — behavior may differ per active profile (inferred from role)",
550
- "config": "may adjust configuration values all modules reading this config may be affected (inferred from role)",
551
- "build_manifest": "changes dependencies, plugins, or project structure — compile-time and runtime classpath affected",
552
- "db_migration": "modifies database schema existing queries, mappings, and constraints may break",
553
- "domain_model": "may alter entity structure may cascade to repositories, DTOs, serializers, and mappers (inferred from role)",
554
- "dto": "may change data transfer contract serialization and API consumers may break (inferred from role)",
555
- "test": "modifies test coverage or test behavior — no production code affected",
556
- "documentation": "updates documentation only no runtime impact",
557
- "ide_noise": "IDE/tooling artifact no application impact",
558
- "source": "modifies application source artifact role derived from file path structure",
542
+ "entrypoint": "application entrypoint (framework bootstrap / CLI handler)",
543
+ "controller": "HTTP routing layer (request-to-handler mapping)",
544
+ "service": "business logic layer (@Service component)",
545
+ "repository": "data access layer (persistence queries / ORM)",
546
+ "mapper": "SQL-object mapping layer (MyBatis mapper / query template)",
547
+ "security": "security component (authentication / access control configuration)",
548
+ "spring_config": "Spring @Configuration class (bean definitions / datasource wiring)",
549
+ "spring_profile": "Spring profile override (environment-specific configuration)",
550
+ "config": "configuration file (application properties / environment values)",
551
+ "build_manifest": "build manifest (dependency and plugin configuration)",
552
+ "db_migration": "database schema migration (DDL change pending execution)",
553
+ "domain_model": "domain entity (@Entity / value object)",
554
+ "dto": "data transfer object (serialization contract)",
555
+ "test": "test file (no production code modified)",
556
+ "documentation": "documentation file (no runtime impact)",
557
+ "ide_noise": "IDE/tooling artifact (no application impact)",
558
+ "source": "application source (artifact role requires annotation inspection)",
559
559
  }
560
560
 
561
561
  # Maps frontend symptom keywords → backend terms likely to contain the root cause.
@@ -1037,7 +1037,7 @@ class TaskContextBuilder:
1037
1037
  _pr_review_hotspots = sorted(
1038
1038
  [f for f, cls in _pr_changed_cls.items()
1039
1039
  if not cls["is_noise"] and cls["artifact_type"] != "build_manifest"],
1040
- key=lambda f: (_delta_impact_score_per_file.get(f) or {}).get("score", 0.0),
1040
+ key=lambda f: (_delta_impact_score_per_file.get(f) or {}).get("_rank_score", 0.0),
1041
1041
  reverse=True,
1042
1042
  )[:8]
1043
1043
 
@@ -1123,33 +1123,29 @@ class TaskContextBuilder:
1123
1123
  _has_code_ev = any(e["type"] in ("annotation", "import") for e in _evidence)
1124
1124
  _has_symbol_ev = any(e["type"] == "symbol" for e in _evidence)
1125
1125
  _role_basis = "annotation" if _has_code_ev else ("symbol" if _has_symbol_ev else "git_diff_only")
1126
- _cls_conf = _f_cls["confidence"]
1127
1126
  _role_obj = {
1128
- "type": "inferred",
1129
- "confidence": "medium" if (_has_code_ev or (_has_symbol_ev and _cls_conf == "high")) else "low",
1130
1127
  "basis": _role_basis,
1128
+ "has_annotation_signal": _has_code_ev,
1129
+ "has_symbol_signal": _has_symbol_ev,
1131
1130
  }
1132
1131
 
1133
- # File confidence: from structured impact entry
1134
- _impact_entry = _delta_impact_score_per_file.get(_f) or {}
1135
- _f_conf = _impact_entry.get("confidence", "low")
1136
1132
  _diff_source = (
1137
1133
  DiffSourceType.GIT_RANGE.value if _f in _committed_set
1138
1134
  else DiffSourceType.WORKTREE_UNSTAGED.value if _f in _uncommitted_set
1139
1135
  else "unknown"
1140
1136
  )
1137
+ _impact_entry = _delta_impact_score_per_file.get(_f) or {}
1141
1138
  _entry = {
1142
1139
  "path": _f,
1143
1140
  "diff_source": _diff_source,
1144
1141
  "role": _role_obj,
1145
- "confidence": _f_conf,
1146
1142
  "artifact_type": _f_atype,
1147
1143
  "evidence": _evidence,
1148
1144
  "change_effect": {
1149
- "statement": _ARTIFACT_CHANGE_EFFECT.get(_f_atype, "may modify application logic (role inferred from path)"),
1150
- "truth_level": "inferred",
1151
- "confidence": _role_obj["confidence"],
1145
+ "statement": _ARTIFACT_CHANGE_EFFECT.get(_f_atype, "application source (artifact role requires annotation inspection)"),
1146
+ "classification_method": _f_cls.get("confidence", "low"),
1152
1147
  },
1148
+ "evidence_completeness": _impact_entry.get("evidence", {}),
1153
1149
  }
1154
1150
  _pr_runtime_changes.append(_entry)
1155
1151
  # Split committed vs uncommitted — never merged
@@ -2336,17 +2332,10 @@ class TaskContextBuilder:
2336
2332
  return "service" # annotation-confirmed — not "core_service" (overclaim)
2337
2333
  return "unclassified" # no role claim without evidence
2338
2334
 
2339
- def _score_to_confidence(score: float) -> str:
2340
- if score >= 0.60:
2341
- return "high"
2342
- if score >= 0.40:
2343
- return "medium"
2344
- return "low"
2345
-
2346
2335
  def _structured_why(path: str, atype: str, module: str, role: str, risk_areas: list[str], cls_confidence: str = "low") -> str:
2347
2336
  area = _classify_impact_area(path, risk_areas, atype)
2348
2337
  prop = _PROPAGATION_RISK.get(atype, "low") if atype != "source" else "unknown"
2349
- effect = _CHANGE_EFFECT.get(atype, "may modify application logic (role unconfirmed)")
2338
+ effect = _CHANGE_EFFECT.get(atype, "application source (artifact role requires annotation inspection)")
2350
2339
  parts = [f"artifact_type: {atype}", f"classification_confidence: {cls_confidence}"]
2351
2340
  # Only emit role/area/propagation when there is confirmed evidence (not source/unclassified)
2352
2341
  if role not in ("unclassified",):
@@ -2431,8 +2420,7 @@ class TaskContextBuilder:
2431
2420
  role = _role_in_system(path, atype, in_ep)
2432
2421
  cls_conf = cls.get("confidence", "low")
2433
2422
  why_str = _structured_why(path, atype, module, role, cls["risk_areas"], cls_conf)
2434
- confidence = _score_to_confidence(score)
2435
- reason = f"changed since {ref_label} | artifact: {atype} | confidence: {confidence}"
2423
+ reason = f"changed since {ref_label} | artifact: {atype}"
2436
2424
 
2437
2425
  relevant.append(RelevantFile(path=path, role=role, score=round(score, 2), reason=reason, why=why_str))
2438
2426
  why[path] = why_str
@@ -2485,7 +2473,7 @@ class TaskContextBuilder:
2485
2473
  why_str = _structured_why(path, rel_atype, _extract_ddd_domain(path), role, rel_cls["risk_areas"], rel_conf)
2486
2474
  why_str += f" | pulled_by: type-aware expansion from {ctx_type} '{ctx_val}'"
2487
2475
  why_str += f" | triggered_by: {', '.join(triggers[:3])}"
2488
- reason = f"expansion: {ctx_type} '{ctx_val}' | artifact: {rel_atype} | confidence: {_score_to_confidence(rel_score)}"
2476
+ reason = f"expansion: {ctx_type} '{ctx_val}' | artifact: {rel_atype}"
2489
2477
  related.append((rel_score, path, RelevantFile(
2490
2478
  path=path, role=role, score=rel_score, reason=reason, why=why_str
2491
2479
  )))
@@ -2595,7 +2583,7 @@ class TaskContextBuilder:
2595
2583
  _why_str += f" | pulled_by: hop-{_hop_num} import from {Path(_seed_path).name}"
2596
2584
  _reason = (
2597
2585
  f"hop-{_hop_num} import-dependent of {Path(_seed_path).name}"
2598
- f" ({_seed_atype}) | confidence: {_score_to_confidence(_dep_score)}"
2586
+ f" ({_seed_atype})"
2599
2587
  )
2600
2588
  why[_dep_path] = _why_str
2601
2589
  # Tests import production code but are not structural dependencies —
@@ -2670,18 +2658,18 @@ class TaskContextBuilder:
2670
2658
  0.02
2671
2659
  )
2672
2660
  _raw_score = round(min(1.0, _base * 0.50 + _sec_w + _fw + _fanout + _ctw), 3)
2661
+ _caller_count = _downstream_count.get(_path, 0)
2673
2662
  impact_score_per_file[_path] = {
2674
- "score": _raw_score,
2675
- "confidence": _score_to_confidence(_raw_score),
2676
- "score_breakdown": {
2677
- "artifact_base": round(_base * 0.50, 3),
2678
- "security_weight": _sec_w,
2679
- "framework_weight": _fw,
2680
- "fanout_weight": round(_fanout, 3),
2681
- "change_type_weight": _ctw,
2682
- },
2663
+ "_rank_score": _raw_score, # internal ranking only — not a confidence claim
2683
2664
  "change_types": sorted(_file_ctypes),
2684
2665
  "diff_severity": _diff_sev,
2666
+ "evidence": {
2667
+ "has_reverse_edges": _caller_count > 0,
2668
+ "reverse_edge_count": _caller_count,
2669
+ "has_route_diff": _diff_sev == "api_change",
2670
+ "has_security_diff": _diff_sev == "security_change",
2671
+ "has_wiring_evidence": _atype in {"spring_config", "security"} and _cls.get("confidence") == "high",
2672
+ },
2685
2673
  }
2686
2674
 
2687
2675
  _CT_ORDER = ["security_change", "behavioral_change", "structural_change",
@@ -2714,24 +2702,29 @@ class TaskContextBuilder:
2714
2702
  changed_subsystems.append(_subsys)
2715
2703
  _seen_subsys.add(_subsys)
2716
2704
 
2717
- # behavioral_changes: gated on semantic diff evidence (api_change/security_change),
2718
- # NOT on artifact type alone. Avoids false behavioral claims for validator/util files.
2719
- behavioral_changes: list[str] = [
2720
- f"{Path(_p).name}: {_CHANGE_EFFECT.get(_cls['artifact_type'], 'may modify application logic')}"
2721
- for _p, _cls in classifications.items()
2722
- if not _cls["is_noise"]
2723
- and diff_severities.get(_p, "unknown") in ("api_change", "security_change")
2724
- ]
2705
+ # behavioral_changes: only emitted when graph evidence exists (import edges found).
2706
+ # Without reverse dependency graph, we cannot make traceable structural claims.
2707
+ # Also gated on semantic diff evidence (api_change/security_change) per file.
2708
+ _has_graph_ev = bool(graph_edges)
2709
+ behavioral_changes: list[str] = (
2710
+ [
2711
+ f"{Path(_p).name}: {_CHANGE_EFFECT.get(_cls['artifact_type'], 'application source (artifact role requires annotation inspection)')} [diff_severity={diff_severities.get(_p)}]"
2712
+ for _p, _cls in classifications.items()
2713
+ if not _cls["is_noise"]
2714
+ and diff_severities.get(_p, "unknown") in ("api_change", "security_change")
2715
+ ]
2716
+ if _has_graph_ev else []
2717
+ )
2725
2718
 
2726
2719
  def _runtime_impact(tc: dict[str, int]) -> list[str]:
2727
2720
  _ri: list[str] = []
2728
2721
  # Only emit claims that are evidence-backed (annotation-confirmed roles)
2729
2722
  if "entrypoint" in tc:
2730
- _ri.append("Entrypoint-classified file modified — may require full context restart before deploy")
2723
+ _ri.append("Entrypoint-classified file modified — restart required before deploying to production")
2731
2724
  if "spring_config" in tc:
2732
- _ri.append("Spring config-classified file modified — wired beans may be rewired on restart")
2725
+ _ri.append("Spring @Configuration-classified file modified — bean context rebuild required on restart")
2733
2726
  if "security" in tc:
2734
- _ri.append("Security-classified file modified — secured endpoints may be affected after restart")
2727
+ _ri.append("Security-classified file modified — inspect authentication and access control wiring")
2735
2728
  if "db_migration" in tc:
2736
2729
  _ri.append("Database schema migration pending — execute before deploying application")
2737
2730
  # service transactional claim: only if @Service annotation confirmed (not source/unclassified)
@@ -28,12 +28,21 @@ from typing import Optional
28
28
  @dataclass
29
29
  class SymbolRecord:
30
30
  symbol: str # fully qualified: pkg.Class | pkg.Class#method | pkg.Class.field
31
- type: str # class | interface | method | field
31
+ type: str # class | interface | method | field (backward-compat values)
32
32
  modifiers: list[str] = field(default_factory=list)
33
33
  annotations: list[str] = field(default_factory=list)
34
34
  imports_used: list[str] = field(default_factory=list)
35
35
  declaring_file: str = ""
36
36
  confidence: str = "medium" # high | medium | low
37
+ # Stable identity contract — populated by _extract_symbols
38
+ stable_id: str = "" # deterministic across formatting/body changes
39
+ symbol_kind: str = "" # class|interface|enum|annotation|method|constructor|field|endpoint|bean
40
+ canonical_name: str = "" # pkg.Class#method(Type1,Type2) — human-readable
41
+ source_file: str = "" # alias for declaring_file (IR output contract)
42
+ signature: str = "" # (Type1,Type2)->ReturnType for methods; type for fields
43
+ param_types: list[str] = field(default_factory=list)
44
+ return_type: str = ""
45
+ annotation_values: dict[str, str] = field(default_factory=dict) # ann_name → raw args string
37
46
 
38
47
 
39
48
  @dataclass
@@ -95,6 +104,7 @@ class EvidenceBundle:
95
104
  _PKG_RE = re.compile(r'^package\s+([\w.]+)\s*;', re.MULTILINE)
96
105
  _IMPORT_RE = re.compile(r'^import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;', re.MULTILINE)
97
106
  _ANN_RE = re.compile(r'^(@[\w.]+)')
107
+ _ANN_WITH_ARGS_RE = re.compile(r'^(@[\w.]+)\s*(?:\(([^)]*)\))?')
98
108
 
99
109
  _CLASS_DECL_RE = re.compile(
100
110
  r'(?:^|(?<=\s))'
@@ -111,10 +121,16 @@ _METHOD_DECL_RE = re.compile(
111
121
  r'^(?P<modifiers>(?:(?:public|private|protected|static|final|synchronized'
112
122
  r'|abstract|default|native|strictfp|override)\s+)*)'
113
123
  r'(?:<[\w,\s?]+>\s+)?'
114
- r'(?:(?:void|boolean|byte|char|short|int|long|float|double|String|[\w.<>\[\]?,]+)\s+)'
124
+ r'(?P<return_type>(?:void|boolean|byte|char|short|int|long|float|double|String|[\w.<>\[\]?,]+)\s+)'
115
125
  r'(?P<name>[a-z_]\w*)\s*\(',
116
126
  )
117
127
 
128
+ _CONSTRUCTOR_DECL_RE = re.compile(
129
+ r'^(?P<modifiers>(?:(?:public|private|protected)\s+)*)'
130
+ r'(?P<name>[A-Z]\w*)\s*\('
131
+ r'(?P<params>[^)]*)',
132
+ )
133
+
118
134
  _FIELD_DECL_RE = re.compile(
119
135
  r'^(?P<modifiers>(?:(?:private|protected|public|static|final|volatile|transient)\s+)*)'
120
136
  r'(?P<type>[\w<>.,\[\]? ]+?)\s+'
@@ -125,6 +141,11 @@ _REQUEST_MAPPING_RE = re.compile(
125
141
  r'@(?:Request|Get|Post|Put|Delete|Patch)Mapping\s*\(\s*(?:value\s*=\s*)?["\']([^"\']+)["\']'
126
142
  )
127
143
 
144
+ _ENDPOINT_ANNOTATIONS: frozenset[str] = frozenset({
145
+ "@GetMapping", "@PostMapping", "@PutMapping", "@DeleteMapping",
146
+ "@PatchMapping", "@RequestMapping",
147
+ })
148
+
128
149
  _MODIFIER_WORDS: frozenset[str] = frozenset({
129
150
  "public", "private", "protected", "static", "final", "abstract",
130
151
  "synchronized", "native", "strictfp", "transient", "volatile", "default",
@@ -159,6 +180,14 @@ _SPRING_OTHER: frozenset[str] = frozenset({
159
180
  "@SpringBootApplication", "@EnableAutoConfiguration",
160
181
  })
161
182
 
183
+ _HTTP_METHOD_MAP: dict[str, str] = {
184
+ "@GetMapping": "GET",
185
+ "@PostMapping": "POST",
186
+ "@PutMapping": "PUT",
187
+ "@DeleteMapping": "DELETE",
188
+ "@PatchMapping": "PATCH",
189
+ }
190
+
162
191
  # ---------------------------------------------------------------------------
163
192
  # Phase 5 constants
164
193
  # ---------------------------------------------------------------------------
@@ -174,6 +203,7 @@ _IR_WEIGHT_DEFAULT: float = 0.3
174
203
  # diff_intensity: method change=1.0, field/annotation=0.6, formatting=0.1
175
204
  _DIFF_INTENSITY_MAP: dict[str, float] = {
176
205
  "signature_change": 1.0,
206
+ "route_surface_change": 1.0,
177
207
  "structural_change": 0.6,
178
208
  "annotation_change": 0.6,
179
209
  "unknown": 0.1,
@@ -182,6 +212,96 @@ _DIFF_INTENSITY_MAP: dict[str, float] = {
182
212
  _PROPAGATION_DECAY: float = 0.5
183
213
  _BFS_MAX_DEPTH: int = 3
184
214
 
215
+ # Regex to strip leading annotations from a single parameter (e.g. @NotNull @Valid String name)
216
+ _ANN_PREFIX_RE = re.compile(r'^(?:@\w+\s*(?:\([^)]*\))?\s*)+')
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Stable ID helpers
221
+ # ---------------------------------------------------------------------------
222
+
223
+ def _normalize_type_name(raw: str) -> str:
224
+ """Strip annotations, final modifier, and param name; return only type.
225
+
226
+ "(Long id)" -> strip after parsing → "Long"
227
+ "@NotNull User user" → "User"
228
+ "List<String>" → "List<String>"
229
+ """
230
+ raw = _ANN_PREFIX_RE.sub("", raw).strip()
231
+ raw = re.sub(r'\bfinal\s+', "", raw).strip()
232
+ # "Type name" → extract Type (rightmost word is the param name)
233
+ m = re.match(r'^([\w<>\[\].,? ]+?)\s+\w+$', raw)
234
+ if m:
235
+ return m.group(1).strip()
236
+ return raw.strip()
237
+
238
+
239
+ def _parse_param_types(params_str: str) -> list[str]:
240
+ """Parse "(Long id, @Valid String name)" → ["Long", "String"].
241
+
242
+ Handles simple param lists only (no nested generic commas).
243
+ For multi-line param lists callers receive an empty string → returns [].
244
+ """
245
+ if not params_str or not params_str.strip():
246
+ return []
247
+ result: list[str] = []
248
+ depth = 0
249
+ current: list[str] = []
250
+ for ch in params_str:
251
+ if ch in ("<", "("):
252
+ depth += 1
253
+ current.append(ch)
254
+ elif ch in (">", ")"):
255
+ depth -= 1
256
+ current.append(ch)
257
+ elif ch == "," and depth == 0:
258
+ part = "".join(current).strip()
259
+ if part:
260
+ t = _normalize_type_name(part)
261
+ if t:
262
+ result.append(t)
263
+ current = []
264
+ else:
265
+ current.append(ch)
266
+ part = "".join(current).strip()
267
+ if part:
268
+ t = _normalize_type_name(part)
269
+ if t:
270
+ result.append(t)
271
+ return result
272
+
273
+
274
+ def _normalize_return_type(raw: str) -> str:
275
+ """Normalize return type string: strip whitespace, keep generics."""
276
+ return raw.strip()
277
+
278
+
279
+ def _compute_stable_id(
280
+ package: str,
281
+ class_simple: str,
282
+ kind: str,
283
+ symbol_name: str,
284
+ param_types: Optional[list[str]] = None,
285
+ return_type: str = "",
286
+ ) -> str:
287
+ """Compute deterministic stable symbol identity.
288
+
289
+ Format: {package}:{class_simple}:{kind}:{symbol_name}[:{params}[:{return_type}]]
290
+
291
+ Survives: formatting, comments, body changes, imports, nearby movement.
292
+ Changes on: rename, param type change, class package move, kind change.
293
+
294
+ Never uses line numbers, byte offsets, or content hashes.
295
+ """
296
+ pkg = package or "_"
297
+ cls = class_simple or "_"
298
+ parts = [pkg, cls, kind, symbol_name]
299
+ if param_types is not None:
300
+ parts.append(f"({','.join(param_types)})")
301
+ if return_type:
302
+ parts.append(_normalize_return_type(return_type))
303
+ return ":".join(parts)
304
+
185
305
 
186
306
  # ---------------------------------------------------------------------------
187
307
  # Helpers — Phases 1–4
@@ -262,6 +382,7 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
262
382
  depth = 0
263
383
  class_stack: list[tuple[str, int]] = []
264
384
  pending_anns: list[str] = []
385
+ pending_ann_values: dict[str, str] = {}
265
386
  in_block_comment = False
266
387
 
267
388
  for line in source.splitlines():
@@ -281,11 +402,14 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
281
402
  net = _count_net_braces(stripped)
282
403
 
283
404
  if stripped.startswith("@"):
284
- ann_m = _ANN_RE.match(stripped)
405
+ ann_m = _ANN_WITH_ARGS_RE.match(stripped)
285
406
  if ann_m:
286
407
  ann = ann_m.group(1)
408
+ ann_args = ann_m.group(2) or ""
287
409
  if ann not in pending_anns:
288
410
  pending_anns.append(ann)
411
+ if ann_args and ann in _ENDPOINT_ANNOTATIONS:
412
+ pending_ann_values[ann] = ann_args.strip()
289
413
  depth += net
290
414
  _pop_closed(class_stack, depth)
291
415
  continue
@@ -312,6 +436,23 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
312
436
 
313
437
  sym_type = "interface" if kind_kw == "interface" else "class"
314
438
 
439
+ # symbol_kind distinguishes enum/annotation from class/interface
440
+ if kind_kw == "enum":
441
+ sym_kind = "enum"
442
+ elif kind_kw == "@interface":
443
+ sym_kind = "annotation"
444
+ elif kind_kw == "interface":
445
+ sym_kind = "interface"
446
+ else:
447
+ sym_kind = "class"
448
+
449
+ _stable_id = _compute_stable_id(package, name, sym_kind, name)
450
+ _sig_parts = [kind_kw, name]
451
+ if extends_str:
452
+ _sig_parts.append(f"extends {extends_str}")
453
+ if implements_str:
454
+ _sig_parts.append(f"implements {implements_str}")
455
+
315
456
  symbols.append(SymbolRecord(
316
457
  symbol=fqn,
317
458
  type=sym_type,
@@ -320,49 +461,129 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
320
461
  imports_used=used,
321
462
  declaring_file=rel_path,
322
463
  confidence="high",
464
+ stable_id=_stable_id,
465
+ symbol_kind=sym_kind,
466
+ canonical_name=fqn,
467
+ source_file=rel_path,
468
+ signature=" ".join(_sig_parts),
469
+ annotation_values=dict(pending_ann_values),
323
470
  ))
324
471
 
325
472
  class_stack.append((fqn, depth))
326
473
  pending_anns = []
474
+ pending_ann_values = {}
327
475
  depth += net
328
476
  _pop_closed(class_stack, depth)
329
477
  continue
330
478
 
331
479
  if class_stack:
480
+ class_fqn = class_stack[-1][0]
481
+ # simple name of enclosing class (last segment, strip inner class paths)
482
+ _class_simple = class_fqn.split(".")[-1]
483
+
332
484
  mth_m = _METHOD_DECL_RE.match(stripped)
333
485
  if mth_m:
334
486
  mname = mth_m.group("name")
335
487
  if mname not in _JAVA_KEYWORDS:
336
- class_fqn = class_stack[-1][0]
337
488
  fqn = f"{class_fqn}#{mname}"
338
489
  modifiers = _parse_modifier_str(mth_m.group("modifiers") or "")
339
490
  used = _resolve_types_from_text(stripped, import_map)
340
491
  conf = "high" if ("public" in modifiers or pending_anns) else "medium"
341
492
 
493
+ # Extract return type and params from matched line
494
+ _ret_raw = (mth_m.group("return_type") or "").strip()
495
+ _after_paren = stripped[mth_m.end():]
496
+ if ")" in _after_paren:
497
+ _params_str = _after_paren[:_after_paren.index(")")]
498
+ _param_types = _parse_param_types(_params_str)
499
+ else:
500
+ _param_types = [] # multi-line param list — deterministically empty
501
+
502
+ # Determine symbol_kind from annotations
503
+ _anns = sorted(set(pending_anns))
504
+ if "@Bean" in _anns:
505
+ _sym_kind = "bean"
506
+ elif _anns and any(a in _ENDPOINT_ANNOTATIONS for a in _anns):
507
+ _sym_kind = "endpoint"
508
+ else:
509
+ _sym_kind = "method"
510
+
511
+ _stable_id = _compute_stable_id(
512
+ package, _class_simple, _sym_kind, mname, _param_types, _ret_raw
513
+ )
514
+ _param_str = ",".join(_param_types)
515
+ _canonical = f"{class_fqn}#{mname}({_param_str})"
516
+ _signature = f"({_param_str})->{_ret_raw}"
517
+
342
518
  symbols.append(SymbolRecord(
343
519
  symbol=fqn,
344
520
  type="method",
345
521
  modifiers=modifiers,
346
- annotations=sorted(set(pending_anns)),
522
+ annotations=_anns,
347
523
  imports_used=used,
348
524
  declaring_file=rel_path,
349
525
  confidence=conf,
526
+ stable_id=_stable_id,
527
+ symbol_kind=_sym_kind,
528
+ canonical_name=_canonical,
529
+ source_file=rel_path,
530
+ signature=_signature,
531
+ param_types=_param_types,
532
+ return_type=_ret_raw,
533
+ annotation_values=dict(pending_ann_values),
350
534
  ))
351
535
  pending_anns = []
536
+ pending_ann_values = {}
352
537
  depth += net
353
538
  _pop_closed(class_stack, depth)
354
539
  continue
355
540
 
541
+ # Constructor detection: uppercase name matching enclosing class
542
+ ctor_m = _CONSTRUCTOR_DECL_RE.match(stripped)
543
+ if ctor_m and ctor_m.group("name") == _class_simple:
544
+ _ctor_params_str = ctor_m.group("params")
545
+ _ctor_param_types = _parse_param_types(_ctor_params_str)
546
+ _ctor_anns = sorted(set(pending_anns))
547
+ _ctor_modifiers = _parse_modifier_str(ctor_m.group("modifiers") or "")
548
+ _ctor_fqn = f"{class_fqn}#<init>"
549
+ _stable_id = _compute_stable_id(
550
+ package, _class_simple, "constructor", _class_simple, _ctor_param_types
551
+ )
552
+ _param_str = ",".join(_ctor_param_types)
553
+ symbols.append(SymbolRecord(
554
+ symbol=_ctor_fqn,
555
+ type="method",
556
+ modifiers=_ctor_modifiers,
557
+ annotations=_ctor_anns,
558
+ imports_used=[],
559
+ declaring_file=rel_path,
560
+ confidence="high" if ("public" in _ctor_modifiers or _ctor_anns) else "medium",
561
+ stable_id=_stable_id,
562
+ symbol_kind="constructor",
563
+ canonical_name=f"{class_fqn}#{_class_simple}({_param_str})",
564
+ source_file=rel_path,
565
+ signature=f"({_param_str})->void",
566
+ param_types=_ctor_param_types,
567
+ return_type="void",
568
+ ))
569
+ pending_anns = []
570
+ pending_ann_values = {}
571
+ depth += net
572
+ _pop_closed(class_stack, depth)
573
+ continue
574
+
356
575
  if pending_anns and any(a in _INJECT_ANNOTATIONS for a in pending_anns):
357
576
  fld_m = _FIELD_DECL_RE.match(stripped)
358
577
  if fld_m:
359
578
  fname = fld_m.group("name")
360
579
  ftype = fld_m.group("type").strip()
361
580
  if fname and ftype and fname not in _JAVA_KEYWORDS:
362
- class_fqn = class_stack[-1][0]
363
581
  fqn = f"{class_fqn}.{fname}"
364
582
  modifiers = _parse_modifier_str(fld_m.group("modifiers") or "")
365
583
  used = _resolve_types_from_text(ftype, import_map)
584
+ _stable_id = _compute_stable_id(
585
+ package, _class_simple, "field", fname, None, ftype
586
+ )
366
587
 
367
588
  symbols.append(SymbolRecord(
368
589
  symbol=fqn,
@@ -372,13 +593,20 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
372
593
  imports_used=used,
373
594
  declaring_file=rel_path,
374
595
  confidence="high",
596
+ stable_id=_stable_id,
597
+ symbol_kind="field",
598
+ canonical_name=fqn,
599
+ source_file=rel_path,
600
+ signature=f"{ftype} {fname}",
375
601
  ))
376
602
  pending_anns = []
603
+ pending_ann_values = {}
377
604
  depth += net
378
605
  _pop_closed(class_stack, depth)
379
606
  continue
380
607
 
381
608
  pending_anns = []
609
+ pending_ann_values = {}
382
610
  depth += net
383
611
  _pop_closed(class_stack, depth)
384
612
 
@@ -545,6 +773,20 @@ def _build_relations(
545
773
  evidence={"type": "annotation", "value": f"@RequestMapping(\"{m_path}\")"},
546
774
  ))
547
775
 
776
+ # contained_in edges: method/field → enclosing class (structural membership)
777
+ _local_classes = {s.symbol for s in symbols if s.type in ("class", "interface")}
778
+ for sym in symbols:
779
+ if sym.type in ("method", "field"):
780
+ enclosing = _enclosing_class(sym.symbol)
781
+ if enclosing != sym.symbol and enclosing in _local_classes:
782
+ edges.append(RelationEdge(
783
+ from_symbol=sym.symbol,
784
+ to_symbol=enclosing,
785
+ type="contained_in",
786
+ confidence="high",
787
+ evidence={"type": "structural", "value": f"member of {enclosing}"},
788
+ ))
789
+
548
790
  seen: set[tuple[str, str, str]] = set()
549
791
  unique: list[RelationEdge] = []
550
792
  for e in edges:
@@ -560,8 +802,73 @@ def _build_relations(
560
802
  # Phase 4 — Symbol-level diff
561
803
  # ---------------------------------------------------------------------------
562
804
 
805
+ # ---------------------------------------------------------------------------
806
+ # Route-surface helpers
807
+ # ---------------------------------------------------------------------------
808
+
809
+ def _parse_route_path(args_str: str) -> str:
810
+ """Extract path string from annotation args. Handles named and positional forms."""
811
+ if not args_str:
812
+ return ""
813
+ for key in ("value", "path"):
814
+ m = re.search(rf'\b{key}\s*=\s*"([^"]*)"', args_str)
815
+ if m:
816
+ return m.group(1)
817
+ m = re.search(r'"([^"]*)"', args_str)
818
+ return m.group(1) if m else ""
819
+
820
+
821
+ def _parse_route_http_method(ann_name: str, args_str: str) -> str:
822
+ """Derive HTTP method from annotation name or explicit method= arg."""
823
+ explicit = _HTTP_METHOD_MAP.get(ann_name)
824
+ if explicit:
825
+ return explicit
826
+ m = re.search(r'method\s*=\s*(?:RequestMethod\.)?(\w+)', args_str or "")
827
+ return m.group(1).upper() if m else ""
828
+
829
+
830
+ def _parse_route_extras(args_str: str) -> dict:
831
+ """Extract produces/consumes/params from annotation args."""
832
+ result: dict = {}
833
+ for key in ("produces", "consumes", "params"):
834
+ m = re.search(rf'\b{key}\s*=\s*(?:"([^"]*)"|{{([^}}]*)}})', args_str or "")
835
+ if m:
836
+ result[key] = m.group(1) or m.group(2) or ""
837
+ return result
838
+
839
+
840
+ def _is_route_symbol(sym: SymbolRecord) -> bool:
841
+ return bool(sym.annotation_values) and any(
842
+ a in _ENDPOINT_ANNOTATIONS for a in sym.annotations
843
+ )
844
+
845
+
846
+ def _route_annotation_name(sym: SymbolRecord) -> str:
847
+ for ann in sym.annotations:
848
+ if ann in _ENDPOINT_ANNOTATIONS:
849
+ return ann
850
+ return ""
851
+
852
+
853
+ def _enclosing_class(fqn: str) -> str:
854
+ if "#" in fqn:
855
+ return fqn.split("#")[0]
856
+ if "." in fqn:
857
+ return fqn.rsplit(".", 1)[0]
858
+ return fqn
859
+
860
+
563
861
  def _symbol_fingerprint(sym: SymbolRecord) -> str:
564
- return f"{sym.type}|{','.join(sym.modifiers)}|{','.join(sym.annotations)}|{','.join(sym.imports_used)}"
862
+ route_val_seg = "|".join(
863
+ f"{a}:{sym.annotation_values.get(a, '')}"
864
+ for a in sorted(sym.annotations)
865
+ if a in _ENDPOINT_ANNOTATIONS
866
+ )
867
+ return (
868
+ f"{sym.type}|{','.join(sym.modifiers)}"
869
+ f"|{','.join(sym.annotations)}|{','.join(sym.imports_used)}"
870
+ f"|{route_val_seg}"
871
+ )
565
872
 
566
873
 
567
874
  def _diff_symbols(
@@ -601,7 +908,11 @@ def _diff_symbols(
601
908
  continue
602
909
 
603
910
  diff_type = "unknown"
604
- if set(old.annotations) != set(new.annotations):
911
+ old_rvals = {a: old.annotation_values.get(a, "") for a in old.annotations if a in _ENDPOINT_ANNOTATIONS}
912
+ new_rvals = {a: new.annotation_values.get(a, "") for a in new.annotations if a in _ENDPOINT_ANNOTATIONS}
913
+ if old_rvals != new_rvals:
914
+ diff_type = "route_surface_change"
915
+ elif set(old.annotations) != set(new.annotations):
605
916
  diff_type = "annotation_change"
606
917
  elif set(old.modifiers) != set(new.modifiers):
607
918
  diff_type = "structural_change"
@@ -618,6 +929,67 @@ def _diff_symbols(
618
929
  return changed
619
930
 
620
931
 
932
+ def _diff_routes(
933
+ old_syms: list[SymbolRecord],
934
+ new_syms: list[SymbolRecord],
935
+ ) -> list[dict]:
936
+ """Detect route-surface changes between old and new symbol sets."""
937
+ old_map = {s.symbol: s for s in old_syms if _is_route_symbol(s)}
938
+ new_map = {s.symbol: s for s in new_syms if _is_route_symbol(s)}
939
+
940
+ route_diffs: list[dict] = []
941
+ for fqn in sorted(set(old_map) & set(new_map)):
942
+ old_sym = old_map[fqn]
943
+ new_sym = new_map[fqn]
944
+
945
+ old_ann = _route_annotation_name(old_sym)
946
+ new_ann = _route_annotation_name(new_sym)
947
+ old_args = old_sym.annotation_values.get(old_ann, "")
948
+ new_args = new_sym.annotation_values.get(new_ann, "")
949
+
950
+ old_path = _parse_route_path(old_args)
951
+ new_path = _parse_route_path(new_args)
952
+ old_http = _parse_route_http_method(old_ann, old_args)
953
+ new_http = _parse_route_http_method(new_ann, new_args)
954
+ old_extras = _parse_route_extras(old_args)
955
+ new_extras = _parse_route_extras(new_args)
956
+
957
+ if old_path == new_path and old_http == new_http and old_ann == new_ann and old_extras == new_extras:
958
+ continue
959
+
960
+ evidence: dict = {
961
+ "annotation_value_changed": old_path != new_path,
962
+ "mapping_annotation": new_ann.lstrip("@"),
963
+ "old_value": old_path,
964
+ "new_value": new_path,
965
+ }
966
+ if old_http != new_http:
967
+ evidence["http_method_changed"] = True
968
+ evidence["old_http_method"] = old_http
969
+ evidence["new_http_method"] = new_http
970
+ if old_ann != new_ann:
971
+ evidence["annotation_changed"] = True
972
+ evidence["old_annotation"] = old_ann
973
+ evidence["new_annotation"] = new_ann
974
+ for key in ("produces", "consumes", "params"):
975
+ if old_extras.get(key) != new_extras.get(key):
976
+ evidence[f"{key}_changed"] = True
977
+ evidence[f"old_{key}"] = old_extras.get(key, "")
978
+ evidence[f"new_{key}"] = new_extras.get(key, "")
979
+
980
+ route_diffs.append({
981
+ "symbol": fqn,
982
+ "controller": _enclosing_class(fqn),
983
+ "route_surface_changed": True,
984
+ "old_route": old_path,
985
+ "new_route": new_path,
986
+ "stable_id": new_sym.stable_id,
987
+ "evidence": evidence,
988
+ })
989
+
990
+ return sorted(route_diffs, key=lambda d: d["symbol"])
991
+
992
+
621
993
  def _get_git_old_content(git_root: Path, rel_path: str, since: str) -> Optional[str]:
622
994
  try:
623
995
  result = subprocess.run(
@@ -746,38 +1118,101 @@ def _detect_subsystems(all_fqns: list[str], relations: list[RelationEdge]) -> li
746
1118
  return [sorted(v) for v in sorted(components.values())]
747
1119
 
748
1120
 
749
- def _propagate_impact(
1121
+ _EDGE_REASON_TEMPLATES: dict[str, str] = {
1122
+ "imports": "{from_sym} depends on {to_sym} (import)",
1123
+ "injects": "{from_sym} injects {to_sym}",
1124
+ "implements": "{from_sym} implements {to_sym}",
1125
+ "extends": "{from_sym} extends {to_sym}",
1126
+ "contained_in": "{from_sym} is a member of {to_sym}",
1127
+ "annotated_with": "{from_sym} is annotated with {to_sym}",
1128
+ "mapped_to": "Route {to_sym} depends on {from_sym}",
1129
+ }
1130
+
1131
+ # Edge types to exclude from reverse impact traversal (too noisy / non-dependency semantics)
1132
+ _REVERSE_EXCLUDE: frozenset[str] = frozenset({"annotated_with", "mapped_to"})
1133
+
1134
+
1135
+ def _edge_reason(edge_type: str, from_sym: str, to_sym: str) -> str:
1136
+ tmpl = _EDGE_REASON_TEMPLATES.get(
1137
+ edge_type, "{from_sym} → {to_sym} [{edge_type}]"
1138
+ )
1139
+ return tmpl.format(from_sym=from_sym, to_sym=to_sym, edge_type=edge_type)
1140
+
1141
+
1142
+ def _build_reverse_adjacency(
1143
+ relations: list[RelationEdge],
1144
+ all_fqns: set[str],
1145
+ ) -> dict[str, list[RelationEdge]]:
1146
+ """Invert the relation graph: target → edges pointing to it (known symbols only)."""
1147
+ reverse: dict[str, list[RelationEdge]] = {}
1148
+ for edge in relations:
1149
+ if edge.type in _REVERSE_EXCLUDE:
1150
+ continue
1151
+ if edge.to_symbol in all_fqns:
1152
+ reverse.setdefault(edge.to_symbol, []).append(edge)
1153
+ return reverse
1154
+
1155
+
1156
+ def _bfs_impact_with_paths(
750
1157
  changed_fqns: set[str],
751
1158
  changed_scores: dict[str, float],
752
- adjacency: dict[str, set[str]],
1159
+ reverse_adj: dict[str, list[RelationEdge]],
753
1160
  all_fqns: set[str],
754
1161
  max_depth: int = _BFS_MAX_DEPTH,
1162
+ enclosing_seeds: set[str] | None = None,
755
1163
  ) -> list[dict]:
756
- """BFS impact propagation with decay per hop. No path no impact."""
1164
+ """BFS on reverse graph: propagates impact from changed symbols to dependents.
1165
+
1166
+ Each impacted entry carries included_because: explicit graph path explaining inclusion.
1167
+ No graph path → no impact (deterministic guarantee).
1168
+
1169
+ enclosing_seeds: set of extra seeds that are enclosing classes (not directly changed).
1170
+ contained_in edges are skipped when traversing FROM these seeds to avoid pulling in
1171
+ sibling members of the actually-changed symbol.
1172
+ """
1173
+ _enclosing = enclosing_seeds or set()
757
1174
  impacted: dict[str, dict] = {}
758
- # (node, via, depth, score)
759
- queue: deque[tuple[str, str, int, float]] = deque()
1175
+ # (node, via_fqn, depth, score, path, reasons)
1176
+ queue: deque[tuple[str, str, int, float, list[str], list[str]]] = deque()
760
1177
 
761
1178
  for fqn in sorted(changed_fqns):
762
1179
  base = changed_scores.get(fqn, 0.0)
763
- for neighbor in sorted(adjacency.get(fqn, set())):
1180
+ skip_contained = fqn in _enclosing
1181
+ for edge in sorted(reverse_adj.get(fqn, []), key=lambda e: e.from_symbol):
1182
+ if skip_contained and edge.type == "contained_in":
1183
+ continue
1184
+ neighbor = edge.from_symbol
764
1185
  if neighbor not in changed_fqns and neighbor in all_fqns:
765
1186
  score = round(base * _PROPAGATION_DECAY, 4)
766
1187
  if score > 0:
767
- queue.append((neighbor, fqn, 1, score))
1188
+ reason = _edge_reason(edge.type, neighbor, fqn)
1189
+ queue.append((neighbor, fqn, 1, score, [fqn, neighbor], [reason]))
768
1190
 
769
1191
  while queue:
770
- node, via, depth, score = queue.popleft()
1192
+ node, via, depth, score, path, reasons = queue.popleft()
771
1193
  existing = impacted.get(node)
772
1194
  if existing and existing["impact_score"] >= score:
773
1195
  continue
774
- impacted[node] = {"entity": node, "depth": depth, "impact_score": score, "via": via}
1196
+ impacted[node] = {
1197
+ "entity": node,
1198
+ "depth": depth,
1199
+ "impact_score": score,
1200
+ "via": via,
1201
+ "graph_path": path,
1202
+ "included_because": reasons,
1203
+ }
775
1204
  if depth < max_depth:
776
- for neighbor in sorted(adjacency.get(node, set())):
1205
+ for edge in sorted(reverse_adj.get(node, []), key=lambda e: e.from_symbol):
1206
+ neighbor = edge.from_symbol
777
1207
  if neighbor not in changed_fqns and neighbor in all_fqns:
778
1208
  next_score = round(score * _PROPAGATION_DECAY, 4)
779
1209
  if next_score > 0:
780
- queue.append((neighbor, node, depth + 1, next_score))
1210
+ reason = _edge_reason(edge.type, neighbor, node)
1211
+ queue.append((
1212
+ neighbor, node, depth + 1, next_score,
1213
+ path + [neighbor],
1214
+ reasons + [reason],
1215
+ ))
781
1216
 
782
1217
  return sorted(impacted.values(), key=lambda x: (-x["impact_score"], x["entity"]))
783
1218
 
@@ -801,6 +1236,7 @@ def _assemble(
801
1236
  relations: list[RelationEdge],
802
1237
  changed_symbols: list[ChangedSymbol],
803
1238
  spring_summary: dict, # noqa: ARG001 — used internally via _spring_role on symbols
1239
+ route_diffs: list[dict] | None = None,
804
1240
  ) -> dict:
805
1241
  """Phase 5: Final assembly — single deterministic output contract."""
806
1242
  sorted_syms = sorted(symbols, key=lambda s: s.symbol)
@@ -912,11 +1348,29 @@ def _assemble(
912
1348
  "evidence_bundle": bundle.to_dict() if bundle else None,
913
1349
  })
914
1350
 
915
- # --- Impact propagation (BFS, graph-only) ---
1351
+ # --- Reverse graph: target → dependents (for impact propagation + agent queries) ---
1352
+ reverse_adj = _build_reverse_adjacency(sorted_rels, all_fqns_set)
1353
+
1354
+ # --- Impact propagation (BFS on reverse graph — finds who depends on changed symbol) ---
916
1355
  changed_with_graph = {e["entity"] for e in changed_entities_out}
917
1356
  changed_scores_map = {fqn: node_scores.get(fqn, 0.0) for fqn in changed_with_graph}
918
- impacted_entities_out = _propagate_impact(
919
- changed_with_graph, changed_scores_map, adjacency, all_fqns_set
1357
+
1358
+ # Method/field change → also propagate from enclosing class (class is effectively changed).
1359
+ # These are "enclosing seeds" — contained_in edges are skipped from them to avoid
1360
+ # pulling in sibling members of the actually-changed symbol.
1361
+ _enclosing_seeds: set[str] = set()
1362
+ _extra_seeds: dict[str, float] = {}
1363
+ for fqn, score in list(changed_scores_map.items()):
1364
+ enclosing = _enclosing_class(fqn)
1365
+ if enclosing != fqn and enclosing in all_fqns_set and enclosing not in changed_scores_map:
1366
+ _extra_seeds[enclosing] = max(_extra_seeds.get(enclosing, 0.0), score)
1367
+ _enclosing_seeds.add(enclosing)
1368
+ changed_with_graph.update(_extra_seeds)
1369
+ changed_scores_map.update(_extra_seeds)
1370
+
1371
+ impacted_entities_out = _bfs_impact_with_paths(
1372
+ changed_with_graph, changed_scores_map, reverse_adj, all_fqns_set,
1373
+ enclosing_seeds=_enclosing_seeds,
920
1374
  )
921
1375
 
922
1376
  # --- Subsystem detection (connected components, graph-only) ---
@@ -942,6 +1396,11 @@ def _assemble(
942
1396
  graph_nodes = [
943
1397
  {
944
1398
  "fqn": s.symbol,
1399
+ "stable_id": s.stable_id,
1400
+ "symbol_kind": s.symbol_kind,
1401
+ "canonical_name": s.canonical_name or s.symbol,
1402
+ "source_file": s.declaring_file,
1403
+ "signature": s.signature,
945
1404
  "type": s.type,
946
1405
  "role": spring_role_map.get(s.symbol, "other"),
947
1406
  "in_degree": in_deg.get(s.symbol, 0),
@@ -951,12 +1410,21 @@ def _assemble(
951
1410
  ]
952
1411
  graph_edges = [_edge_to_dict(e) for e in sorted_rels]
953
1412
 
1413
+ # Reverse graph index: target_fqn → {edge_type → [from_fqn, ...]} for agent queries
1414
+ reverse_graph_out: dict[str, dict[str, list[str]]] = {}
1415
+ for target, edges_in in sorted(reverse_adj.items()):
1416
+ by_type: dict[str, list[str]] = {}
1417
+ for e in sorted(edges_in, key=lambda x: x.from_symbol):
1418
+ by_type.setdefault(e.type, []).append(e.from_symbol)
1419
+ reverse_graph_out[target] = by_type
1420
+
954
1421
  return {
955
1422
  "schema_version": "final-v1",
956
1423
  "graph": {
957
1424
  "nodes": graph_nodes,
958
1425
  "edges": graph_edges,
959
1426
  },
1427
+ "reverse_graph": reverse_graph_out,
960
1428
  "analysis": {
961
1429
  "changed_entities": changed_entities_out,
962
1430
  "impacted_entities": impacted_entities_out,
@@ -969,6 +1437,7 @@ def _assemble(
969
1437
  },
970
1438
  "subsystems": subsystems,
971
1439
  "change_set": change_set_out,
1440
+ "route_surface": route_diffs or [],
972
1441
  "audit": {
973
1442
  "dropped_fields": dropped_fields,
974
1443
  },
@@ -999,11 +1468,13 @@ def extract_file_ir(
999
1468
  spring_summary = _build_spring_summary(symbols)
1000
1469
 
1001
1470
  changed_symbols: list[ChangedSymbol] = []
1471
+ route_diffs: list[dict] = []
1002
1472
  if old_source is not None:
1003
1473
  _, old_symbols, _ = _extract_symbols(old_source, rel_path)
1004
1474
  changed_symbols = _diff_symbols(old_symbols, symbols)
1475
+ route_diffs = _diff_routes(old_symbols, symbols)
1005
1476
 
1006
- return _assemble(symbols, relations, changed_symbols, spring_summary)
1477
+ return _assemble(symbols, relations, changed_symbols, spring_summary, route_diffs)
1007
1478
 
1008
1479
 
1009
1480
  def build_repo_ir(
@@ -1024,6 +1495,7 @@ def build_repo_ir(
1024
1495
  all_symbols: list[SymbolRecord] = []
1025
1496
  all_relations: list[RelationEdge] = []
1026
1497
  all_changed: list[ChangedSymbol] = []
1498
+ all_route_diffs: list[dict] = []
1027
1499
 
1028
1500
  for rel_path in sorted(file_paths):
1029
1501
  abs_path = root / rel_path
@@ -1042,6 +1514,7 @@ def build_repo_ir(
1042
1514
  if old_source is not None:
1043
1515
  _, old_symbols, _ = _extract_symbols(old_source, rel_path)
1044
1516
  all_changed.extend(_diff_symbols(old_symbols, symbols))
1517
+ all_route_diffs.extend(_diff_routes(old_symbols, symbols))
1045
1518
  elif since:
1046
1519
  for sym in symbols:
1047
1520
  all_changed.append(ChangedSymbol(
@@ -1065,7 +1538,8 @@ def build_repo_ir(
1065
1538
  seen.add(key)
1066
1539
  unique_relations.append(e)
1067
1540
 
1068
- return _assemble(all_symbols, unique_relations, all_changed, spring_summary)
1541
+ all_route_diffs_sorted = sorted(all_route_diffs, key=lambda d: d["symbol"])
1542
+ return _assemble(all_symbols, unique_relations, all_changed, spring_summary, all_route_diffs_sorted)
1069
1543
 
1070
1544
 
1071
1545
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.30.13
3
+ Version: 1.30.14
4
4
  Summary: Deterministic codebase context for AI coding agents
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -221,7 +221,7 @@ Description-Content-Type: text/markdown
221
221
 
222
222
  **Deterministic, behavior-aware codebase context for AI agents and PR review.**
223
223
 
224
- ![Version](https://img.shields.io/badge/version-1.30.13-blue)
224
+ ![Version](https://img.shields.io/badge/version-1.30.14-blue)
225
225
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
226
226
 
227
227
  ---
@@ -257,7 +257,7 @@ pipx install sourcecode
257
257
 
258
258
  ```bash
259
259
  sourcecode version
260
- # sourcecode 1.30.13
260
+ # sourcecode 1.30.14
261
261
  ```
262
262
 
263
263
  ---
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=scUPhCCkXqojtj_q9g2hhEC2zw9bcfExciIaq6Vcfck,104
1
+ sourcecode/__init__.py,sha256=J6N6z5RQFo5wJ4DBIDbgaGji2fockYUeA6wyuudLbU4,104
2
2
  sourcecode/adaptive_scanner.py,sha256=RTNExwWPXzjgLaRueT7UuxkPj5ZEToWjGbx1j0LSZ9E,10250
3
3
  sourcecode/architecture_analyzer.py,sha256=MyBa0Hf5HmkudZQDLKrjcWDKETXETXl0mQX1swtTwAA,39091
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
@@ -17,17 +17,17 @@ sourcecode/doc_analyzer.py,sha256=afA4uJFwXZ_uR2l4J0pQwbeTkRkGmKdN9KhRVYePBUw,24
17
17
  sourcecode/entrypoint_classifier.py,sha256=gvKgl0f5T8ol1r4JMmkeqGHuZTfZJiOwFOWdc7EYwYw,4061
18
18
  sourcecode/env_analyzer.py,sha256=GxCidahAAIptTdDFIlVB6URd4HBnBlIX_SqUov3MBRQ,22076
19
19
  sourcecode/file_classifier.py,sha256=48ly5Z6exkzBy8lNy1AkdP4-oJqIA1zT3LZfffuTyDo,11572
20
- sourcecode/flow_analyzer.py,sha256=jJZG-cQrENI7zc6kduNT1dLVtxY2EvJc95Te3Wam_Wk,27550
20
+ sourcecode/flow_analyzer.py,sha256=1XczDeeIjOplAAO6QLprwBEGFgHk-Qf4T8ZLeyKzgWY,27603
21
21
  sourcecode/git_analyzer.py,sha256=_pCg2V4d2aa17k9hayTzpexAj8syvyk4y9NYNvvgOAI,12802
22
22
  sourcecode/graph_analyzer.py,sha256=iUK-7pSV-cvGqqD2hENdYmhnm0wcXFEyK-xnu5ul8OU,62515
23
23
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
24
- sourcecode/prepare_context.py,sha256=O3hInKTTNGHJe8vu-wDcNYz6JPknILyyE394XPwpoMs,150864
24
+ sourcecode/prepare_context.py,sha256=OD5dYHiPSk42XSFj0WNIeFS_HFdb4BmN2eY5lxn8vJ8,149941
25
25
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
26
26
  sourcecode/ranking_engine.py,sha256=virVglafZufioHpZpwktjMvUiL0TZELWQCQnQNV8dFo,9360
27
27
  sourcecode/redactor.py,sha256=xuGcadGEHaPw4qZXlMDvzMCsr4VOkdp3oBQptHyJk8c,2884
28
28
  sourcecode/relevance_scorer.py,sha256=MYF4FFkveAQps9SmTeTlh6ODiBz2F--_hWNeHMLtUHQ,8405
29
29
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
30
- sourcecode/repository_ir.py,sha256=L1-qsWT3sWkXyq7tgiiEDyb53PNttuZPsyvjdH7ft1U,38135
30
+ sourcecode/repository_ir.py,sha256=uwi6-mJPh1E2-RYjf-ndqUXTvN32-7Nfv6oFqwHQi1c,57566
31
31
  sourcecode/runtime_classifier.py,sha256=zWX3r3HCKHc-qtIobErOa8aKMmaoPYREtJKvPcBGPjQ,14792
32
32
  sourcecode/scanner.py,sha256=aM3h9-DCQ3xKpeHpHYdo2vX6T5P95HA_YwZbkAVNwmo,8288
33
33
  sourcecode/schema.py,sha256=fj3BZ3IcnNV4j21BFIEvz8Qnw_vZoqIbzzRg-qQ-nd0,24530
@@ -63,8 +63,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
63
63
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
64
64
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
65
65
  sourcecode/telemetry/transport.py,sha256=KJeIPCPWMdmbCP3ySGs2iUlia34U6vWne2dZsUezesw,1560
66
- sourcecode-1.30.13.dist-info/METADATA,sha256=lr1N28PeCvFmyTPIW1KYSD-6tK_O4dl0VSrAlv_7G4o,26773
67
- sourcecode-1.30.13.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
68
- sourcecode-1.30.13.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
69
- sourcecode-1.30.13.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
70
- sourcecode-1.30.13.dist-info/RECORD,,
66
+ sourcecode-1.30.14.dist-info/METADATA,sha256=OyIxTxe6JrHhKVJJSCThUcol7s0ydMbdAls1m0q1ePE,26773
67
+ sourcecode-1.30.14.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
68
+ sourcecode-1.30.14.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
69
+ sourcecode-1.30.14.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
70
+ sourcecode-1.30.14.dist-info/RECORD,,