sourcecode 1.35.31__py3-none-any.whl → 1.35.33__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.35.31"
3
+ __version__ = "1.35.33"
@@ -237,7 +237,7 @@ class ArchitectureAnalyzer:
237
237
  return ArchitectureAnalysis(
238
238
  requested=True,
239
239
  pattern="unknown",
240
- limitations=["Arquitectura no inferida: proyecto sin archivos de codigo suficientes"],
240
+ limitations=["Architecture not inferred: insufficient source files in project"],
241
241
  evidence=[{"type": "none", "paths": [], "reason": "insufficient source files", "confidence": "high"}],
242
242
  tentative=False,
243
243
  )
sourcecode/classifier.py CHANGED
@@ -19,6 +19,10 @@ _API_FRAMEWORKS = {
19
19
  "Actix Web",
20
20
  "Rocket",
21
21
  "Spring Boot",
22
+ "Spring MVC",
23
+ "Spring WebFlux",
24
+ "Micronaut",
25
+ "Vert.x",
22
26
  "Quarkus",
23
27
  "Jakarta EE", # JAX-RS / Jakarta REST — pure JAX-RS projects must not fall to "unknown"
24
28
  "Laravel",
sourcecode/cli.py CHANGED
@@ -1227,159 +1227,164 @@ def main(
1227
1227
  except Exception:
1228
1228
  pass
1229
1229
 
1230
- if _git_sha and _git_root_str:
1231
- _excl_key = (
1232
- ",".join(sorted(e.strip() for e in exclude.split(",") if e.strip()))
1233
- if exclude else ""
1234
- )
1230
+ _excl_key = (
1231
+ ",".join(sorted(e.strip() for e in exclude.split(",") if e.strip()))
1232
+ if exclude else ""
1233
+ )
1235
1234
 
1236
- # ── Core (analysis) flags: affect which analyzers run + scan config ──
1237
- # Use effective_depth (not raw depth) so Java auto-adjustment is captured.
1238
- # acv = ANALYZER_CACHE_VERSION — bumped only when analysis logic/schema
1239
- # changes, NOT on every package release. Prevents patch-bump cache wipes.
1240
- _core_flags_str = (
1241
- f"acv={_cache_mod.ANALYZER_CACHE_VERSION},"
1242
- f"dep={dependencies},gm={graph_modules},"
1243
- f"docs={docs},fm={full_metrics},sem={semantics},"
1244
- f"arch={architecture},gc={git_context},em={env_map},"
1245
- f"cn={code_notes},"
1246
- f"ex={_excl_key},depth={effective_depth}"
1247
- )
1248
- _core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
1235
+ # ── Core (analysis) flags: affect which analyzers run + scan config ──
1236
+ # Use effective_depth (not raw depth) so Java auto-adjustment is captured.
1237
+ # acv = ANALYZER_CACHE_VERSION — bumped only when analysis logic/schema
1238
+ # changes, NOT on every package release. Prevents patch-bump cache wipes.
1239
+ _core_flags_str = (
1240
+ f"acv={_cache_mod.ANALYZER_CACHE_VERSION},"
1241
+ f"dep={dependencies},gm={graph_modules},"
1242
+ f"docs={docs},fm={full_metrics},sem={semantics},"
1243
+ f"arch={architecture},gc={git_context},em={env_map},"
1244
+ f"cn={code_notes},"
1245
+ f"ex={_excl_key},depth={effective_depth}"
1246
+ )
1247
+ _core_h = _hashlib.sha256(_core_flags_str.encode()).hexdigest()[:8]
1248
+ if _git_sha and _git_root_str:
1249
1249
  _core_key = f"{_git_sha}-{_core_h}"
1250
-
1251
- # ── View flags: output presentation only (no re-analysis needed) ──
1252
- _view_flags_str = (
1253
- f"c={compact},ag={agent},mode={mode},fmt={format},full={full},"
1254
- f"co={changed_only},tree={tree},nt={no_tree},"
1255
- f"rb={rank_by},sym={symbol},ep={entrypoints_only},"
1256
- f"nr={no_redact},gd={graph_detail},dd={docs_depth},"
1257
- f"mn={max_nodes},ge={graph_edges},mi={max_importers},"
1258
- f"eg={emit_graph}"
1259
- )
1260
- _view_h = _hashlib.sha256(_view_flags_str.encode()).hexdigest()[:8]
1261
-
1262
- # ── Lookup ──────────────────────────────────────────────────────
1263
- # Step 1: try L1 to obtain the core_hash needed for L2 key
1264
- _l1_result = _cache_mod.read_core(target, _core_key)
1265
-
1266
- # P1-A: --env-map misses L1 when base (em=False) exists.
1267
- # Try the base key so env analysis can be injected lazily (<1 s)
1268
- # instead of triggering a 17 s full rescan.
1269
- _l1_needs_env_inject = False
1270
- if _l1_result is None and env_map:
1271
- _base_flags = _core_flags_str.replace(",em=True,", ",em=False,")
1272
- _base_h8 = _hashlib.sha256(_base_flags.encode()).hexdigest()[:8]
1273
- _base_key = f"{_git_sha}-{_base_h8}"
1274
- _base_result = _cache_mod.read_core(target, _base_key)
1275
- if _base_result is not None:
1276
- _l1_result = _base_result
1277
- _l1_needs_env_inject = True
1278
-
1279
- if _l1_result is not None:
1280
- _core_dict_l1, _core_hash = _l1_result
1281
- _view_key = f"{_core_hash}-{_view_h}"
1282
-
1283
- # Step 2: try L2 (exact view match).
1284
- # Skip L2 for --changed-only: the stored view is a previous
1285
- # diff snapshot that is stale for the current diff.
1286
- if not changed_only:
1287
- _cache_hit_content = _cache_mod.read_view(target, _view_key)
1288
-
1289
- # Step 3: L1 hit but L2 miss → rebuild view from core dict
1290
- if _cache_hit_content is None:
1291
- try:
1292
- from sourcecode.serializer import build_view_from_core as _bvfc
1293
- _rebuilt = _bvfc(
1294
- _core_dict_l1,
1295
- compact=compact,
1296
- agent=agent,
1297
- full=full,
1298
- no_tree=no_tree,
1299
- tree=tree,
1300
- )
1301
- # P1-A: inject env analysis when base L1 (em=False) was used.
1302
- # EnvAnalyzer walks only env/config files — typically <1 s.
1303
- if _rebuilt is not None and _l1_needs_env_inject and compact:
1304
- try:
1305
- from sourcecode.env_analyzer import EnvAnalyzer as _EnvA_p1a
1306
- _env_r_p1a, _env_s_p1a = _EnvA_p1a().analyze(target, {})
1307
- if _env_s_p1a and (getattr(_env_s_p1a, "total", 0) or _env_r_p1a):
1308
- _es_p1a: dict = {
1309
- "total": getattr(_env_s_p1a, "total", 0),
1310
- "required": getattr(_env_s_p1a, "required_count", 0),
1311
- }
1312
- _cats = getattr(_env_s_p1a, "categories", None)
1313
- if _cats:
1314
- _es_p1a["categories"] = _cats
1315
- _rebuilt = dict(_rebuilt)
1316
- _rebuilt["env_summary"] = _es_p1a
1317
- if _env_r_p1a:
1318
- _sorted_er = sorted(
1319
- _env_r_p1a,
1320
- key=lambda e: (
1321
- not getattr(e, "required", False),
1322
- getattr(e, "key", ""),
1323
- ),
1324
- )
1325
- _rebuilt["env_map"] = [
1326
- {
1327
- "key": getattr(e, "key", ""),
1328
- **({"required": True} if getattr(e, "required", False) else {}),
1329
- **({"category": getattr(e, "category", None)} if getattr(e, "category", None) else {}),
1330
- }
1331
- for e in _sorted_er[:15]
1332
- ]
1333
- except Exception:
1334
- pass # env inject failed continue without env data
1335
- if _rebuilt is not None:
1336
- # Apply redaction
1337
- if not no_redact:
1338
- from sourcecode.redactor import redact_dict as _red_l1
1339
- _rebuilt = _red_l1(_rebuilt)
1340
- # Apply output budget
1341
- if agent:
1342
- from sourcecode.output_budget import (
1343
- trim_to_budget as _trim_l1,
1344
- BUDGET_AGENT,
1345
- )
1346
- _rebuilt = _trim_l1(_rebuilt, BUDGET_AGENT, label="agent")
1347
- elif compact:
1348
- from sourcecode.output_budget import (
1349
- trim_to_budget as _trim_l1c,
1350
- BUDGET_COMPACT,
1351
- )
1352
- _rebuilt = _trim_l1c(_rebuilt, BUDGET_COMPACT, label="compact")
1353
- # Serialize
1354
- if format == "yaml":
1355
- from io import StringIO as _SIO_L1
1356
- from ruamel.yaml import YAML as _YAML_L1
1357
- _yl1 = _YAML_L1()
1358
- _yl1.default_flow_style = False
1359
- _yl1.representer.add_representer(
1360
- type(None),
1361
- lambda d, v: d.represent_scalar(
1362
- "tag:yaml.org,2002:null", "null"
1363
- ),
1364
- )
1365
- _sl1 = _SIO_L1()
1366
- _yl1.dump(_rebuilt, _sl1)
1367
- _cache_hit_content = _sl1.getvalue()
1368
- else:
1369
- import json as _json_l1
1370
- _cache_hit_content = _json_l1.dumps(
1371
- _rebuilt, indent=2, ensure_ascii=False
1372
- )
1373
- # Cache rebuilt view in L2 (skip for --changed-only: stale diff)
1374
- if _cache_hit_content and not changed_only:
1375
- _cache_mod.write_view(
1376
- target,
1377
- _view_key,
1378
- _cache_hit_content,
1379
- fmt=format,
1380
- )
1381
- except Exception:
1382
- _cache_hit_content = None # rebuild failed → full analysis
1250
+ else:
1251
+ # No git history (untracked/no-commit repo) — stable synthetic key
1252
+ # scoped per repo path via cache_dir(); invalidated by --no-cache or cache clear.
1253
+ _core_key = f"nogit-{_core_h}"
1254
+
1255
+ # ── View flags: output presentation only (no re-analysis needed) ──
1256
+ _view_flags_str = (
1257
+ f"c={compact},ag={agent},mode={mode},fmt={format},full={full},"
1258
+ f"co={changed_only},tree={tree},nt={no_tree},"
1259
+ f"rb={rank_by},sym={symbol},ep={entrypoints_only},"
1260
+ f"nr={no_redact},gd={graph_detail},dd={docs_depth},"
1261
+ f"mn={max_nodes},ge={graph_edges},mi={max_importers},"
1262
+ f"eg={emit_graph}"
1263
+ )
1264
+ _view_h = _hashlib.sha256(_view_flags_str.encode()).hexdigest()[:8]
1265
+
1266
+ # ── Lookup ──────────────────────────────────────────────────────
1267
+ # Step 1: try L1 to obtain the core_hash needed for L2 key
1268
+ _l1_result = _cache_mod.read_core(target, _core_key)
1269
+
1270
+ # P1-A: --env-map misses L1 when base (em=False) exists.
1271
+ # Try the base key so env analysis can be injected lazily (<1 s)
1272
+ # instead of triggering a 17 s full rescan.
1273
+ _l1_needs_env_inject = False
1274
+ if _l1_result is None and env_map:
1275
+ _base_flags = _core_flags_str.replace(",em=True,", ",em=False,")
1276
+ _base_h8 = _hashlib.sha256(_base_flags.encode()).hexdigest()[:8]
1277
+ _sha_prefix = _git_sha if _git_sha else "nogit"
1278
+ _base_key = f"{_sha_prefix}-{_base_h8}"
1279
+ _base_result = _cache_mod.read_core(target, _base_key)
1280
+ if _base_result is not None:
1281
+ _l1_result = _base_result
1282
+ _l1_needs_env_inject = True
1283
+
1284
+ if _l1_result is not None:
1285
+ _core_dict_l1, _core_hash = _l1_result
1286
+ _view_key = f"{_core_hash}-{_view_h}"
1287
+
1288
+ # Step 2: try L2 (exact view match).
1289
+ # Skip L2 for --changed-only: the stored view is a previous
1290
+ # diff snapshot that is stale for the current diff.
1291
+ if not changed_only:
1292
+ _cache_hit_content = _cache_mod.read_view(target, _view_key)
1293
+
1294
+ # Step 3: L1 hit but L2 miss → rebuild view from core dict
1295
+ if _cache_hit_content is None:
1296
+ try:
1297
+ from sourcecode.serializer import build_view_from_core as _bvfc
1298
+ _rebuilt = _bvfc(
1299
+ _core_dict_l1,
1300
+ compact=compact,
1301
+ agent=agent,
1302
+ full=full,
1303
+ no_tree=no_tree,
1304
+ tree=tree,
1305
+ )
1306
+ # P1-A: inject env analysis when base L1 (em=False) was used.
1307
+ # EnvAnalyzer walks only env/config files typically <1 s.
1308
+ if _rebuilt is not None and _l1_needs_env_inject and compact:
1309
+ try:
1310
+ from sourcecode.env_analyzer import EnvAnalyzer as _EnvA_p1a
1311
+ _env_r_p1a, _env_s_p1a = _EnvA_p1a().analyze(target, {})
1312
+ if _env_s_p1a and (getattr(_env_s_p1a, "total", 0) or _env_r_p1a):
1313
+ _es_p1a: dict = {
1314
+ "total": getattr(_env_s_p1a, "total", 0),
1315
+ "required": getattr(_env_s_p1a, "required_count", 0),
1316
+ }
1317
+ _cats = getattr(_env_s_p1a, "categories", None)
1318
+ if _cats:
1319
+ _es_p1a["categories"] = _cats
1320
+ _rebuilt = dict(_rebuilt)
1321
+ _rebuilt["env_summary"] = _es_p1a
1322
+ if _env_r_p1a:
1323
+ _sorted_er = sorted(
1324
+ _env_r_p1a,
1325
+ key=lambda e: (
1326
+ not getattr(e, "required", False),
1327
+ getattr(e, "key", ""),
1328
+ ),
1329
+ )
1330
+ _rebuilt["env_map"] = [
1331
+ {
1332
+ "key": getattr(e, "key", ""),
1333
+ **({"required": True} if getattr(e, "required", False) else {}),
1334
+ **({"category": getattr(e, "category", None)} if getattr(e, "category", None) else {}),
1335
+ }
1336
+ for e in _sorted_er[:15]
1337
+ ]
1338
+ except Exception:
1339
+ pass # env inject failed — continue without env data
1340
+ if _rebuilt is not None:
1341
+ # Apply redaction
1342
+ if not no_redact:
1343
+ from sourcecode.redactor import redact_dict as _red_l1
1344
+ _rebuilt = _red_l1(_rebuilt)
1345
+ # Apply output budget
1346
+ if agent:
1347
+ from sourcecode.output_budget import (
1348
+ trim_to_budget as _trim_l1,
1349
+ BUDGET_AGENT,
1350
+ )
1351
+ _rebuilt = _trim_l1(_rebuilt, BUDGET_AGENT, label="agent")
1352
+ elif compact:
1353
+ from sourcecode.output_budget import (
1354
+ trim_to_budget as _trim_l1c,
1355
+ BUDGET_COMPACT,
1356
+ )
1357
+ _rebuilt = _trim_l1c(_rebuilt, BUDGET_COMPACT, label="compact")
1358
+ # Serialize
1359
+ if format == "yaml":
1360
+ from io import StringIO as _SIO_L1
1361
+ from ruamel.yaml import YAML as _YAML_L1
1362
+ _yl1 = _YAML_L1()
1363
+ _yl1.default_flow_style = False
1364
+ _yl1.representer.add_representer(
1365
+ type(None),
1366
+ lambda d, v: d.represent_scalar(
1367
+ "tag:yaml.org,2002:null", "null"
1368
+ ),
1369
+ )
1370
+ _sl1 = _SIO_L1()
1371
+ _yl1.dump(_rebuilt, _sl1)
1372
+ _cache_hit_content = _sl1.getvalue()
1373
+ else:
1374
+ import json as _json_l1
1375
+ _cache_hit_content = _json_l1.dumps(
1376
+ _rebuilt, indent=2, ensure_ascii=False
1377
+ )
1378
+ # Cache rebuilt view in L2 (skip for --changed-only: stale diff)
1379
+ if _cache_hit_content and not changed_only:
1380
+ _cache_mod.write_view(
1381
+ target,
1382
+ _view_key,
1383
+ _cache_hit_content,
1384
+ fmt=format,
1385
+ )
1386
+ except Exception:
1387
+ _cache_hit_content = None # rebuild failed → full analysis
1383
1388
 
1384
1389
  except Exception:
1385
1390
  _core_key = ""
@@ -2290,6 +2295,11 @@ def main(
2290
2295
  try:
2291
2296
  from sourcecode.ris import _has_uncommitted_changes as _huc_fresh
2292
2297
  _uncommitted_fresh = _huc_fresh(target)
2298
+ # _huc_fresh uses --untracked-files=no — it misses repos where all
2299
+ # files are untracked (no staged/unstaged changes to tracked files).
2300
+ # Promote to True when _allowed_changed_files contains untracked files.
2301
+ if not _uncommitted_fresh and _allowed_changed_files:
2302
+ _uncommitted_fresh = True
2293
2303
  except Exception:
2294
2304
  _uncommitted_fresh = False
2295
2305
  import datetime as _dt
@@ -2335,44 +2345,51 @@ def main(
2335
2345
  import atexit as _atexit
2336
2346
  import threading as _threading
2337
2347
 
2338
- # Capture all closure state before handing off to thread
2339
- _bg_sm = sm
2340
- _bg_target = target
2341
- _bg_core_key = _core_key
2342
- _bg_view_key = _view_key
2343
- _bg_view_flags_str = _view_flags_str
2344
- _bg_content = content
2345
- _bg_format = format
2346
- _bg_hashlib = _hashlib
2347
- _bg_cache_mod = _cache_mod
2348
-
2349
- def _write_cache_async() -> None:
2350
- try:
2351
- from sourcecode.serializer import core_view as _core_view_fn
2352
- _core_dict_write = _core_view_fn(_bg_sm)
2353
- _written_core_hash = _bg_cache_mod.write_core(
2354
- _bg_target, _bg_core_key, _core_dict_write
2355
- )
2356
- if _written_core_hash:
2357
- _vk = _bg_view_key
2358
- if not _vk:
2359
- _wvh = _bg_hashlib.sha256(_bg_view_flags_str.encode()).hexdigest()[:8]
2360
- _vk = f"{_written_core_hash}-{_wvh}"
2361
- _bg_cache_mod.write_view(
2362
- _bg_target,
2363
- _vk,
2364
- _bg_content,
2365
- fmt=_bg_format,
2366
- layers=_compute_analyzer_fingerprints(),
2348
+ # Pre-compute core dict in the main thread avoids the 5-second atexit
2349
+ # join race on large repos where core_view(sm) itself takes seconds.
2350
+ # Background thread only does gzip + disk I/O (fast, bounded latency).
2351
+ _bg_core_dict: dict | None = None
2352
+ try:
2353
+ from sourcecode.serializer import core_view as _core_view_fn
2354
+ _bg_core_dict = _core_view_fn(sm)
2355
+ except Exception:
2356
+ pass
2357
+
2358
+ if _bg_core_dict is not None:
2359
+ _bg_target = target
2360
+ _bg_core_key = _core_key
2361
+ _bg_view_key = _view_key
2362
+ _bg_view_flags_str = _view_flags_str
2363
+ _bg_content = content
2364
+ _bg_format = format
2365
+ _bg_hashlib = _hashlib
2366
+ _bg_cache_mod = _cache_mod
2367
+
2368
+ def _write_cache_async() -> None:
2369
+ try:
2370
+ _written_core_hash = _bg_cache_mod.write_core(
2371
+ _bg_target, _bg_core_key, _bg_core_dict
2367
2372
  )
2368
- from sourcecode.cache import cache_dir as _cdir, _gc as _run_gc
2369
- _run_gc(_cdir(_bg_target))
2370
- except Exception:
2371
- pass
2373
+ if _written_core_hash:
2374
+ _vk = _bg_view_key
2375
+ if not _vk:
2376
+ _wvh = _bg_hashlib.sha256(_bg_view_flags_str.encode()).hexdigest()[:8]
2377
+ _vk = f"{_written_core_hash}-{_wvh}"
2378
+ _bg_cache_mod.write_view(
2379
+ _bg_target,
2380
+ _vk,
2381
+ _bg_content,
2382
+ fmt=_bg_format,
2383
+ layers=_compute_analyzer_fingerprints(),
2384
+ )
2385
+ from sourcecode.cache import cache_dir as _cdir, _gc as _run_gc
2386
+ _run_gc(_cdir(_bg_target))
2387
+ except Exception:
2388
+ pass
2372
2389
 
2373
- _cache_write_thread = _threading.Thread(target=_write_cache_async, daemon=True)
2374
- _cache_write_thread.start()
2375
- _atexit.register(_cache_write_thread.join, 5.0)
2390
+ _cache_write_thread = _threading.Thread(target=_write_cache_async, daemon=True)
2391
+ _cache_write_thread.start()
2392
+ _atexit.register(_cache_write_thread.join, 30.0)
2376
2393
 
2377
2394
  # Update RIS with aggregated snapshot data (non-fatal side-effect).
2378
2395
  # Update RIS whenever git is available, even when L1/L2 cache is skipped
@@ -1277,6 +1277,11 @@ class DependencyAnalyzer:
1277
1277
 
1278
1278
  return records, limitations
1279
1279
 
1280
+ def _strip_gradle_comments(self, content: str) -> str:
1281
+ content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
1282
+ content = re.sub(r"//[^\n]*", "", content)
1283
+ return content
1284
+
1280
1285
  def _analyze_gradle(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
1281
1286
  for filename in ("build.gradle", "build.gradle.kts"):
1282
1287
  gradle_file = root / filename
@@ -1287,7 +1292,22 @@ class DependencyAnalyzer:
1287
1292
  return [], [f"gradle: error reading {filename}"]
1288
1293
  props = self._parse_gradle_properties(root, content)
1289
1294
  records = self._parse_gradle_dependencies(content, props, filename)
1290
- return records, ["gradle: no compatible lockfile found; transitive dependencies unavailable"]
1295
+
1296
+ # Follow apply from: 'other.gradle' directives (simple string literals only)
1297
+ apply_pat = re.compile(
1298
+ r"""apply\s+from\s*:\s*["']([^"'${}\s]+\.gradle(?:\.kts)?)["']"""
1299
+ )
1300
+ for m in apply_pat.finditer(self._strip_gradle_comments(content)):
1301
+ applied_path = root / m.group(1)
1302
+ if applied_path.exists() and applied_path.resolve() != gradle_file.resolve():
1303
+ try:
1304
+ applied_content = applied_path.read_text(encoding="utf-8", errors="replace")
1305
+ more = self._parse_gradle_dependencies(applied_content, props, m.group(1))
1306
+ records.extend(more)
1307
+ except OSError:
1308
+ pass
1309
+
1310
+ return self._dedupe(records), ["gradle: no compatible lockfile found; transitive dependencies unavailable"]
1291
1311
  return [], []
1292
1312
 
1293
1313
  def _parse_gradle_properties(self, root: Path, content: str) -> dict[str, str]:
@@ -1326,6 +1346,7 @@ class DependencyAnalyzer:
1326
1346
  def _parse_gradle_dependencies(
1327
1347
  self, content: str, props: dict[str, str], manifest_path: str
1328
1348
  ) -> list[DependencyRecord]:
1349
+ content = self._strip_gradle_comments(content)
1329
1350
  _DIRECT = frozenset({
1330
1351
  "implementation", "api", "compileOnly", "runtimeOnly", "compile", "provided",
1331
1352
  "compileClasspath", "runtimeClasspath",
@@ -94,6 +94,7 @@ _CLASS_RE = re.compile(
94
94
  )
95
95
  _METHOD_RE = re.compile(
96
96
  r'^\s*(?:(?:public|protected|private|static|final|synchronized|abstract|native|default|override)\s+)*'
97
+ r'(?:@\w+(?:\([^)]*\))?\s+)*' # optional return-type annotations (e.g. @ResponseBody)
97
98
  r'(?:<[^>]+>\s+)?' # optional generic return type
98
99
  r'(?:[\w<>\[\],?\s]+\s+)?' # return type (optional for constructors)
99
100
  r'(\w+)\s*\(' # method/constructor name + opening paren
@@ -288,6 +289,8 @@ def chunk_java_file(
288
289
  current_method_name = ""
289
290
  current_method_type = ""
290
291
  method_brace_start_depth = -1
292
+ # State for multi-line method signatures (name detected, waiting for opening '{')
293
+ _pending_method: Optional[tuple[str, str]] = None # (name, type)
291
294
 
292
295
  for line_no_0, raw_line in enumerate(all_lines):
293
296
  line_no = line_no_0 + 1 # 1-based
@@ -315,32 +318,16 @@ def chunk_java_file(
315
318
 
316
319
  # Check if this line starts a method/constructor AT class_depth+1
317
320
  if class_depth >= 0 and depth == class_depth + 1 and not current_method_name:
318
- is_method, mname, mtype = _is_method_or_constructor_start(
319
- stripped, class_name, depth, class_depth
320
- )
321
- if is_method and "{" in raw_line:
322
- # Flush anything accumulated as field_block / class_header
323
- # Include annotation lines in the new method chunk
324
- if pending_lines:
325
- # Check if last N pending lines are annotations for this method
326
- # Flush everything up to ann_buffer start
327
- if ann_buffer:
328
- ann_start_line = ann_buffer[0][0]
329
- pre_ann_lines = pending_lines[:ann_start_line - pending_start]
330
- if pre_ann_lines:
331
- _flush_chunk(ann_start_line - 1)
332
- # Move ann_buffer lines into the new method chunk
333
- pending_start = ann_start_line
334
- pending_lines = [al for _, al in ann_buffer]
335
- ann_buffer = []
336
- else:
337
- _flush_chunk(line_no - 1)
338
- pending_start = line_no
339
- pending_lines = []
340
-
321
+ # Multi-line signature: we already detected the method name; this line
322
+ # should contain the opening brace that starts the method body.
323
+ # Guard: opens > closes ensures a net depth increase (not a balanced
324
+ # annotation arg like @RequestMapping({"v1","v2"})).
325
+ if _pending_method and opens > closes:
326
+ mname, mtype = _pending_method
327
+ _pending_method = None
341
328
  current_method_name = mname
342
329
  current_method_type = mtype
343
- method_brace_start_depth = depth + opens - 1 # depth entering method body
330
+ method_brace_start_depth = depth + opens - 1
344
331
  pending_type = mtype
345
332
  pending_symbol = f"{class_name}#{mname}" if class_name else mname
346
333
  pending_lines.append(raw_line)
@@ -348,6 +335,49 @@ def chunk_java_file(
348
335
  ann_buffer = []
349
336
  continue
350
337
 
338
+ if not _pending_method:
339
+ is_method, mname, mtype = _is_method_or_constructor_start(
340
+ stripped, class_name, depth, class_depth
341
+ )
342
+ if is_method:
343
+ # Flush anything accumulated as field_block / class_header
344
+ # Include annotation lines in the new method chunk
345
+ if pending_lines:
346
+ if ann_buffer:
347
+ ann_start_line = ann_buffer[0][0]
348
+ pre_ann_lines = pending_lines[:ann_start_line - pending_start]
349
+ if pre_ann_lines:
350
+ _flush_chunk(ann_start_line - 1)
351
+ # Move ann_buffer lines into the new method chunk
352
+ pending_start = ann_start_line
353
+ pending_lines = [al for _, al in ann_buffer]
354
+ ann_buffer = []
355
+ else:
356
+ _flush_chunk(line_no - 1)
357
+ pending_start = line_no
358
+ pending_lines = []
359
+
360
+ if "{" in raw_line:
361
+ # Single-line signature: method body opens on same line
362
+ current_method_name = mname
363
+ current_method_type = mtype
364
+ method_brace_start_depth = depth + opens - 1
365
+ pending_type = mtype
366
+ pending_symbol = f"{class_name}#{mname}" if class_name else mname
367
+ pending_lines.append(raw_line)
368
+ depth += opens - closes
369
+ ann_buffer = []
370
+ continue
371
+ else:
372
+ # Multi-line signature: record name, wait for '{' on later line
373
+ _pending_method = (mname, mtype)
374
+ pending_type = mtype
375
+ pending_symbol = f"{class_name}#{mname}" if class_name else mname
376
+ pending_lines.append(raw_line)
377
+ depth += opens - closes
378
+ ann_buffer = []
379
+ continue
380
+
351
381
  # Update depth
352
382
  depth += opens - closes
353
383
 
@@ -506,7 +506,7 @@ _LEGACY_API_RULES: list[_Rule] = [
506
506
  ),
507
507
  fix_hint=(
508
508
  "Replace Date with LocalDate/LocalDateTime/ZonedDateTime, "
509
- "Calendar with java.time.Calendar, "
509
+ "Calendar with LocalDate or ZonedDateTime (java.time — no Calendar equivalent), "
510
510
  "SimpleDateFormat with DateTimeFormatter (thread-safe)."
511
511
  ),
512
512
  migration_target="java_8_best_practice",
@@ -1299,16 +1299,31 @@ _STATIC_LIMITATIONS: list[str] = [
1299
1299
 
1300
1300
 
1301
1301
  def _detect_spring_boot_2(root: Path) -> bool:
1302
- """Return True if any pom.xml or build.gradle declares spring-boot 2.x."""
1302
+ """Return True if any build file in the repo declares spring-boot 2.x.
1303
+
1304
+ Checks root build files plus one level of child modules to handle monorepos
1305
+ where the Spring Boot parent version lives in a submodule pom.xml.
1306
+ """
1303
1307
  _SB2 = re.compile(
1304
- r"(?:spring[.\-]boot[.\-]?(?:version|starter|parent)[^=\n]*[=:\s>\"']?\s*)"
1305
- r"2\.\d+[\.\d]*|"
1306
- r"<version>\s*2\.\d+[\.\d]*\s*</version>.*spring.boot|"
1307
- r"spring.boot.*<version>\s*2\.\d+",
1308
+ # Maven: <parent>...<artifactId>spring-boot-*</artifactId>...<version>2.x
1309
+ r"spring[.\-]?boot.*?<version>\s*2\.\d+|"
1310
+ r"<version>\s*2\.\d+[\.\d]*\s*</version>.*?spring[.\-]?boot|"
1311
+ # Maven properties: <spring.boot.version>2.x or spring-boot.version=2.x
1312
+ r"spring[.\-_]?boot[.\-_]?version\s*[=>\"'\s]+2\.\d+|"
1313
+ # Gradle plugin: id 'org.springframework.boot' version '2.x'
1314
+ r"org\.springframework\.boot[^\n]*?['\"']2\.\d+",
1308
1315
  re.IGNORECASE | re.DOTALL,
1309
1316
  )
1310
- for name in ("pom.xml", "build.gradle", "build.gradle.kts"):
1311
- candidate = root / name
1317
+ # Candidate build files: root + one level deep (child modules in monorepos)
1318
+ root_files = [
1319
+ root / name
1320
+ for name in ("pom.xml", "build.gradle", "build.gradle.kts", "gradle.properties")
1321
+ ]
1322
+ child_poms = list(root.glob("*/pom.xml"))
1323
+ child_gradle = list(root.glob("*/build.gradle")) + list(root.glob("*/build.gradle.kts"))
1324
+ # Limit child scan to 30 files to stay fast on large monorepos
1325
+ candidates = root_files + (child_poms + child_gradle)[:30]
1326
+ for candidate in candidates:
1312
1327
  try:
1313
1328
  text = candidate.read_text(encoding="utf-8", errors="replace")
1314
1329
  if _SB2.search(text):
@@ -78,7 +78,8 @@ def is_test_path(path: str) -> bool:
78
78
  or name.endswith(".spec.js")
79
79
  or (name.endswith("test.java") and name != "test.java")
80
80
  or name.endswith("tests.java")
81
- or (name.startswith("test") and name.endswith(".java") and len(name) > 9)
81
+ or (name.startswith("test") and name.endswith(".java") and len(name) > 9
82
+ and "/src/main/" not in norm)
82
83
  ):
83
84
  return True
84
85
 
@@ -2756,8 +2756,8 @@ def _build_route_surface(
2756
2756
  _route_entry["security_annotations"] = _sec
2757
2757
  routes.append(_route_entry)
2758
2758
 
2759
- # Phase 3: inheritance projection — subclasses with zero own endpoints
2760
- # but with a class-level @RequestMapping prefix inherit parent methods.
2759
+ # Phase 3: inheritance projection — subclasses with a class-level @RequestMapping
2760
+ # prefix inherit parent methods that they do not override (same HTTP verb + path suffix).
2761
2761
  if extends_map:
2762
2762
  fqn_to_simple: dict[str, str] = {d["fqn"]: s for s, d in class_info.items()}
2763
2763
  simple_extends: dict[str, str] = {
@@ -2771,11 +2771,14 @@ def _build_route_surface(
2771
2771
  }
2772
2772
 
2773
2773
  for cls_simple, data in class_info.items():
2774
- if data["own_endpoints"]:
2775
- continue
2776
2774
  if not any(data["prefixes"]):
2777
2775
  continue
2778
2776
 
2777
+ # (verb, suffix) pairs declared on this subclass — these shadow parent methods.
2778
+ own_override_set: set[tuple[str, str]] = {
2779
+ (verb, suffix) for verb, suffix, _, _ in data["own_endpoints"]
2780
+ }
2781
+
2779
2782
  chain = simple_extends.get(cls_simple)
2780
2783
  visited: set[str] = {cls_simple}
2781
2784
  depth = 1
@@ -2786,6 +2789,9 @@ def _build_route_surface(
2786
2789
  break
2787
2790
  if parent["own_endpoints"]:
2788
2791
  for verb, suffix, declaring_sym, stable_id in parent["own_endpoints"]:
2792
+ # Skip methods the subclass overrides (same verb + path suffix).
2793
+ if (verb, suffix) in own_override_set:
2794
+ continue
2789
2795
  for prefix in data["prefixes"]:
2790
2796
  # P1 fix: collapse any number of consecutive slashes
2791
2797
  full_path = re.sub(r"/+", "/", prefix + "/" + suffix).rstrip("/") or "/"
@@ -2903,6 +2909,24 @@ def build_repo_ir(
2903
2909
  # Spring Data
2904
2910
  '@Query', '@NamedQuery',
2905
2911
  )
2912
+ # Pre-pass: collect custom meta-annotation names from @interface definitions
2913
+ # that compose known Spring stereotypes (e.g. @DomainService = @Service + @Transactional).
2914
+ # These names must be added to the marker set so classes using them aren't
2915
+ # filtered out by the fast pre-scan below.
2916
+ _custom_meta_markers: set[str] = set()
2917
+ for _rp in sorted(file_paths):
2918
+ try:
2919
+ _src = (root / _rp).read_text(encoding="utf-8", errors="replace")
2920
+ except OSError:
2921
+ continue
2922
+ if "@interface" not in _src:
2923
+ continue
2924
+ if not any(m in _src for m in _ANNOTATION_MARKERS):
2925
+ continue
2926
+ for _m in re.finditer(r'@interface\s+(\w+)', _src):
2927
+ _custom_meta_markers.add(f"@{_m.group(1)}")
2928
+ _effective_markers = _ANNOTATION_MARKERS + tuple(_custom_meta_markers)
2929
+
2906
2930
  _per_file: list[tuple[str, str, str, list[str], list[SymbolRecord]]] = []
2907
2931
  for rel_path in sorted(file_paths):
2908
2932
  abs_path = root / rel_path
@@ -2915,7 +2939,7 @@ def build_repo_ir(
2915
2939
  _meta_chars_read += len(source)
2916
2940
  # Fast pre-scan: if file has no relevant annotations skip full extraction.
2917
2941
  # Still register package/class name for same-package resolution.
2918
- if not any(marker in source for marker in _ANNOTATION_MARKERS):
2942
+ if not any(marker in source for marker in _effective_markers):
2919
2943
  pkg_m = _PKG_RE.search(source)
2920
2944
  _pkg = pkg_m.group(1) if pkg_m else ""
2921
2945
  # Minimal class-name symbols for same-package map (no methods/fields)
@@ -3384,6 +3408,31 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
3384
3408
  # No independent re-extraction here — _route_security_from_sym is the single
3385
3409
  # source of truth for security policy extraction.
3386
3410
 
3411
+ # Detect interface-based Spring MVC controllers (implements Controller).
3412
+ # These predate annotation-style and have URL mapping in XML, not annotations.
3413
+ # We emit a synthetic "(xml-mapped)" entry so they appear in the endpoint surface.
3414
+ _SPRING_CONTROLLER_IFACE = "org.springframework.web.servlet.mvc.Controller"
3415
+ _annotated_classes = {
3416
+ route.get("effective_class", "").split(".")[-1]
3417
+ for route in routes
3418
+ }
3419
+ for sym in all_symbols:
3420
+ if sym.type != "class":
3421
+ continue
3422
+ if _SPRING_CONTROLLER_IFACE not in sym.imports_used:
3423
+ continue
3424
+ cls_simple = sym.symbol.split(".")[-1]
3425
+ if cls_simple in _annotated_classes:
3426
+ continue
3427
+ routes.append({
3428
+ "symbol": f"{sym.symbol}#handleRequest",
3429
+ "effective_class": sym.symbol,
3430
+ "method": "ANY",
3431
+ "path": "(xml-mapped)",
3432
+ "security_annotations": None,
3433
+ "note": "interface-based Spring MVC controller — URL mapped via XML",
3434
+ })
3435
+
3387
3436
  endpoints: list[dict] = []
3388
3437
  for route in routes:
3389
3438
  handler = (
@@ -3970,7 +4019,9 @@ def compute_blast_radius(
3970
4019
  )
3971
4020
  risk_score = round(min(raw_score, 100.0), 2)
3972
4021
 
3973
- if risk_score >= 20 or n_ep >= 3 or n_txn >= 2:
4022
+ if risk_score >= 30 or (n_ep >= 5 and n_txn >= 2):
4023
+ risk_level = "critical"
4024
+ elif risk_score >= 20 or n_ep >= 3 or n_txn >= 2:
3974
4025
  risk_level = "high"
3975
4026
  elif risk_score >= 5 or n_ep >= 1 or n_txn >= 1:
3976
4027
  risk_level = "medium"
@@ -4232,6 +4283,16 @@ def _resolve_target(
4232
4283
  return "not_found", set()
4233
4284
 
4234
4285
 
4286
+ _BLAST_SKIP_EDGE_TYPES: frozenset[str] = frozenset({"contained_in", "imports"})
4287
+ # 'contained_in': structural membership (method→enclosing class), not a caller.
4288
+ # 'imports': Java import statements — any class that references the type in
4289
+ # an import declaration, including those that only use it as a
4290
+ # method-return type or catch-block type. Import presence does NOT
4291
+ # imply a runtime dependency; including it produces false-positive
4292
+ # callers (e.g. sibling service classes that share a utility interface).
4293
+ # Consistent with spring_impact._SKIP_EDGE_TYPES.
4294
+
4295
+
4235
4296
  def _all_callers_from_rg(fqn: str, reverse_graph: dict[str, dict[str, list[str]]]) -> list[str]:
4236
4297
  """Return all callers of fqn from the reverse graph (all edge types).
4237
4298
 
@@ -4242,13 +4303,17 @@ def _all_callers_from_rg(fqn: str, reverse_graph: dict[str, dict[str, list[str]]
4242
4303
  CH-002 fix: for 'injects' edges, normalize field/constructor FQNs to their
4243
4304
  enclosing class. e.g. pkg.ConsolidacionService.calcularField → pkg.ConsolidacionService
4244
4305
  so BFS can continue through DI injection chains and find controllers.
4306
+
4307
+ FP-001 fix: skip 'imports' edges — import declarations are not runtime
4308
+ dependencies and produce false-positive callers (e.g. sibling classes that share
4309
+ a utility interface but don't call the target).
4245
4310
  """
4246
4311
  entry = reverse_graph.get(fqn) or {}
4247
4312
  callers: list[str] = []
4248
4313
  seen: set[str] = set()
4249
4314
  for edge_type, fqn_list in entry.items():
4250
- if edge_type == "contained_in":
4251
- continue # structural membership, not a caller
4315
+ if edge_type in _BLAST_SKIP_EDGE_TYPES:
4316
+ continue
4252
4317
  for c in fqn_list:
4253
4318
  normalized = _normalize_owner_fqn(c) if edge_type == "injects" else c
4254
4319
  if normalized not in seen:
@@ -465,9 +465,9 @@ def _compute_risk(
465
465
  caller_score = min(direct_callers * 2.0 + indirect_callers * 0.5, 20.0)
466
466
  total = finding_score + endpoint_score + caller_score
467
467
 
468
- if total >= 20.0:
468
+ if total >= 25.0:
469
469
  level = "critical"
470
- elif total >= 10.0:
470
+ elif total >= 12.0:
471
471
  level = "high"
472
472
  elif total >= 4.0:
473
473
  level = "medium"
@@ -570,6 +570,9 @@ class ImpactOrchestrator:
570
570
  # When the queried class is an interface, BFS should also start from
571
571
  # implementation symbols so TX boundaries and callers on the impl are found.
572
572
  impl_graph = getattr(cir, "implementation_graph", None)
573
+ # Track original seed classes BEFORE CH-001a expansion so CH-001b does not
574
+ # cascade through interfaces shared by impl classes added here (false positives).
575
+ original_seed_classes: set[str] = {_class_of(s) for s in seed_fqns}
573
576
  if impl_graph is not None:
574
577
  seed_classes_ch001 = {_class_of(s) for s in seed_fqns}
575
578
  impl_seeds: list[str] = []
@@ -598,11 +601,14 @@ class ImpactOrchestrator:
598
601
  # Callers typically inject the interface type, so reverse-graph edges live on
599
602
  # the interface node, not on the implementation node. Without this expansion,
600
603
  # querying 'OrderServiceImpl' finds 0 callers even though 36 classes inject it.
604
+ # IMPORTANT: only expand ORIGINAL user-query seeds, not classes added by CH-001a.
605
+ # Expanding CH-001a-added impls cascades through shared utility interfaces
606
+ # (e.g. RefByUuid) and produces false-positive callers from sibling implementors.
601
607
  if impl_graph is not None:
602
608
  current_seed_classes = {_class_of(s) for s in seed_fqns}
603
609
  iface_seeds: list[str] = []
604
610
  iface_classes_added: set[str] = set()
605
- for seed_class in sorted(current_seed_classes):
611
+ for seed_class in sorted(original_seed_classes):
606
612
  ifaces = impl_graph.interfaces_of(seed_class)
607
613
  for iface_class in ifaces:
608
614
  if iface_class in iface_classes_added or iface_class in current_seed_classes:
@@ -45,6 +45,9 @@ _BEAN_ANNOTATIONS: frozenset[str] = frozenset({
45
45
  "@Entity", "@MappedSuperclass", "@Embeddable",
46
46
  })
47
47
 
48
+ # JPA stereotypes that are NOT Spring IoC beans — present in any JPA project (Quarkus, JEE, etc.)
49
+ _JPA_ONLY_STEREOTYPES: frozenset[str] = frozenset({"entity", "mappedsuperclass", "embeddable"})
50
+
48
51
  _GENERIC_PARAM_RE = re.compile(r"<[A-Z][\w,\s<>?]*>")
49
52
 
50
53
 
@@ -153,18 +156,45 @@ class BeanGraph:
153
156
  raw_ir = getattr(cir, "_raw_ir", {}) or {}
154
157
  nodes = (raw_ir.get("graph") or {}).get("nodes") or []
155
158
 
159
+ # Pass 1: build meta-bean-annotation map from annotation-type nodes.
160
+ # e.g. @DomainService (annotated with @Service) maps "@DomainService" → "service"
161
+ _meta_bean_stereotype: dict[str, str] = {}
156
162
  for node in nodes:
157
163
  if not isinstance(node, dict):
158
164
  continue
165
+ if (node.get("symbol_kind") or node.get("type") or "") != "annotation":
166
+ continue
167
+ _ann_set = set(node.get("annotations") or [])
168
+ _match = _ann_set & _BEAN_ANNOTATIONS
169
+ if not _match:
170
+ continue
171
+ _fqn = node.get("fqn") or ""
172
+ if not _fqn:
173
+ continue
174
+ _simple = "@" + _fqn.split(".")[-1]
175
+ _bean_ann = next(iter(_match))
176
+ _meta_bean_stereotype[_simple] = _bean_ann.lstrip("@").lower()
177
+
178
+ # Pass 2: collect all bean nodes (direct or via meta-annotation).
179
+ for node in nodes:
180
+ if not isinstance(node, dict):
181
+ continue
182
+ if (node.get("symbol_kind") or node.get("type") or "") == "annotation":
183
+ continue # annotation-type nodes are not beans
159
184
  ann_set = set(node.get("annotations") or [])
160
185
  match = ann_set & _BEAN_ANNOTATIONS
161
186
  if not match:
162
- continue
187
+ meta_match = ann_set & set(_meta_bean_stereotype)
188
+ if not meta_match:
189
+ continue
190
+ ann = next(iter(meta_match))
191
+ stereotype = _meta_bean_stereotype[ann]
192
+ else:
193
+ ann = next(iter(match))
194
+ stereotype = ann.lstrip("@").lower()
163
195
  fqn = node.get("fqn") or ""
164
196
  if not fqn:
165
197
  continue
166
- ann = next(iter(match))
167
- stereotype = ann.lstrip("@").lower()
168
198
  beans[fqn] = BeanNode(
169
199
  fqn=fqn,
170
200
  stereotype=stereotype,
@@ -186,6 +216,10 @@ class BeanGraph:
186
216
  def is_bean(self, fqn: str) -> bool:
187
217
  return fqn in self.beans
188
218
 
219
+ def has_spring_beans(self) -> bool:
220
+ """True only if Spring IoC beans exist — JPA entities do not count."""
221
+ return any(b.stereotype not in _JPA_ONLY_STEREOTYPES for b in self.beans.values())
222
+
189
223
  def get_stereotype(self, fqn: str) -> str:
190
224
  node = self.beans.get(fqn)
191
225
  return node.stereotype if node else ""
@@ -21,7 +21,7 @@ from sourcecode.spring_findings import (
21
21
  SEVERITY_ORDER,
22
22
  deduplicate_findings,
23
23
  )
24
- from sourcecode.spring_model import InheritanceGraph, SpringSemanticModel
24
+ from sourcecode.spring_model import BeanGraph, InheritanceGraph, SpringSemanticModel
25
25
  from sourcecode.spring_semantic import TransactionBoundaryIndex, build_tx_index
26
26
 
27
27
  if TYPE_CHECKING:
@@ -472,11 +472,8 @@ def run_security_audit(
472
472
 
473
473
  elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
474
474
 
475
- _spring_detected = (
476
- (model is not None and bool(model.bean_graph.beans))
477
- or tx_index.stats()["total"] > 0
478
- or cir.metadata.get("security_model", "unknown") != "unknown"
479
- )
475
+ _bean_graph = model.bean_graph if model is not None else BeanGraph.build(cir)
476
+ _spring_detected = _bean_graph.has_spring_beans() or tx_index.stats()["total"] > 0
480
477
 
481
478
  _sec_limitations = [
482
479
  "SEC-001: only emitted for annotation_based security model",
@@ -293,26 +293,53 @@ def build_tx_index(cir: "CanonicalRepositoryIR") -> TransactionBoundaryIndex:
293
293
  graph = raw_ir.get("graph") or {}
294
294
  nodes = graph.get("nodes") or []
295
295
 
296
+ # Pass 1: build meta-@Transactional map from annotation-type nodes.
297
+ # e.g. @ReadOnlyTransaction (annotated with @Transactional(readOnly=true))
298
+ # maps "@ReadOnlyTransaction" → "readOnly = true"
299
+ _meta_tx_args: dict[str, str] = {}
296
300
  for node in nodes:
297
301
  if not isinstance(node, dict):
298
302
  continue
303
+ if (node.get("symbol_kind") or node.get("type") or "") != "annotation":
304
+ continue
305
+ ann_set = set(node.get("annotations") or [])
306
+ if "@Transactional" not in ann_set:
307
+ continue
308
+ _fqn = node.get("fqn") or node.get("symbol") or ""
309
+ if not _fqn:
310
+ continue
311
+ _simple = "@" + _fqn.split(".")[-1]
312
+ _av = node.get("annotation_values") or {}
313
+ _meta_tx_args[_simple] = _av.get("@Transactional", "")
299
314
 
300
- annotations = node.get("annotations") or []
301
- if "@Transactional" not in annotations:
315
+ # Pass 2: index all @Transactional boundaries (direct or via meta-annotation).
316
+ for node in nodes:
317
+ if not isinstance(node, dict):
302
318
  continue
303
319
 
320
+ symbol_kind = node.get("symbol_kind") or node.get("type") or ""
321
+ if symbol_kind == "annotation":
322
+ continue # annotation-type nodes are not TX boundaries
323
+
324
+ annotations = node.get("annotations") or []
325
+ ann_values = node.get("annotation_values") or {}
326
+
327
+ if "@Transactional" in annotations:
328
+ raw_args = ann_values.get("@Transactional", "")
329
+ else:
330
+ # Check meta-annotations (composed @Transactional)
331
+ meta_key = next((a for a in annotations if a in _meta_tx_args), None)
332
+ if meta_key is None:
333
+ continue
334
+ raw_args = _meta_tx_args[meta_key]
335
+
304
336
  fqn = node.get("fqn") or node.get("symbol") or ""
305
337
  if not fqn:
306
338
  continue
307
339
 
308
- symbol_kind = node.get("symbol_kind") or node.get("type") or ""
309
340
  source_file = node.get("source_file") or node.get("declaring_file") or ""
310
341
  modifiers = node.get("modifiers") or []
311
342
 
312
- # annotation_values is stored per-symbol in the graph node
313
- ann_values = node.get("annotation_values") or {}
314
- raw_args = ann_values.get("@Transactional", "")
315
-
316
343
  # Determine scope: class-level or method-level
317
344
  if symbol_kind in ("class", "interface", "enum"):
318
345
  scope = "class"
@@ -722,7 +722,7 @@ def run_tx_audit(
722
722
 
723
723
  elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
724
724
 
725
- _spring_detected = tx_index.stats()["total"] > 0 or bool(model.bean_graph.beans)
725
+ _spring_detected = tx_index.stats()["total"] > 0 or model.bean_graph.has_spring_beans()
726
726
 
727
727
  _tx_limitations = [
728
728
  "Self-invocation via this.method() not detected — requires AST-level analysis",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.31
3
+ Version: 1.35.33
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.35.31-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.35.33-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.35.31
117
+ # sourcecode 1.35.33
118
118
 
119
119
  **v1.35.28** — 7 bug fixes: `rename-class` cross-package disambiguation (BUG-4), `rename-class` collision detection (BUG-2), `find_java_files` false positive on `com/test/` package paths (BUG-1), `cold-start --compact` correct key names (BUG-6), `@EnableMethodSecurity` no longer suppresses SEC-001 (BUG-3), `explain` @Entity stereotype detection (BUG-5), XML+annotation mixed security retagging (BUG-7).
120
120
  ```
@@ -1,13 +1,13 @@
1
- sourcecode/__init__.py,sha256=vn3X_Wwf3J1rXhz7g_ftTtHDMu17T3htGxxaeDAJqew,104
1
+ sourcecode/__init__.py,sha256=iEPW1VKpIapMFx1-ORA_vdq1Ke_GeAEHxf6haX6UZ80,104
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
- sourcecode/architecture_analyzer.py,sha256=qh749a7ykPtGmQI1MR9y6j8TtL_jBdVYFx9YRsLqOMw,44121
3
+ sourcecode/architecture_analyzer.py,sha256=liCwQmLgb5vplohy8arjYxs_HOIv5C9MjLh_gY6bc5Q,44115
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
5
5
  sourcecode/ast_extractor.py,sha256=sa6CmLpn-k5G3_Hzxn8hAlZ5-TS-EVzXDD0Gvxd2jzs,50613
6
6
  sourcecode/cache.py,sha256=wAyPrXN5DqiGivnMpeEuun2xHDKfBer2_oBsh6kj_vc,30447
7
7
  sourcecode/canonical_ir.py,sha256=c_lYTVoegg-1W2dZ34_2s3tN8L0GVx7eiDRh9ghdSD8,24178
8
8
  sourcecode/cir_graphs.py,sha256=rZi8JV4ZrAa2WSCeyNa4JIEKQ_yZzDZTsrvVz2KfuKA,8919
9
- sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
10
- sourcecode/cli.py,sha256=pcblBewwYo8t8VIwc7naWeHT33khsLARQxc8O9ZYX_U,250086
9
+ sourcecode/classifier.py,sha256=hKzg-nQ47htqqIUzSGvYxv15cXrA3KgICTwJmdqal0o,8095
10
+ sourcecode/cli.py,sha256=3rbloccTkwexz8RdKpvMMBSSru27F0091unER2jZVDc,250580
11
11
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
12
12
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
13
13
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
@@ -15,13 +15,13 @@ sourcecode/context_summarizer.py,sha256=zlbm8ytdvJToohU108-dwBmEl52xl0gXpf6PZBOW
15
15
  sourcecode/contract_model.py,sha256=nRxJKPMs1VHwFTa8AVXhGmaLjti3Lr2sjHDpWgv1bfE,3917
16
16
  sourcecode/contract_pipeline.py,sha256=gvTdDniedm_mjq4vaHqnBY2UkQ0s00gtXqzTLILNXHc,28719
17
17
  sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo,19702
18
- sourcecode/dependency_analyzer.py,sha256=gvFJf9gHyUGRia3tdPz8s0aX2Re6aohMhb40uFEbjp0,60420
18
+ sourcecode/dependency_analyzer.py,sha256=qEnRiKFkleZJyLf_DyznJbWD1GJ881iG4RRDqH9oGQ4,61524
19
19
  sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24411
20
20
  sourcecode/entrypoint_classifier.py,sha256=jhTYlyqDJH2AtdEcLVaRU3lYRTJuF8DkxVzl4-W3zWE,5322
21
21
  sourcecode/env_analyzer.py,sha256=aNTyYgQk5noJDfJU6FmasmESOHfiomyJw5EvZqjy6qc,22213
22
22
  sourcecode/error_schema.py,sha256=uwosfNaSujtYm11_732Hu92z5ITV040fQDaIyefSvR4,1683
23
23
  sourcecode/explain.py,sha256=N5189hO8Ydbunr431zWDpSueSTdgBZh9l2xU-fH-AO8,16832
24
- sourcecode/file_chunker.py,sha256=xceHnlEg6SlSJAO5Iv2-bXICPjN8qvjQJ2CLYrHuq0o,14744
24
+ sourcecode/file_chunker.py,sha256=3vkM3mDQ5eE_yTPvUgjyjpGFBIjkW6_mrBmIbrylnA8,16444
25
25
  sourcecode/file_classifier.py,sha256=A0fEABqtfVu1MfoaxnPAvGpZgneGgVXlJDhT74NYXxE,15314
26
26
  sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,28781
27
27
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
@@ -30,9 +30,9 @@ sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,
30
30
  sourcecode/license.py,sha256=3JCV2OeTVttKrOGBguU5uZC0c02Stig-KLB0mP2lNiY,22742
31
31
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
32
32
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
33
- sourcecode/migrate_check.py,sha256=GuYK36DDFkwf07jbAgcoc-Ovq8ttLQNMsRqhsUilMzY,54514
33
+ sourcecode/migrate_check.py,sha256=vowVIAxVaHU8vhZUEt-HrWrWM38m6a5INHJQGjEg5E0,55390
34
34
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
35
- sourcecode/path_filters.py,sha256=ROFRQ8eSLBEMiixK9f45-RO7um4VEEcjoD5AA4I427I,3739
35
+ sourcecode/path_filters.py,sha256=EN1RGZRvLq5EcPgpjYV_IyCKVlAQQn2bbpEisQ5LpGg,3780
36
36
  sourcecode/pr_comment_renderer.py,sha256=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrfjz2o,14423
37
37
  sourcecode/pr_impact.py,sha256=dCDVw83EDbyVf6F9ZmEQmsFz8ruVH7d4mpeKQCIZHM0,16805
38
38
  sourcecode/prepare_context.py,sha256=nvBs5dCzTQMsm5jNEuXp7JHE5l9YIgUmv__pDWbLYgQ,222289
@@ -42,7 +42,7 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
42
42
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
43
43
  sourcecode/rename_refactor.py,sha256=rWCsXoDxJNdsmkUXjPtHphT5CjYOgEPmcc817_8Gu-Y,12538
44
44
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
45
- sourcecode/repository_ir.py,sha256=XbdoDXWRiDepcX14SVSPVcCUIeBYrGBaNAI1XL6GULw,180151
45
+ sourcecode/repository_ir.py,sha256=2urePvYhsOWtbe-RnT8kASTsSvlR3xqztnpPBU4tFdY,183359
46
46
  sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
47
47
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
48
48
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
@@ -51,11 +51,11 @@ sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIx
51
51
  sourcecode/serializer.py,sha256=GR1RcgY2abmanTIHiYzRXuOnhB2MidBVAiETat1w5cU,124724
52
52
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
53
53
  sourcecode/spring_findings.py,sha256=8V91iHOg9hFgg6tLLl4FSsgrF-dBqOcO2s-K5sD_goA,5417
54
- sourcecode/spring_impact.py,sha256=Ohm2k3W4Wts8Kx8Z7DIM-J-cwGtTJBWKFBsX-WkupBQ,32943
55
- sourcecode/spring_model.py,sha256=6Lk3rGGFy2suq867S8Da_aCNAXtSGJ36XBaQd9VNTFc,14888
56
- sourcecode/spring_security_audit.py,sha256=AmUkqoExkNZ3YxxZf9TwkwX-f7P_SETm0QC7VqEAqh4,20618
57
- sourcecode/spring_semantic.py,sha256=CiAf77p48-RFrUF0zbgww4w2Xigrbo1t5M3ZCDIfV_g,12032
58
- sourcecode/spring_tx_analyzer.py,sha256=DgauE1gUIuLorbNxMdSwIlXJxK_wTHgc0pDV0HyxWPY,30710
54
+ sourcecode/spring_impact.py,sha256=QMsRiuIW76FrxtFjV6iJRh0uHl07vE0rQ3x9fbFLLZw,33456
55
+ sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
56
+ sourcecode/spring_security_audit.py,sha256=XtPJ1SXlZJ8k6VYmaWuAp7Bbir4UmreAL7doIGQ5I7o,20595
57
+ sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM,13307
58
+ sourcecode/spring_tx_analyzer.py,sha256=FdFcyqPp3aT9oJ-PKrnXcTA6s69wdvzG-NBm0GMGPTU,30717
59
59
  sourcecode/summarizer.py,sha256=zgdps7yS2IktAbWe7IWz0oUcr3QIuNPRGrsScbZ4R1g,21797
60
60
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
61
61
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
@@ -96,8 +96,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
96
96
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
97
97
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
98
98
  sourcecode/telemetry/transport.py,sha256=QSslxIwij8YkRWcVvxykODDrkiN_GAAEu3dUP7KIWeE,1651
99
- sourcecode-1.35.31.dist-info/METADATA,sha256=2WLlgdeZf7H8YoM5q7jesekUHYEM5r1XwP0LLUVZREg,21705
100
- sourcecode-1.35.31.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
- sourcecode-1.35.31.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
- sourcecode-1.35.31.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
- sourcecode-1.35.31.dist-info/RECORD,,
99
+ sourcecode-1.35.33.dist-info/METADATA,sha256=t2-bN_WFdMDu1-wae-AEc3kfo13peCzTHER1jdvp7IE,21705
100
+ sourcecode-1.35.33.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
+ sourcecode-1.35.33.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
+ sourcecode-1.35.33.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
+ sourcecode-1.35.33.dist-info/RECORD,,