sourcecode 1.35.32__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.32"
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",
@@ -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
 
@@ -3408,6 +3408,31 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
3408
3408
  # No independent re-extraction here — _route_security_from_sym is the single
3409
3409
  # source of truth for security policy extraction.
3410
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
+
3411
3436
  endpoints: list[dict] = []
3412
3437
  for route in routes:
3413
3438
  handler = (
@@ -3994,7 +4019,9 @@ def compute_blast_radius(
3994
4019
  )
3995
4020
  risk_score = round(min(raw_score, 100.0), 2)
3996
4021
 
3997
- 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:
3998
4025
  risk_level = "high"
3999
4026
  elif risk_score >= 5 or n_ep >= 1 or n_txn >= 1:
4000
4027
  risk_level = "medium"
@@ -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"
@@ -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
 
@@ -213,6 +216,10 @@ class BeanGraph:
213
216
  def is_bean(self, fqn: str) -> bool:
214
217
  return fqn in self.beans
215
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
+
216
223
  def get_stereotype(self, fqn: str) -> str:
217
224
  node = self.beans.get(fqn)
218
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",
@@ -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.32
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.32-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.32
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=ODn86wcZpbd0bfIC-gO0QCjwYQ2DntE3iTlUzYkn9Eo,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,7 +15,7 @@ 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
@@ -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=r-6LtwR7FYfXU5GEKg7BZfwJMtHBBnnaCiCkJMX04u8,182227
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=9pNteDyV9kEfWiPlS3nngrQwJ5-cOk7eI0Qta5U_80Y,33456
55
- sourcecode/spring_model.py,sha256=oFpRgRZHXykptZxiN9-jsm4TNKT9ltlSWKNFmJ6tUf0,16184
56
- sourcecode/spring_security_audit.py,sha256=AmUkqoExkNZ3YxxZf9TwkwX-f7P_SETm0QC7VqEAqh4,20618
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
57
  sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM,13307
58
- sourcecode/spring_tx_analyzer.py,sha256=DgauE1gUIuLorbNxMdSwIlXJxK_wTHgc0pDV0HyxWPY,30710
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.32.dist-info/METADATA,sha256=JHM-I0fCoFifX8sZKT7ONpl_WvjLnH7btFCd-YXP7lM,21705
100
- sourcecode-1.35.32.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
101
- sourcecode-1.35.32.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
102
- sourcecode-1.35.32.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
103
- sourcecode-1.35.32.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,,