sourcecode 1.71.0__py3-none-any.whl → 1.72.0__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 +1 -1
- sourcecode/canonical_ir.py +14 -0
- sourcecode/cli.py +77 -13
- sourcecode/explain.py +7 -2
- sourcecode/integration_detector.py +11 -0
- sourcecode/migrate_check.py +50 -1
- sourcecode/repository_ir.py +311 -10
- sourcecode/spring_security_audit.py +11 -0
- sourcecode/spring_tx_analyzer.py +70 -9
- {sourcecode-1.71.0.dist-info → sourcecode-1.72.0.dist-info}/METADATA +1 -1
- {sourcecode-1.71.0.dist-info → sourcecode-1.72.0.dist-info}/RECORD +14 -14
- {sourcecode-1.71.0.dist-info → sourcecode-1.72.0.dist-info}/WHEEL +0 -0
- {sourcecode-1.71.0.dist-info → sourcecode-1.72.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-1.71.0.dist-info → sourcecode-1.72.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/canonical_ir.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import hashlib
|
|
18
18
|
import json
|
|
19
|
+
import re
|
|
19
20
|
from dataclasses import dataclass, field
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from typing import Any, Optional
|
|
@@ -37,6 +38,11 @@ IR_SCHEMA_VERSION = "1.0.0"
|
|
|
37
38
|
# Edge types excluded from reverse graph (mirrors repository_ir._REVERSE_EXCLUDE)
|
|
38
39
|
_REVERSE_EXCLUDE: frozenset[str] = frozenset({"annotated_with", "mapped_to"})
|
|
39
40
|
|
|
41
|
+
# A route path that looks like a Java FQN is framework dynamic-admin routing, not a
|
|
42
|
+
# real REST endpoint (mirrors the filter in repository_ir.extract_java_endpoints so
|
|
43
|
+
# the CIR endpoint count reconciles with the `endpoints` command). See BUG #7.
|
|
44
|
+
_FQN_PATH_RE: re.Pattern = re.compile(r"/(org|com|net|io|edu)\.[a-z][a-z0-9]*\.[a-zA-Z]")
|
|
45
|
+
|
|
40
46
|
|
|
41
47
|
# ---------------------------------------------------------------------------
|
|
42
48
|
# CanonicalSecurity
|
|
@@ -324,9 +330,17 @@ def ir_dict_to_canonical(
|
|
|
324
330
|
# Build canonical endpoints from route_surface — stable ordering
|
|
325
331
|
route_surface = ir.get("route_surface") or []
|
|
326
332
|
# Deduplicate by endpoint id (route_surface can have duplicates from multi-prefix)
|
|
333
|
+
# BUG #7 (Broadleaf field test): the `endpoints` command (extract_java_endpoints)
|
|
334
|
+
# drops routes whose path looks like a Java FQN — framework dynamic-admin routing
|
|
335
|
+
# (Broadleaf @AdminSection registers entity class FQNs as URL segments) is not a
|
|
336
|
+
# real REST surface. The CIR endpoint list feeds `spring-audit` (endpoints_analyzed),
|
|
337
|
+
# `impact` and `validation`; it must apply the SAME filter so its count reconciles
|
|
338
|
+
# with `endpoints` instead of being silently inflated by those pseudo-routes.
|
|
327
339
|
_seen_ids: set[str] = set()
|
|
328
340
|
raw_endpoints: list[CanonicalEndpoint] = []
|
|
329
341
|
for r in route_surface:
|
|
342
|
+
if _FQN_PATH_RE.search(r.get("path", "") or ""):
|
|
343
|
+
continue
|
|
330
344
|
ep = _route_to_canonical_endpoint(r)
|
|
331
345
|
if ep.id not in _seen_ids:
|
|
332
346
|
_seen_ids.add(ep.id)
|
sourcecode/cli.py
CHANGED
|
@@ -5764,11 +5764,63 @@ def modernize_cmd(
|
|
|
5764
5764
|
key=lambda h: (-h["hotspot_score"], h["fqn"]),
|
|
5765
5765
|
)[:15]
|
|
5766
5766
|
|
|
5767
|
-
# Cross-module tangles:
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
)
|
|
5767
|
+
# Cross-module tangles: actual inter-subsystem coupling measured from the
|
|
5768
|
+
# dependency graph, NOT a re-labelled subsystem list. For every structural
|
|
5769
|
+
# edge whose endpoints live in two different subsystems we tally a directed
|
|
5770
|
+
# (from_subsystem → to_subsystem) coupling count; pairs coupled in BOTH
|
|
5771
|
+
# directions are the real tangles (bidirectional/cyclic module coupling).
|
|
5772
|
+
_graph_edges: list = (ir.get("graph") or {}).get("edges") or []
|
|
5773
|
+
# Structural coupling edge types. annotated_with / mapped_to / event edges are
|
|
5774
|
+
# excluded (metadata + separate topology, not module-decomposition coupling).
|
|
5775
|
+
_TANGLE_EDGE_TYPES = frozenset({
|
|
5776
|
+
"imports", "injects", "extends", "implements",
|
|
5777
|
+
"references", "calls", "instantiates", "returns",
|
|
5778
|
+
})
|
|
5779
|
+
_fqn_to_pkg: dict[str, str] = {}
|
|
5780
|
+
_pkg_to_label: dict[str, str] = {}
|
|
5781
|
+
for _s in subsystems:
|
|
5782
|
+
_pp = _s.get("package_prefix") or _s.get("pkg") or ""
|
|
5783
|
+
if not _pp:
|
|
5784
|
+
continue
|
|
5785
|
+
_pkg_to_label[_pp] = _s.get("label") or _s.get("name") or _pp
|
|
5786
|
+
for _m in (_s.get("members") or []):
|
|
5787
|
+
_fqn_to_pkg[_m] = _pp
|
|
5788
|
+
|
|
5789
|
+
from collections import Counter as _Counter
|
|
5790
|
+
_pair_counts: "_Counter[tuple[str, str]]" = _Counter()
|
|
5791
|
+
_pair_example: dict[tuple[str, str], str] = {}
|
|
5792
|
+
for _e in _graph_edges:
|
|
5793
|
+
if _e.get("type") not in _TANGLE_EDGE_TYPES:
|
|
5794
|
+
continue
|
|
5795
|
+
_a = _fqn_to_pkg.get(_e.get("from"))
|
|
5796
|
+
_b = _fqn_to_pkg.get(_e.get("to"))
|
|
5797
|
+
if not _a or not _b or _a == _b:
|
|
5798
|
+
continue
|
|
5799
|
+
_pair_counts[(_a, _b)] += 1
|
|
5800
|
+
_pair_example.setdefault((_a, _b), f"{_e.get('from')} → {_e.get('to')}")
|
|
5801
|
+
|
|
5802
|
+
_cross_module_tangles = []
|
|
5803
|
+
for (_a, _b), _cnt in sorted(
|
|
5804
|
+
_pair_counts.items(), key=lambda kv: (-kv[1], kv[0])
|
|
5805
|
+
):
|
|
5806
|
+
_reverse = _pair_counts.get((_b, _a), 0)
|
|
5807
|
+
_cross_module_tangles.append({
|
|
5808
|
+
"from_subsystem": _pkg_to_label.get(_a, _a),
|
|
5809
|
+
"to_subsystem": _pkg_to_label.get(_b, _b),
|
|
5810
|
+
"from_package": _a,
|
|
5811
|
+
"to_package": _b,
|
|
5812
|
+
"edge_count": _cnt,
|
|
5813
|
+
"reverse_edge_count": _reverse,
|
|
5814
|
+
# A mutual (cyclic) dependency is the actual "tangle" — both modules
|
|
5815
|
+
# reference each other, so neither can be extracted independently.
|
|
5816
|
+
"mutual": _reverse > 0,
|
|
5817
|
+
"example": _pair_example.get((_a, _b), ""),
|
|
5818
|
+
})
|
|
5819
|
+
# Surface mutual tangles first (highest decomposition risk), then by strength.
|
|
5820
|
+
_cross_module_tangles.sort(
|
|
5821
|
+
key=lambda t: (not t["mutual"], -t["edge_count"], t["from_package"])
|
|
5822
|
+
)
|
|
5823
|
+
_cross_module_tangles = _cross_module_tangles[:15]
|
|
5772
5824
|
|
|
5773
5825
|
_summary = {
|
|
5774
5826
|
"total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
|
|
@@ -5837,6 +5889,17 @@ def modernize_cmd(
|
|
|
5837
5889
|
{"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
|
|
5838
5890
|
for n in coupling_nodes
|
|
5839
5891
|
],
|
|
5892
|
+
# BUG #6 (Broadleaf field test): make the in_degree metric self-describing
|
|
5893
|
+
# so it is not confused with `explain`'s caller count. They differ by design.
|
|
5894
|
+
"high_coupling_nodes_note": (
|
|
5895
|
+
"in_degree = raw count of incoming graph edges across ALL edge types "
|
|
5896
|
+
"(imports, injects, extends/implements, references, annotations), "
|
|
5897
|
+
"counted at symbol level. This is deliberately larger than and NOT "
|
|
5898
|
+
"equal to `sourcecode explain`'s caller count, which reports DISTINCT "
|
|
5899
|
+
"dependent classes (DI dependents + reverse-call-graph callers, "
|
|
5900
|
+
"deduplicated to class level). Use in_degree for blast-radius ranking, "
|
|
5901
|
+
"explain's caller list for the concrete dependents to inspect."
|
|
5902
|
+
),
|
|
5840
5903
|
"statically_unreferenced": [
|
|
5841
5904
|
{"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
|
|
5842
5905
|
for n in dead_zones
|
|
@@ -5853,14 +5916,15 @@ def modernize_cmd(
|
|
|
5853
5916
|
],
|
|
5854
5917
|
"subsystem_summary": _subsystem_summary,
|
|
5855
5918
|
"subsystem_summary_note": _subsystem_summary_note,
|
|
5856
|
-
"cross_module_tangles":
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5919
|
+
"cross_module_tangles": _cross_module_tangles,
|
|
5920
|
+
"cross_module_tangles_note": (
|
|
5921
|
+
"Directed inter-subsystem coupling measured from structural graph "
|
|
5922
|
+
"edges (imports/injects/extends/implements/references/calls). "
|
|
5923
|
+
"edge_count = edges from_package→to_package; mutual=true marks a "
|
|
5924
|
+
"bidirectional (cyclic) dependency — the actual tangle that blocks "
|
|
5925
|
+
"independent module extraction. Empty means no cross-subsystem "
|
|
5926
|
+
"structural coupling was detected."
|
|
5927
|
+
),
|
|
5864
5928
|
# BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
|
|
5865
5929
|
"recommendation": (
|
|
5866
5930
|
(
|
sourcecode/explain.py
CHANGED
|
@@ -298,10 +298,15 @@ def _structural_purpose(
|
|
|
298
298
|
head = f"Likely {role} (no DI annotations found)" if role else \
|
|
299
299
|
"Likely a structurally significant class (no DI annotations found)"
|
|
300
300
|
signals: list[str] = []
|
|
301
|
+
# BUG #6 (Broadleaf field test): this counts DISTINCT dependent classes (DI
|
|
302
|
+
# dependents + reverse-call-graph callers, deduped to class level), which is a
|
|
303
|
+
# different metric from modernize's node.in_degree (raw incoming-edge count
|
|
304
|
+
# across ALL edge types, symbol-level). Name it explicitly so the two numbers
|
|
305
|
+
# are not read as a contradiction when cross-referenced.
|
|
301
306
|
if in_degree >= 3:
|
|
302
|
-
signals.append(f"high in
|
|
307
|
+
signals.append(f"high fan-in ({in_degree} distinct caller classes)")
|
|
303
308
|
elif in_degree:
|
|
304
|
-
signals.append(f"
|
|
309
|
+
signals.append(f"{in_degree} distinct caller class{'es' if in_degree != 1 else ''}")
|
|
305
310
|
if lifecycle:
|
|
306
311
|
signals.append(f"lifecycle methods detected ({'/'.join(lifecycle)})")
|
|
307
312
|
suffix = f" — {', '.join(signals)}" if signals else ""
|
|
@@ -83,6 +83,17 @@ _TOKEN_CLIENTS: "tuple[tuple[str, str, str], ...]" = (
|
|
|
83
83
|
("InitialLdapContext", "ldap", "jndi-ldap"),
|
|
84
84
|
("InitialDirContext", "ldap", "jndi-ldap"),
|
|
85
85
|
("LdapContext", "ldap", "jndi-ldap"),
|
|
86
|
+
# Spring Security LDAP authentication (BUG #5, Broadleaf field test): the
|
|
87
|
+
# idiomatic auth integration is a UserDetails mapper that extends
|
|
88
|
+
# LdapUserDetailsMapper and consumes DirContextOperations — neither uses
|
|
89
|
+
# LdapTemplate/ContextSource directly, so the connection-type tokens above
|
|
90
|
+
# miss it entirely. These types come from spring-security-ldap / spring-ldap
|
|
91
|
+
# and are unambiguous LDAP integration points.
|
|
92
|
+
("LdapUserDetailsMapper", "ldap", "spring-security-ldap"),
|
|
93
|
+
("DirContextOperations", "ldap", "spring-ldap"),
|
|
94
|
+
("LdapContextSource", "ldap", "spring-ldap"),
|
|
95
|
+
("DefaultSpringSecurityContextSource", "ldap", "spring-security-ldap"),
|
|
96
|
+
("BindAuthenticator", "ldap", "spring-security-ldap"),
|
|
86
97
|
# Mail / SMTP (JavaMail / Jakarta Mail)
|
|
87
98
|
("JavaMailSender", "smtp", "spring-mail"),
|
|
88
99
|
("MimeMessage", "smtp", "javamail"),
|
sourcecode/migrate_check.py
CHANGED
|
@@ -645,6 +645,18 @@ def _classify_code_context(finding: "MigrationFinding") -> str:
|
|
|
645
645
|
if is_test_or_fixture_path(path):
|
|
646
646
|
return "test"
|
|
647
647
|
norm = path.replace("\\", "/").lower()
|
|
648
|
+
# BUG #4 (Broadleaf field test): a Spring XML config file can live under
|
|
649
|
+
# src/main/resources yet be test-only scaffolding loaded exclusively by
|
|
650
|
+
# test @ContextConfiguration (e.g. bl-applicationContext-test-security.xml).
|
|
651
|
+
# is_test_or_fixture_path deliberately ignores the filename, so a `test`
|
|
652
|
+
# marker in the config file's own name is not seen. For XML config, a `test`
|
|
653
|
+
# token in the base filename is a strong, low-FP test-scope signal —
|
|
654
|
+
# production context files are not named `-test-`. Keep it out of
|
|
655
|
+
# blocking_count so test scaffolding never inflates the headline blocker.
|
|
656
|
+
if norm.endswith(".xml"):
|
|
657
|
+
base = norm.rsplit("/", 1)[-1]
|
|
658
|
+
if re.search(r"(?:^|[-_.])test(?:[-_.]|$)", base):
|
|
659
|
+
return "test"
|
|
648
660
|
if any(frag in norm for frag in _GENERATED_PATH_FRAGMENTS):
|
|
649
661
|
return "generated"
|
|
650
662
|
# javax.annotation.Generated is the marker emitted into autogenerated code.
|
|
@@ -936,6 +948,42 @@ def _find_non_java_files(
|
|
|
936
948
|
return xml_files, build_files
|
|
937
949
|
|
|
938
950
|
|
|
951
|
+
def _xml_explanation_for(rule: "_XmlRule", matches: list) -> str:
|
|
952
|
+
"""Return an explanation reflecting the sub-pattern that actually matched.
|
|
953
|
+
|
|
954
|
+
BUG #4 (Broadleaf field test): MIG-031's pattern is an OR of two independent
|
|
955
|
+
triggers — old-style `<http auto-config="true">` and a versioned legacy schema
|
|
956
|
+
(spring-security-[2-5].x.xsd). The static template asserted BOTH, so a file
|
|
957
|
+
that only used auto-config (its schema line already being the unversioned
|
|
958
|
+
spring-security.xsd) got a factually wrong explanation claiming a versioned
|
|
959
|
+
schema. Emit only the condition(s) present in this file.
|
|
960
|
+
"""
|
|
961
|
+
if rule.id != "MIG-031":
|
|
962
|
+
return rule.explanation
|
|
963
|
+
matched_text = " ".join(m.group(0).lower() for m in matches)
|
|
964
|
+
has_autoconfig = "auto-config" in matched_text
|
|
965
|
+
has_versioned_schema = bool(re.search(r"spring-security-[2345]\.", matched_text))
|
|
966
|
+
triggers: list[str] = []
|
|
967
|
+
if has_autoconfig:
|
|
968
|
+
triggers.append(
|
|
969
|
+
"uses the old-style <http auto-config='true'> shortcut, which was "
|
|
970
|
+
"removed in Spring Security 6"
|
|
971
|
+
)
|
|
972
|
+
if has_versioned_schema:
|
|
973
|
+
triggers.append(
|
|
974
|
+
"references a versioned legacy schema (spring-security-[2-5].x.xsd) "
|
|
975
|
+
"whose namespace attributes changed in Spring Security 6"
|
|
976
|
+
)
|
|
977
|
+
if not triggers: # defensive — pattern matched but neither branch classified
|
|
978
|
+
return rule.explanation
|
|
979
|
+
return (
|
|
980
|
+
"XML-based Spring Security configuration in this file "
|
|
981
|
+
+ " and ".join(triggers)
|
|
982
|
+
+ ". Spring Security 6 (Spring Boot 3) requires migrating this to "
|
|
983
|
+
"Java-based @Configuration with a SecurityFilterChain @Bean."
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
939
987
|
def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
|
940
988
|
"""Apply XML rules to raw XML text. Returns one finding per matched rule."""
|
|
941
989
|
findings: list[MigrationFinding] = []
|
|
@@ -947,6 +995,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
|
|
947
995
|
continue
|
|
948
996
|
first_line = text[: matches[0].start()].count("\n") + 1
|
|
949
997
|
snippets = [m.group(0)[:120].strip() for m in matches[:5]]
|
|
998
|
+
explanation = _xml_explanation_for(rule, matches)
|
|
950
999
|
findings.append(
|
|
951
1000
|
MigrationFinding(
|
|
952
1001
|
id=MigrationFinding.make_id(rule.id, rel_path),
|
|
@@ -956,7 +1005,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
|
|
956
1005
|
source_file=rel_path,
|
|
957
1006
|
first_line=first_line,
|
|
958
1007
|
imports_found=snippets,
|
|
959
|
-
explanation=
|
|
1008
|
+
explanation=explanation,
|
|
960
1009
|
fix_hint=rule.fix_hint,
|
|
961
1010
|
migration_target=rule.migration_target,
|
|
962
1011
|
openrewrite_recipe=rule.openrewrite_recipe,
|
sourcecode/repository_ir.py
CHANGED
|
@@ -387,6 +387,59 @@ def _strip_java_comments(source: str) -> str:
|
|
|
387
387
|
return source
|
|
388
388
|
|
|
389
389
|
|
|
390
|
+
# Declaration keyword OR a brace — matched in source order to track nesting when
|
|
391
|
+
# building minimal symbols for pre-scan-skipped files (see _minimal_class_symbols).
|
|
392
|
+
_MIN_DECL_OR_BRACE_RE = re.compile(r'\b(?:class|interface|enum)\s+([A-Z]\w*)|([{}])')
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _minimal_class_symbols(
|
|
396
|
+
source: str, package: str, rel_path: str
|
|
397
|
+
) -> "list[SymbolRecord]":
|
|
398
|
+
"""Nesting-aware class/interface/enum symbols for pre-scan-skipped files.
|
|
399
|
+
|
|
400
|
+
Tracks brace depth so a NESTED type gets a fully-qualified `pkg.Outer.Inner`
|
|
401
|
+
FQN (matching _extract_symbols), never a package-only `pkg.Inner`.
|
|
402
|
+
|
|
403
|
+
BUG #2 (Broadleaf field test): the previous fast path emitted every declared
|
|
404
|
+
type as `f"{pkg}.{name}"`, dropping the enclosing class. Two distinct nested
|
|
405
|
+
types with the same simple name under different outer classes in one package
|
|
406
|
+
(Broadleaf has 25 `GroupName` nested classes) therefore collapsed onto ONE
|
|
407
|
+
colliding FQN, and — since pre-scan-skipped files build no relations — those
|
|
408
|
+
zero-degree phantom nodes were reported as false `statically_unreferenced`
|
|
409
|
+
dead code. Qualifying by the enclosing class keeps them distinct and correct.
|
|
410
|
+
"""
|
|
411
|
+
decl_source = _STRING_LITERAL_RE.sub('', _strip_java_comments(source))
|
|
412
|
+
syms: "list[SymbolRecord]" = []
|
|
413
|
+
stack: "list[tuple[str, int]]" = [] # (fqn, brace-depth at which body opened)
|
|
414
|
+
depth = 0
|
|
415
|
+
pending_fqn: "Optional[str]" = None
|
|
416
|
+
for m in _MIN_DECL_OR_BRACE_RE.finditer(decl_source):
|
|
417
|
+
name = m.group(1)
|
|
418
|
+
if name is not None:
|
|
419
|
+
enclosing = stack[-1][0] if stack else None
|
|
420
|
+
if enclosing:
|
|
421
|
+
pending_fqn = f"{enclosing}.{name}"
|
|
422
|
+
else:
|
|
423
|
+
pending_fqn = f"{package}.{name}" if package else name
|
|
424
|
+
continue
|
|
425
|
+
brace = m.group(2)
|
|
426
|
+
if brace == '{':
|
|
427
|
+
depth += 1
|
|
428
|
+
if pending_fqn is not None:
|
|
429
|
+
stack.append((pending_fqn, depth))
|
|
430
|
+
syms.append(SymbolRecord(
|
|
431
|
+
symbol=pending_fqn, type="class", confidence="medium",
|
|
432
|
+
declaring_file=rel_path,
|
|
433
|
+
))
|
|
434
|
+
pending_fqn = None
|
|
435
|
+
else: # '}'
|
|
436
|
+
while stack and stack[-1][1] == depth:
|
|
437
|
+
stack.pop()
|
|
438
|
+
if depth > 0:
|
|
439
|
+
depth -= 1
|
|
440
|
+
return syms
|
|
441
|
+
|
|
442
|
+
|
|
390
443
|
def _parse_annotation_line(line: str) -> tuple[str, str]:
|
|
391
444
|
"""Parse annotation name and args from a line starting with '@'.
|
|
392
445
|
|
|
@@ -1127,6 +1180,234 @@ def _build_same_package_map(symbols: list[SymbolRecord]) -> dict[str, dict[str,
|
|
|
1127
1180
|
return result
|
|
1128
1181
|
|
|
1129
1182
|
|
|
1183
|
+
# A type-qualified member reference inside an annotation argument, e.g. the
|
|
1184
|
+
# `GroupName.General` / `FieldOrder.NAME` in
|
|
1185
|
+
# `@AdminPresentation(group = GroupName.General, order = FieldOrder.NAME)`, or the
|
|
1186
|
+
# fully-qualified nested form `SystemPropertyAdminPresentation.GroupOrder.General`.
|
|
1187
|
+
# group(0) is the whole dotted chain (UpperCamel head + `.member`… tail); the
|
|
1188
|
+
# resolver walks the chain through known nested types so a reference to
|
|
1189
|
+
# `Outer.Nested.CONST` credits the NESTED holder, not just the outer type.
|
|
1190
|
+
_ANN_REF_TOKEN_RE = re.compile(r'\b[A-Z]\w*(?:\.\w+)+')
|
|
1191
|
+
# Combined token scanner: an annotation-with-args head, a type declaration, or a
|
|
1192
|
+
# brace — matched in source order so annotation references can be attributed to
|
|
1193
|
+
# the class whose body encloses them.
|
|
1194
|
+
_ANN_OR_DECL_OR_BRACE_RE = re.compile(
|
|
1195
|
+
r'@([A-Z]\w*)\s*\('
|
|
1196
|
+
r'|\b(?:class|interface|enum)\s+([A-Z]\w*)'
|
|
1197
|
+
r'|([{}])'
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _extract_annotation_type_refs(
|
|
1202
|
+
source: str, package: str
|
|
1203
|
+
) -> "list[tuple[str, str]]":
|
|
1204
|
+
"""Return (enclosing_class_fqn, referenced_type_simple_name) pairs.
|
|
1205
|
+
|
|
1206
|
+
Walks the comment/string-stripped source tracking the nesting stack so each
|
|
1207
|
+
type reference found inside an annotation argument list is attributed to the
|
|
1208
|
+
class whose body encloses the annotated member. Member-level annotations sit
|
|
1209
|
+
inside the class body (stack has the class); this is the common case for
|
|
1210
|
+
`@AdminPresentation(group = GroupName.General)` on entity fields.
|
|
1211
|
+
"""
|
|
1212
|
+
text = _STRING_LITERAL_RE.sub('""', _strip_java_comments(source))
|
|
1213
|
+
refs: "list[tuple[str, str]]" = []
|
|
1214
|
+
stack: "list[tuple[str, int]]" = []
|
|
1215
|
+
depth = 0
|
|
1216
|
+
pending: "Optional[str]" = None
|
|
1217
|
+
# Type refs found in a CLASS-LEVEL annotation (which precedes the class body,
|
|
1218
|
+
# so the stack does not yet contain the owning class) are held here and
|
|
1219
|
+
# attributed to the class when its body opens — e.g. an interface annotated
|
|
1220
|
+
# `@AdminTabPresentation(name = TabName.General)` that references its OWN
|
|
1221
|
+
# nested constant holders.
|
|
1222
|
+
pending_class_refs: "list[str]" = []
|
|
1223
|
+
n = len(text)
|
|
1224
|
+
idx = 0
|
|
1225
|
+
while idx < n:
|
|
1226
|
+
m = _ANN_OR_DECL_OR_BRACE_RE.search(text, idx)
|
|
1227
|
+
if not m:
|
|
1228
|
+
break
|
|
1229
|
+
ann, cname, brace = m.group(1), m.group(2), m.group(3)
|
|
1230
|
+
if ann is not None:
|
|
1231
|
+
# Balance the annotation's parens and scan its argument text.
|
|
1232
|
+
popen = m.end() - 1
|
|
1233
|
+
d = 0
|
|
1234
|
+
j = popen
|
|
1235
|
+
while j < n:
|
|
1236
|
+
c = text[j]
|
|
1237
|
+
if c == '(':
|
|
1238
|
+
d += 1
|
|
1239
|
+
elif c == ')':
|
|
1240
|
+
d -= 1
|
|
1241
|
+
if d == 0:
|
|
1242
|
+
break
|
|
1243
|
+
j += 1
|
|
1244
|
+
arg_text = text[popen + 1:j]
|
|
1245
|
+
cur_class = stack[-1][0] if stack else ""
|
|
1246
|
+
for tm in _ANN_REF_TOKEN_RE.finditer(arg_text):
|
|
1247
|
+
if cur_class:
|
|
1248
|
+
refs.append((cur_class, tm.group(0)))
|
|
1249
|
+
else:
|
|
1250
|
+
pending_class_refs.append(tm.group(0))
|
|
1251
|
+
idx = j + 1
|
|
1252
|
+
continue
|
|
1253
|
+
if cname is not None:
|
|
1254
|
+
enc = stack[-1][0] if stack else None
|
|
1255
|
+
pending = f"{enc}.{cname}" if enc else (
|
|
1256
|
+
f"{package}.{cname}" if package else cname
|
|
1257
|
+
)
|
|
1258
|
+
idx = m.end()
|
|
1259
|
+
continue
|
|
1260
|
+
if brace == '{':
|
|
1261
|
+
depth += 1
|
|
1262
|
+
if pending is not None:
|
|
1263
|
+
stack.append((pending, depth))
|
|
1264
|
+
# A class-level annotation on this class referenced these types.
|
|
1265
|
+
for head in pending_class_refs:
|
|
1266
|
+
refs.append((pending, head))
|
|
1267
|
+
pending_class_refs = []
|
|
1268
|
+
pending = None
|
|
1269
|
+
else: # '}'
|
|
1270
|
+
while stack and stack[-1][1] == depth:
|
|
1271
|
+
stack.pop()
|
|
1272
|
+
if depth > 0:
|
|
1273
|
+
depth -= 1
|
|
1274
|
+
idx = m.end()
|
|
1275
|
+
return refs
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _annotation_reference_edges(
|
|
1279
|
+
ref_sources: "list[tuple[str, list[str], list[tuple[str, str]]]]",
|
|
1280
|
+
all_symbols: "list[SymbolRecord]",
|
|
1281
|
+
relations: "list[RelationEdge]",
|
|
1282
|
+
same_pkg_map: "dict[str, dict[str, str]]",
|
|
1283
|
+
) -> "list[RelationEdge]":
|
|
1284
|
+
"""Emit `references` edges for static constants read inside annotation args.
|
|
1285
|
+
|
|
1286
|
+
BUG #2 (Broadleaf field test): `@AdminPresentation(group = GroupName.General)`
|
|
1287
|
+
is a real reference to the constant-holder nested class `GroupName`, but the
|
|
1288
|
+
static call-graph never counted annotation-argument reads as edges. The
|
|
1289
|
+
referenced nested classes therefore had zero in-degree and were reported as
|
|
1290
|
+
false `statically_unreferenced` dead code (~95% of that list on Broadleaf).
|
|
1291
|
+
|
|
1292
|
+
Resolution for a bare head type T referenced from class C:
|
|
1293
|
+
import / same-package / wildcard → direct FQN, else
|
|
1294
|
+
nested-type lookup: a unique repo nested type named T, or — when the simple
|
|
1295
|
+
name is ambiguous (Broadleaf has 25 `GroupName`s) — the one declared by C or
|
|
1296
|
+
one of C's (transitive) supertypes. Unresolvable / ambiguous refs are
|
|
1297
|
+
dropped (honest: no guessed edge).
|
|
1298
|
+
"""
|
|
1299
|
+
class_fqns: set[str] = {
|
|
1300
|
+
s.symbol for s in all_symbols
|
|
1301
|
+
if s.type in ("class", "interface") and "#" not in s.symbol
|
|
1302
|
+
}
|
|
1303
|
+
# enclosing_of[nested_fqn] = outer_fqn; nested_by_simple[simple] = {fqns}.
|
|
1304
|
+
enclosing_of: dict[str, str] = {}
|
|
1305
|
+
nested_by_simple: dict[str, set[str]] = {}
|
|
1306
|
+
for fqn in class_fqns:
|
|
1307
|
+
prefix = fqn
|
|
1308
|
+
while True:
|
|
1309
|
+
cut = prefix.rfind(".")
|
|
1310
|
+
if cut < 0:
|
|
1311
|
+
break
|
|
1312
|
+
prefix = prefix[:cut]
|
|
1313
|
+
if prefix in class_fqns:
|
|
1314
|
+
enclosing_of[fqn] = prefix
|
|
1315
|
+
nested_by_simple.setdefault(fqn[len(prefix) + 1:], set()).add(fqn)
|
|
1316
|
+
break
|
|
1317
|
+
|
|
1318
|
+
direct_super: dict[str, set[str]] = {}
|
|
1319
|
+
for e in relations:
|
|
1320
|
+
if e.type in ("extends", "implements"):
|
|
1321
|
+
direct_super.setdefault(e.from_symbol, set()).add(e.to_symbol)
|
|
1322
|
+
|
|
1323
|
+
def _super_closure(fqn: str) -> set[str]:
|
|
1324
|
+
seen: set[str] = set()
|
|
1325
|
+
stack = [fqn]
|
|
1326
|
+
while stack:
|
|
1327
|
+
cur = stack.pop()
|
|
1328
|
+
for sup in direct_super.get(cur, ()):
|
|
1329
|
+
if sup not in seen:
|
|
1330
|
+
seen.add(sup)
|
|
1331
|
+
stack.append(sup)
|
|
1332
|
+
return seen
|
|
1333
|
+
|
|
1334
|
+
edges: list[RelationEdge] = []
|
|
1335
|
+
seen_edges: set[tuple[str, str]] = set()
|
|
1336
|
+
for package, raw_imports, ref_pairs in ref_sources:
|
|
1337
|
+
if not ref_pairs:
|
|
1338
|
+
continue
|
|
1339
|
+
import_map: dict[str, str] = {}
|
|
1340
|
+
wildcard_pkgs: list[str] = []
|
|
1341
|
+
for fq in raw_imports:
|
|
1342
|
+
parts = fq.split(".")
|
|
1343
|
+
if parts[-1] == "*":
|
|
1344
|
+
wildcard_pkgs.append(fq[:-2])
|
|
1345
|
+
else:
|
|
1346
|
+
import_map[parts[-1]] = fq
|
|
1347
|
+
pkg_types = same_pkg_map.get(package, {})
|
|
1348
|
+
|
|
1349
|
+
def _resolve_head(head: str, cur_class: str) -> "Optional[str]":
|
|
1350
|
+
fq = import_map.get(head) or pkg_types.get(head)
|
|
1351
|
+
if fq:
|
|
1352
|
+
return fq
|
|
1353
|
+
for wp in wildcard_pkgs:
|
|
1354
|
+
cand = same_pkg_map.get(wp, {}).get(head)
|
|
1355
|
+
if cand:
|
|
1356
|
+
return cand
|
|
1357
|
+
# cur_class itself may BE the head (self-qualified nested ref).
|
|
1358
|
+
if cur_class.rsplit(".", 1)[-1] == head:
|
|
1359
|
+
return cur_class
|
|
1360
|
+
cands = nested_by_simple.get(head)
|
|
1361
|
+
if not cands:
|
|
1362
|
+
return None
|
|
1363
|
+
if len(cands) == 1:
|
|
1364
|
+
return next(iter(cands))
|
|
1365
|
+
allowed = {cur_class} | _super_closure(cur_class)
|
|
1366
|
+
matched = [c for c in cands if enclosing_of.get(c) in allowed]
|
|
1367
|
+
if len(matched) == 1:
|
|
1368
|
+
return matched[0]
|
|
1369
|
+
self_nested = f"{cur_class}.{head}"
|
|
1370
|
+
if self_nested in cands:
|
|
1371
|
+
return self_nested
|
|
1372
|
+
return None
|
|
1373
|
+
|
|
1374
|
+
def _resolve_chain(chain: str, cur_class: str) -> "Optional[str]":
|
|
1375
|
+
parts = chain.split(".")
|
|
1376
|
+
fqn = _resolve_head(parts[0], cur_class)
|
|
1377
|
+
if not fqn:
|
|
1378
|
+
return None
|
|
1379
|
+
# Walk the remaining segments through KNOWN nested types so a
|
|
1380
|
+
# `Outer.Nested.CONST` reference credits the nested holder actually read,
|
|
1381
|
+
# not merely the outer type. Stop at the first non-type segment (the
|
|
1382
|
+
# constant/field), which is the deepest resolvable class.
|
|
1383
|
+
for seg in parts[1:]:
|
|
1384
|
+
nxt = f"{fqn}.{seg}"
|
|
1385
|
+
if nxt in class_fqns:
|
|
1386
|
+
fqn = nxt
|
|
1387
|
+
else:
|
|
1388
|
+
break
|
|
1389
|
+
return fqn
|
|
1390
|
+
|
|
1391
|
+
for cur_class, chain in ref_pairs:
|
|
1392
|
+
if cur_class not in class_fqns:
|
|
1393
|
+
continue
|
|
1394
|
+
target = _resolve_chain(chain, cur_class)
|
|
1395
|
+
if not target or target == cur_class or target not in class_fqns:
|
|
1396
|
+
continue
|
|
1397
|
+
key = (cur_class, target)
|
|
1398
|
+
if key in seen_edges:
|
|
1399
|
+
continue
|
|
1400
|
+
seen_edges.add(key)
|
|
1401
|
+
edges.append(RelationEdge(
|
|
1402
|
+
from_symbol=cur_class,
|
|
1403
|
+
to_symbol=target,
|
|
1404
|
+
type="references",
|
|
1405
|
+
confidence="high",
|
|
1406
|
+
evidence={"type": "annotation_value", "value": chain},
|
|
1407
|
+
))
|
|
1408
|
+
return edges
|
|
1409
|
+
|
|
1410
|
+
|
|
1130
1411
|
# Reserved words that read like calls (`if (`, `for (`, …). Can never be sibling
|
|
1131
1412
|
# method names (Java reserves them), but guard the intra-class scan anyway.
|
|
1132
1413
|
_CALL_KEYWORDS: frozenset[str] = frozenset({
|
|
@@ -3374,6 +3655,9 @@ def build_repo_ir(
|
|
|
3374
3655
|
)
|
|
3375
3656
|
|
|
3376
3657
|
_per_file: list[tuple[str, str, str, list[str], list[SymbolRecord]]] = []
|
|
3658
|
+
# (package, imports, annotation-ref-pairs) for the reference-graph post-pass,
|
|
3659
|
+
# collected from BOTH fully-parsed and pre-scan-skipped files (BUG #2).
|
|
3660
|
+
_ann_ref_sources: list[tuple[str, list[str], list[tuple[str, str]]]] = []
|
|
3377
3661
|
for rel_path in sorted(file_paths):
|
|
3378
3662
|
abs_path = root / rel_path
|
|
3379
3663
|
try:
|
|
@@ -3400,23 +3684,31 @@ def build_repo_ir(
|
|
|
3400
3684
|
# literals before scanning so only real declarations are captured. The name
|
|
3401
3685
|
# must also start uppercase ([A-Z]) — Java type convention, matching the
|
|
3402
3686
|
# precision of the full _CLASS_DECL_RE used on annotated files.
|
|
3403
|
-
|
|
3404
|
-
_min_syms: list[SymbolRecord] = []
|
|
3405
|
-
for _cm in re.finditer(r'\b(?:class|interface|enum)\s+([A-Z]\w*)', _decl_source):
|
|
3406
|
-
_cls_name = _cm.group(1)
|
|
3407
|
-
_fqn = f"{_pkg}.{_cls_name}" if _pkg else _cls_name
|
|
3408
|
-
_min_syms.append(SymbolRecord(
|
|
3409
|
-
symbol=_fqn, type="class", confidence="medium",
|
|
3410
|
-
declaring_file=rel_path,
|
|
3411
|
-
))
|
|
3687
|
+
_min_syms = _minimal_class_symbols(source, _pkg, rel_path)
|
|
3412
3688
|
all_symbols.extend(_min_syms)
|
|
3413
|
-
# No relations needed for non-annotated files
|
|
3689
|
+
# No relations needed for non-annotated files. BUG #2: but a pre-scan-
|
|
3690
|
+
# skipped file can still hold annotation-argument constant references —
|
|
3691
|
+
# e.g. a presentation interface whose class-level @AdminTabPresentation
|
|
3692
|
+
# references its own nested TabName/TabOrder holders. Capture those refs
|
|
3693
|
+
# (cheaply, no full parse) so the reference-graph post-pass can resolve
|
|
3694
|
+
# them and the holders are not flagged as false dead code.
|
|
3695
|
+
if "@" in source:
|
|
3696
|
+
_skipped_refs = _extract_annotation_type_refs(source, _pkg)
|
|
3697
|
+
if _skipped_refs:
|
|
3698
|
+
_imports = re.findall(
|
|
3699
|
+
r'^\s*import\s+(?:static\s+)?([\w.]+)\s*;', source, re.MULTILINE
|
|
3700
|
+
)
|
|
3701
|
+
_ann_ref_sources.append((_pkg, _imports, _skipped_refs))
|
|
3414
3702
|
continue
|
|
3415
3703
|
package, symbols, raw_imports = _extract_symbols(
|
|
3416
3704
|
source, rel_path, extra_capture=_extra_capture
|
|
3417
3705
|
)
|
|
3418
3706
|
all_symbols.extend(symbols)
|
|
3419
3707
|
_per_file.append((rel_path, source, package, raw_imports, symbols))
|
|
3708
|
+
if "@" in source:
|
|
3709
|
+
_parsed_refs = _extract_annotation_type_refs(source, package)
|
|
3710
|
+
if _parsed_refs:
|
|
3711
|
+
_ann_ref_sources.append((package, raw_imports, _parsed_refs))
|
|
3420
3712
|
|
|
3421
3713
|
# Build {package: {simple_name: FQN}} from every class/interface found.
|
|
3422
3714
|
_same_pkg_map: dict[str, dict[str, str]] = _build_same_package_map(all_symbols)
|
|
@@ -3454,6 +3746,15 @@ def build_repo_ir(
|
|
|
3454
3746
|
|
|
3455
3747
|
all_relations.extend(relations)
|
|
3456
3748
|
|
|
3749
|
+
# BUG #2 (Broadleaf): annotation-argument static-constant reads
|
|
3750
|
+
# (@Anno(attr = SomeType.CONST)) are real references — emit them as edges so
|
|
3751
|
+
# constant-holder types are not reported as zero-caller dead code. Runs as a
|
|
3752
|
+
# global post-pass because bare inherited-nested references (e.g. `GroupName`
|
|
3753
|
+
# from an implemented interface) need the repo-wide supertype/nested index.
|
|
3754
|
+
all_relations.extend(
|
|
3755
|
+
_annotation_reference_edges(_ann_ref_sources, all_symbols, all_relations, _same_pkg_map)
|
|
3756
|
+
)
|
|
3757
|
+
|
|
3457
3758
|
spring_summary = _build_spring_summary(all_symbols)
|
|
3458
3759
|
|
|
3459
3760
|
# Deduplicate relations
|
|
@@ -491,6 +491,17 @@ def run_security_audit(
|
|
|
491
491
|
limitations=_sec_limitations,
|
|
492
492
|
metadata={
|
|
493
493
|
"endpoints_analyzed": len(cir.endpoints),
|
|
494
|
+
"endpoints_analyzed_note": (
|
|
495
|
+
"Count of canonical endpoints analyzed for security: Spring-MVC/JAX-RS "
|
|
496
|
+
"annotated handlers plus OpenAPI-spec-recovered routes. Built from the "
|
|
497
|
+
"SAME route surface as the `endpoints` command and with the SAME "
|
|
498
|
+
"exclusion of framework dynamic-admin FQN-shaped paths, but "
|
|
499
|
+
"DEDUPLICATED by (method, path, controller, handler) — so this value is "
|
|
500
|
+
"<= the `endpoints` command total, the small difference being "
|
|
501
|
+
"multi-prefix routes that collapse to one canonical endpoint here. It "
|
|
502
|
+
"does not include the non-Spring REST surface (`endpoints` reports that "
|
|
503
|
+
"separately under non_spring_rest_surface)."
|
|
504
|
+
),
|
|
494
505
|
"security_model": cir.metadata.get("security_model", "unknown"),
|
|
495
506
|
"analysis_time_ms": elapsed_ms,
|
|
496
507
|
},
|
sourcecode/spring_tx_analyzer.py
CHANGED
|
@@ -56,13 +56,61 @@ _WRITE_METHOD_RE = re.compile(
|
|
|
56
56
|
# _CATCH_SWALLOW_RE is retained for reference but replaced by brace-counting
|
|
57
57
|
# extraction in _has_swallowed_exception to avoid false positives from nested
|
|
58
58
|
# braces (nested if/try blocks inside catch terminating the match prematurely).
|
|
59
|
-
_CATCH_HEADER_RE = re.compile(r'\bcatch\s*\([^)]
|
|
59
|
+
_CATCH_HEADER_RE = re.compile(r'\bcatch\s*\(([^)]+)\)\s*\{')
|
|
60
60
|
_LOG_IN_CATCH_RE = re.compile(r'\b(?:log|logger|LOG|System\.out|e\.print)\b')
|
|
61
61
|
_RETHROW_IN_CATCH_RE = re.compile(r'\bthrow\b')
|
|
62
62
|
# Non-trivial return (method call) inside a catch block indicates recovery, not
|
|
63
63
|
# silent swallowing — e.g. `return findNextId(idType)` after creating a missing row.
|
|
64
64
|
_RECOVERY_RETURN_RE = re.compile(r'\breturn\s+\w[\w.<>]*\s*\(')
|
|
65
65
|
|
|
66
|
+
# Exception types whose catch is a documented part of normal control flow, not a
|
|
67
|
+
# masked failure. Catching these is the standard JPA/Spring-Data idiom (e.g.
|
|
68
|
+
# Query.getSingleResult() throws NoResultException by contract when 0 rows match;
|
|
69
|
+
# a tolerant insert catches EntityExistsException on a race). Catching one of
|
|
70
|
+
# these must never trigger TX-005. (Broadleaf field test: 4/4 TX-005 findings
|
|
71
|
+
# were this idiom — 100% FP rate.)
|
|
72
|
+
_EXPECTED_CONTROL_FLOW_EXCEPTIONS = frozenset({
|
|
73
|
+
"NoResultException",
|
|
74
|
+
"NonUniqueResultException",
|
|
75
|
+
"EntityExistsException",
|
|
76
|
+
"EntityNotFoundException",
|
|
77
|
+
"EmptyResultDataAccessException",
|
|
78
|
+
"IncorrectResultSizeDataAccessException",
|
|
79
|
+
})
|
|
80
|
+
# A caught type is "expected" if any of its simple-name tokens is in the set above.
|
|
81
|
+
_CATCH_TYPE_TOKEN_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_.]*')
|
|
82
|
+
# Lexical markers of a deliberate, developer-documented no-op inside a catch block.
|
|
83
|
+
# When the catch body's leading comment says the swallow is intentional, TX-005 is
|
|
84
|
+
# not reporting a bug — it is second-guessing an explicit decision.
|
|
85
|
+
_INTENTIONAL_SWALLOW_COMMENT_RE = re.compile(
|
|
86
|
+
r'(?://|/\*|\*)[^\n]*?\b('
|
|
87
|
+
r'intentional|intentionally|acceptable|expected|'
|
|
88
|
+
r'do\s+nothing|no[\s\-]?op|noop|ignore[ds]?|ignoring|'
|
|
89
|
+
r'on\s+purpose|safe\s+to\s+ignore|by\s+design|not\s+an?\s+error'
|
|
90
|
+
r')\b',
|
|
91
|
+
re.IGNORECASE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _caught_types(catch_header_inner: str) -> list[str]:
|
|
96
|
+
"""Extract caught exception simple-name tokens from a catch header inner text.
|
|
97
|
+
|
|
98
|
+
`catch_header_inner` is the text inside the parens, e.g.
|
|
99
|
+
"NoResultException nre" or "IllegalStateException | IOException e".
|
|
100
|
+
Returns simple names only (last dotted segment), lowercase-preserving.
|
|
101
|
+
"""
|
|
102
|
+
# Drop the trailing variable name; split multi-catch on '|'.
|
|
103
|
+
types: list[str] = []
|
|
104
|
+
for alt in catch_header_inner.split('|'):
|
|
105
|
+
toks = _CATCH_TYPE_TOKEN_RE.findall(alt)
|
|
106
|
+
# First token(s) are the type; the last bare identifier is the var name.
|
|
107
|
+
# A type token contains '.' or starts uppercase; the var name is lowercase.
|
|
108
|
+
for tok in toks:
|
|
109
|
+
simple = tok.rsplit('.', 1)[-1]
|
|
110
|
+
if simple and simple[0].isupper():
|
|
111
|
+
types.append(simple)
|
|
112
|
+
return types
|
|
113
|
+
|
|
66
114
|
|
|
67
115
|
def _extract_method_body(source: str, method_name: str) -> str:
|
|
68
116
|
"""Extract the first method body matching method_name using brace counting.
|
|
@@ -597,7 +645,7 @@ class _TX005ExceptionSwallowing:
|
|
|
597
645
|
depth -= 1
|
|
598
646
|
i += 1
|
|
599
647
|
body = source[brace_pos:i]
|
|
600
|
-
for catch_block in self._extract_catch_blocks(body):
|
|
648
|
+
for caught_header, catch_block in self._extract_catch_blocks(body):
|
|
601
649
|
if not _LOG_IN_CATCH_RE.search(catch_block):
|
|
602
650
|
continue
|
|
603
651
|
if _RETHROW_IN_CATCH_RE.search(catch_block):
|
|
@@ -605,18 +653,31 @@ class _TX005ExceptionSwallowing:
|
|
|
605
653
|
# Non-trivial return (method call) indicates recovery, not swallowing.
|
|
606
654
|
if _RECOVERY_RETURN_RE.search(catch_block):
|
|
607
655
|
continue
|
|
656
|
+
# Catching an exception that is part of normal control flow (JPA
|
|
657
|
+
# getSingleResult / tolerant insert) is not error-masking — skip.
|
|
658
|
+
caught = _caught_types(caught_header)
|
|
659
|
+
if any(t in _EXPECTED_CONTROL_FLOW_EXCEPTIONS for t in caught):
|
|
660
|
+
continue
|
|
661
|
+
# A developer-documented deliberate no-op is an explicit decision,
|
|
662
|
+
# not a bug the tool should flag.
|
|
663
|
+
if _INTENTIONAL_SWALLOW_COMMENT_RE.search(catch_block):
|
|
664
|
+
continue
|
|
608
665
|
return True
|
|
609
666
|
return False
|
|
610
667
|
|
|
611
668
|
@staticmethod
|
|
612
|
-
def _extract_catch_blocks(body: str) -> list[str]:
|
|
613
|
-
"""Extract
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
669
|
+
def _extract_catch_blocks(body: str) -> list[tuple[str, str]]:
|
|
670
|
+
"""Extract (caught-type-header, catch-body) pairs from a method body.
|
|
671
|
+
|
|
672
|
+
Uses brace counting so nested braces inside the catch body do not
|
|
673
|
+
terminate the match prematurely (unlike a simple [^}]* regex). The
|
|
674
|
+
caught-type-header is the text inside the catch parens (e.g.
|
|
675
|
+
"NoResultException nre"), used to distinguish expected-control-flow
|
|
676
|
+
exceptions from genuinely swallowed failures.
|
|
617
677
|
"""
|
|
618
|
-
blocks: list[str] = []
|
|
678
|
+
blocks: list[tuple[str, str]] = []
|
|
619
679
|
for m in _CATCH_HEADER_RE.finditer(body):
|
|
680
|
+
header_inner = m.group(1)
|
|
620
681
|
brace_pos = m.end() - 1
|
|
621
682
|
depth = 1
|
|
622
683
|
i = brace_pos + 1
|
|
@@ -627,7 +688,7 @@ class _TX005ExceptionSwallowing:
|
|
|
627
688
|
elif c == '}':
|
|
628
689
|
depth -= 1
|
|
629
690
|
i += 1
|
|
630
|
-
blocks.append(body[brace_pos:i])
|
|
691
|
+
blocks.append((header_inner, body[brace_pos:i]))
|
|
631
692
|
return blocks
|
|
632
693
|
|
|
633
694
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=m6miAE4bWPKWjY-CMgVBctXpyTJ_4N6fgxWDzFMCCTk,103
|
|
2
2
|
sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
|
|
3
3
|
sourcecode/architecture_analyzer.py,sha256=r_xf-SWXwUm3nVQMCSXHJ3M8zKsQP7Ze8Nqf6TVLRq8,45998
|
|
4
4
|
sourcecode/architecture_summary.py,sha256=UaxmUU77oTlWxttMU3c6PxHrU-Q6q5N31TeU_NWWTdU,24297
|
|
5
5
|
sourcecode/ast_extractor.py,sha256=sa6CmLpn-k5G3_Hzxn8hAlZ5-TS-EVzXDD0Gvxd2jzs,50613
|
|
6
6
|
sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
|
|
7
|
-
sourcecode/canonical_ir.py,sha256=
|
|
7
|
+
sourcecode/canonical_ir.py,sha256=IftvTlokcSLbZYHjXQu55FnS_X1LU9bXGsGxm6ujPtw,25793
|
|
8
8
|
sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
|
|
9
9
|
sourcecode/classifier.py,sha256=YTTCoRdcLEFRVcql9Ow1dE7eYQj0jq2rgx32bRDnb1k,13852
|
|
10
|
-
sourcecode/cli.py,sha256=
|
|
10
|
+
sourcecode/cli.py,sha256=ZWl9QHCe8kCt5bBWtkyZayy78p9dR8cXW4H-W0j76M8,286811
|
|
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
|
|
@@ -20,7 +20,7 @@ sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24
|
|
|
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
|
-
sourcecode/explain.py,sha256=
|
|
23
|
+
sourcecode/explain.py,sha256=2Og-r74yUm38wAF6qD28Gt0jxi1Ygdx33hwp6-NH0xs,20348
|
|
24
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
|
|
@@ -29,12 +29,12 @@ sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
|
|
|
29
29
|
sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
|
|
30
30
|
sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,62403
|
|
31
31
|
sourcecode/hibernate_strat.py,sha256=h0leIhlWvSjYq3F99LxvLIDLrJ-xPYxWAREG4LkqZ-4,61190
|
|
32
|
-
sourcecode/integration_detector.py,sha256=
|
|
32
|
+
sourcecode/integration_detector.py,sha256=69TGGtBCzWz0TpjyVhnOh58pFMzSIeqbs_gVQhm98Yk,18622
|
|
33
33
|
sourcecode/jdk_exports.py,sha256=fCrlwNAXUT9gge_joq6kMnY3zJxYB2pxqy-0w3o3MJI,874
|
|
34
34
|
sourcecode/license.py,sha256=wckiLuiwaE35KMCStUf1gYzleJuFe6qsSyxUQJvit3s,23500
|
|
35
35
|
sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
|
|
36
36
|
sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
|
|
37
|
-
sourcecode/migrate_check.py,sha256=
|
|
37
|
+
sourcecode/migrate_check.py,sha256=xWxI7qovIslRRw0lhf1qsKyMKbMHFV8XsWUEzfENh38,105138
|
|
38
38
|
sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
|
|
39
39
|
sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
|
|
40
40
|
sourcecode/path_filters.py,sha256=VnaD9jxZVNzluackNSTCdIddwzAqIseuSCs_A-gpCDM,6898
|
|
@@ -47,7 +47,7 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
|
|
|
47
47
|
sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
|
|
48
48
|
sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
|
|
49
49
|
sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
|
|
50
|
-
sourcecode/repository_ir.py,sha256=
|
|
50
|
+
sourcecode/repository_ir.py,sha256=iw_TVIpWT204AvTvm60iaDUCSZKDvY7s2OkLODOQPnk,244042
|
|
51
51
|
sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
|
|
52
52
|
sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
|
|
53
53
|
sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
|
|
@@ -59,9 +59,9 @@ sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC
|
|
|
59
59
|
sourcecode/spring_findings.py,sha256=G7Or2lKBUQbcTDqudLvSs9XvNg_YoAa-_lBOG_ULs8E,5457
|
|
60
60
|
sourcecode/spring_impact.py,sha256=1Eu1vkdwTVsw92iBEJDe_i_FaSf4uGH9Rb0eSgIAAZc,57542
|
|
61
61
|
sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
|
|
62
|
-
sourcecode/spring_security_audit.py,sha256=
|
|
62
|
+
sourcecode/spring_security_audit.py,sha256=xrdv3MaR_m2wblA6a8MRWM7attvZeoPXKsoRO_R2lY4,21401
|
|
63
63
|
sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM,13307
|
|
64
|
-
sourcecode/spring_tx_analyzer.py,sha256=
|
|
64
|
+
sourcecode/spring_tx_analyzer.py,sha256=iH9mK0Tgvhgy_Ee2r13KA_RMf8HKN-pjCKQGuyCCBjM,33812
|
|
65
65
|
sourcecode/summarizer.py,sha256=hV-kPFXXh15GnIvmLZnmVWTEbmr35C-yKVtPiR25U2g,21802
|
|
66
66
|
sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
|
|
67
67
|
sourcecode/validation_surface.py,sha256=2Ojfzhw9R4XpemAFqb9RXf4FKyI3DYjGo4Dp0AHZMQo,22600
|
|
@@ -104,8 +104,8 @@ sourcecode/telemetry/consent.py,sha256=H5z2Wu63pZqbaKucRPoJQJ0zCo4cke9ZlBrJC-MaW
|
|
|
104
104
|
sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
|
|
105
105
|
sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
|
|
106
106
|
sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
|
|
107
|
-
sourcecode-1.
|
|
108
|
-
sourcecode-1.
|
|
109
|
-
sourcecode-1.
|
|
110
|
-
sourcecode-1.
|
|
111
|
-
sourcecode-1.
|
|
107
|
+
sourcecode-1.72.0.dist-info/METADATA,sha256=U1s6MWAt8ek9ne5Rp7fr8i7TEfeyK_zmz4v51pEg-Eo,48339
|
|
108
|
+
sourcecode-1.72.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
109
|
+
sourcecode-1.72.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
|
|
110
|
+
sourcecode-1.72.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
111
|
+
sourcecode-1.72.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|