sourcecode 1.71.0__py3-none-any.whl → 1.73.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 +111 -19
- sourcecode/detectors/java.py +99 -8
- sourcecode/detectors/parsers.py +23 -0
- sourcecode/explain.py +7 -2
- sourcecode/integration_detector.py +54 -0
- sourcecode/migrate_check.py +54 -13
- sourcecode/repository_ir.py +346 -10
- sourcecode/serializer.py +6 -1
- sourcecode/spring_security_audit.py +11 -0
- sourcecode/spring_tx_analyzer.py +70 -9
- {sourcecode-1.71.0.dist-info → sourcecode-1.73.0.dist-info}/METADATA +1 -1
- {sourcecode-1.71.0.dist-info → sourcecode-1.73.0.dist-info}/RECORD +17 -17
- {sourcecode-1.71.0.dist-info → sourcecode-1.73.0.dist-info}/WHEEL +0 -0
- {sourcecode-1.71.0.dist-info → sourcecode-1.73.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-1.71.0.dist-info → sourcecode-1.73.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
|
@@ -4221,6 +4221,7 @@ def _build_c4_export(
|
|
|
4221
4221
|
edges: "list[dict]",
|
|
4222
4222
|
endpoints: "list[dict]",
|
|
4223
4223
|
integrations: "dict",
|
|
4224
|
+
endpoint_meta: "Optional[dict]" = None,
|
|
4224
4225
|
) -> "dict":
|
|
4225
4226
|
"""Assemble a unified, tool-agnostic C4 architecture export + incremental manifest.
|
|
4226
4227
|
|
|
@@ -4236,6 +4237,15 @@ def _build_c4_export(
|
|
|
4236
4237
|
# consumer counts/classifies modules instead of inferring them from leaf dirs.
|
|
4237
4238
|
module_graph["module_roots"] = _detect_module_roots(by_directory)
|
|
4238
4239
|
api_surface = _group_endpoints_by_controller(endpoints)
|
|
4240
|
+
# BUG #7 (Alfresco field test): carry the endpoint extractor's structured
|
|
4241
|
+
# zero-result explanation and non-Spring REST surface into api_surface, so a
|
|
4242
|
+
# C4 consumer seeing 0 controllers understands WHY (unrecognized routing, e.g.
|
|
4243
|
+
# WebScripts) instead of concluding the system has no public API.
|
|
4244
|
+
if endpoint_meta:
|
|
4245
|
+
if endpoint_meta.get("zero_result_reason"):
|
|
4246
|
+
api_surface["zero_result_reason"] = endpoint_meta["zero_result_reason"]
|
|
4247
|
+
if endpoint_meta.get("non_spring_rest_surface"):
|
|
4248
|
+
api_surface["non_spring_rest_surface"] = endpoint_meta["non_spring_rest_surface"]
|
|
4239
4249
|
containers = _detect_containers(root)
|
|
4240
4250
|
|
|
4241
4251
|
limitations: "list[str]" = []
|
|
@@ -4364,7 +4374,8 @@ def export_cmd(
|
|
|
4364
4374
|
from sourcecode.repository_ir import extract_java_endpoints
|
|
4365
4375
|
ir = build_repo_ir(file_list, root)
|
|
4366
4376
|
graph = ir.get("graph", {})
|
|
4367
|
-
|
|
4377
|
+
_ep_data = extract_java_endpoints(root)
|
|
4378
|
+
endpoints = _ep_data.get("endpoints", [])
|
|
4368
4379
|
data = _build_c4_export(
|
|
4369
4380
|
root,
|
|
4370
4381
|
file_list,
|
|
@@ -4372,6 +4383,7 @@ def export_cmd(
|
|
|
4372
4383
|
graph.get("edges", []),
|
|
4373
4384
|
endpoints,
|
|
4374
4385
|
detect_integrations(file_list, root),
|
|
4386
|
+
endpoint_meta=_ep_data,
|
|
4375
4387
|
)
|
|
4376
4388
|
output = _serialize_dict(data, format)
|
|
4377
4389
|
_emit_command_output(output, output_path, copy,
|
|
@@ -5215,14 +5227,16 @@ def explain_cmd(
|
|
|
5215
5227
|
Path("."),
|
|
5216
5228
|
help="Repository root (default: current directory)",
|
|
5217
5229
|
),
|
|
5218
|
-
format: str = typer.Option(
|
|
5219
|
-
|
|
5220
|
-
help="Output format: text (default) or json."
|
|
5221
|
-
|
|
5230
|
+
format: Optional[str] = typer.Option(
|
|
5231
|
+
None, "--format", "-f",
|
|
5232
|
+
help="Output format: text (default) or json. If unset and --output ends "
|
|
5233
|
+
"in .json, JSON is written automatically.",
|
|
5222
5234
|
),
|
|
5223
5235
|
output_path: Optional[Path] = typer.Option(
|
|
5224
5236
|
None, "--output", "-o",
|
|
5225
|
-
help="Write output to a file instead of stdout."
|
|
5237
|
+
help="Write output to a file instead of stdout. NOTE: the default format is "
|
|
5238
|
+
"human-readable text/Markdown, NOT JSON — pass --format json (or use a "
|
|
5239
|
+
".json output path) for machine-readable output.",
|
|
5226
5240
|
),
|
|
5227
5241
|
copy: bool = typer.Option(
|
|
5228
5242
|
False, "--copy", "-c",
|
|
@@ -5231,6 +5245,11 @@ def explain_cmd(
|
|
|
5231
5245
|
) -> None:
|
|
5232
5246
|
"""Human-readable architectural summary for a class.
|
|
5233
5247
|
|
|
5248
|
+
\b
|
|
5249
|
+
OUTPUT FORMAT: defaults to text/Markdown (human-oriented). Unlike most
|
|
5250
|
+
subcommands this is NOT JSON by default — pass --format json, or give an
|
|
5251
|
+
--output path ending in .json, for a machine-readable envelope.
|
|
5252
|
+
|
|
5234
5253
|
\b
|
|
5235
5254
|
Generates a structured explanation derived entirely from static analysis:
|
|
5236
5255
|
- Purpose and Spring stereotype
|
|
@@ -5278,6 +5297,15 @@ def explain_cmd(
|
|
|
5278
5297
|
)
|
|
5279
5298
|
raise typer.Exit(code=1)
|
|
5280
5299
|
|
|
5300
|
+
# BUG #2 (Alfresco field test): `explain -o out.json` previously wrote Markdown
|
|
5301
|
+
# to a .json file, breaking any programmatic consumer that assumed JSON parity
|
|
5302
|
+
# with the other subcommands. Infer JSON from a .json output extension when
|
|
5303
|
+
# --format is unset; an explicit --format always wins.
|
|
5304
|
+
if format is None:
|
|
5305
|
+
if output_path and str(output_path).lower().endswith(".json"):
|
|
5306
|
+
format = "json"
|
|
5307
|
+
else:
|
|
5308
|
+
format = "text"
|
|
5281
5309
|
_enforce_format("explain", format)
|
|
5282
5310
|
|
|
5283
5311
|
file_list = find_java_files(target)
|
|
@@ -5764,11 +5792,63 @@ def modernize_cmd(
|
|
|
5764
5792
|
key=lambda h: (-h["hotspot_score"], h["fqn"]),
|
|
5765
5793
|
)[:15]
|
|
5766
5794
|
|
|
5767
|
-
# Cross-module tangles:
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
)
|
|
5795
|
+
# Cross-module tangles: actual inter-subsystem coupling measured from the
|
|
5796
|
+
# dependency graph, NOT a re-labelled subsystem list. For every structural
|
|
5797
|
+
# edge whose endpoints live in two different subsystems we tally a directed
|
|
5798
|
+
# (from_subsystem → to_subsystem) coupling count; pairs coupled in BOTH
|
|
5799
|
+
# directions are the real tangles (bidirectional/cyclic module coupling).
|
|
5800
|
+
_graph_edges: list = (ir.get("graph") or {}).get("edges") or []
|
|
5801
|
+
# Structural coupling edge types. annotated_with / mapped_to / event edges are
|
|
5802
|
+
# excluded (metadata + separate topology, not module-decomposition coupling).
|
|
5803
|
+
_TANGLE_EDGE_TYPES = frozenset({
|
|
5804
|
+
"imports", "injects", "extends", "implements",
|
|
5805
|
+
"references", "calls", "instantiates", "returns",
|
|
5806
|
+
})
|
|
5807
|
+
_fqn_to_pkg: dict[str, str] = {}
|
|
5808
|
+
_pkg_to_label: dict[str, str] = {}
|
|
5809
|
+
for _s in subsystems:
|
|
5810
|
+
_pp = _s.get("package_prefix") or _s.get("pkg") or ""
|
|
5811
|
+
if not _pp:
|
|
5812
|
+
continue
|
|
5813
|
+
_pkg_to_label[_pp] = _s.get("label") or _s.get("name") or _pp
|
|
5814
|
+
for _m in (_s.get("members") or []):
|
|
5815
|
+
_fqn_to_pkg[_m] = _pp
|
|
5816
|
+
|
|
5817
|
+
from collections import Counter as _Counter
|
|
5818
|
+
_pair_counts: "_Counter[tuple[str, str]]" = _Counter()
|
|
5819
|
+
_pair_example: dict[tuple[str, str], str] = {}
|
|
5820
|
+
for _e in _graph_edges:
|
|
5821
|
+
if _e.get("type") not in _TANGLE_EDGE_TYPES:
|
|
5822
|
+
continue
|
|
5823
|
+
_a = _fqn_to_pkg.get(_e.get("from"))
|
|
5824
|
+
_b = _fqn_to_pkg.get(_e.get("to"))
|
|
5825
|
+
if not _a or not _b or _a == _b:
|
|
5826
|
+
continue
|
|
5827
|
+
_pair_counts[(_a, _b)] += 1
|
|
5828
|
+
_pair_example.setdefault((_a, _b), f"{_e.get('from')} → {_e.get('to')}")
|
|
5829
|
+
|
|
5830
|
+
_cross_module_tangles = []
|
|
5831
|
+
for (_a, _b), _cnt in sorted(
|
|
5832
|
+
_pair_counts.items(), key=lambda kv: (-kv[1], kv[0])
|
|
5833
|
+
):
|
|
5834
|
+
_reverse = _pair_counts.get((_b, _a), 0)
|
|
5835
|
+
_cross_module_tangles.append({
|
|
5836
|
+
"from_subsystem": _pkg_to_label.get(_a, _a),
|
|
5837
|
+
"to_subsystem": _pkg_to_label.get(_b, _b),
|
|
5838
|
+
"from_package": _a,
|
|
5839
|
+
"to_package": _b,
|
|
5840
|
+
"edge_count": _cnt,
|
|
5841
|
+
"reverse_edge_count": _reverse,
|
|
5842
|
+
# A mutual (cyclic) dependency is the actual "tangle" — both modules
|
|
5843
|
+
# reference each other, so neither can be extracted independently.
|
|
5844
|
+
"mutual": _reverse > 0,
|
|
5845
|
+
"example": _pair_example.get((_a, _b), ""),
|
|
5846
|
+
})
|
|
5847
|
+
# Surface mutual tangles first (highest decomposition risk), then by strength.
|
|
5848
|
+
_cross_module_tangles.sort(
|
|
5849
|
+
key=lambda t: (not t["mutual"], -t["edge_count"], t["from_package"])
|
|
5850
|
+
)
|
|
5851
|
+
_cross_module_tangles = _cross_module_tangles[:15]
|
|
5772
5852
|
|
|
5773
5853
|
_summary = {
|
|
5774
5854
|
"total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
|
|
@@ -5837,6 +5917,17 @@ def modernize_cmd(
|
|
|
5837
5917
|
{"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
|
|
5838
5918
|
for n in coupling_nodes
|
|
5839
5919
|
],
|
|
5920
|
+
# BUG #6 (Broadleaf field test): make the in_degree metric self-describing
|
|
5921
|
+
# so it is not confused with `explain`'s caller count. They differ by design.
|
|
5922
|
+
"high_coupling_nodes_note": (
|
|
5923
|
+
"in_degree = raw count of incoming graph edges across ALL edge types "
|
|
5924
|
+
"(imports, injects, extends/implements, references, annotations), "
|
|
5925
|
+
"counted at symbol level. This is deliberately larger than and NOT "
|
|
5926
|
+
"equal to `sourcecode explain`'s caller count, which reports DISTINCT "
|
|
5927
|
+
"dependent classes (DI dependents + reverse-call-graph callers, "
|
|
5928
|
+
"deduplicated to class level). Use in_degree for blast-radius ranking, "
|
|
5929
|
+
"explain's caller list for the concrete dependents to inspect."
|
|
5930
|
+
),
|
|
5840
5931
|
"statically_unreferenced": [
|
|
5841
5932
|
{"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
|
|
5842
5933
|
for n in dead_zones
|
|
@@ -5853,14 +5944,15 @@ def modernize_cmd(
|
|
|
5853
5944
|
],
|
|
5854
5945
|
"subsystem_summary": _subsystem_summary,
|
|
5855
5946
|
"subsystem_summary_note": _subsystem_summary_note,
|
|
5856
|
-
"cross_module_tangles":
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5947
|
+
"cross_module_tangles": _cross_module_tangles,
|
|
5948
|
+
"cross_module_tangles_note": (
|
|
5949
|
+
"Directed inter-subsystem coupling measured from structural graph "
|
|
5950
|
+
"edges (imports/injects/extends/implements/references/calls). "
|
|
5951
|
+
"edge_count = edges from_package→to_package; mutual=true marks a "
|
|
5952
|
+
"bidirectional (cyclic) dependency — the actual tangle that blocks "
|
|
5953
|
+
"independent module extraction. Empty means no cross-subsystem "
|
|
5954
|
+
"structural coupling was detected."
|
|
5955
|
+
),
|
|
5864
5956
|
# BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
|
|
5865
5957
|
"recommendation": (
|
|
5866
5958
|
(
|
sourcecode/detectors/java.py
CHANGED
|
@@ -10,7 +10,11 @@ from sourcecode.detectors.base import (
|
|
|
10
10
|
EntryPoint,
|
|
11
11
|
StackDetection,
|
|
12
12
|
)
|
|
13
|
-
from sourcecode.detectors.parsers import
|
|
13
|
+
from sourcecode.detectors.parsers import (
|
|
14
|
+
read_text_lines,
|
|
15
|
+
substitute_maven_properties as _resolve_maven_property,
|
|
16
|
+
unique_strings,
|
|
17
|
+
)
|
|
14
18
|
from sourcecode.schema import FrameworkDetection
|
|
15
19
|
from sourcecode.tree_utils import flatten_file_tree
|
|
16
20
|
|
|
@@ -55,6 +59,14 @@ _JAX_RS_PROVIDER_RE = re.compile(r'@Provider\b')
|
|
|
55
59
|
|
|
56
60
|
# --- CDI / Jakarta EE scopes ---
|
|
57
61
|
_CDI_SCOPED_RE = re.compile(r'@(?:ApplicationScoped|RequestScoped|SessionScoped|Singleton|Dependent)\b')
|
|
62
|
+
# BUG #3: a genuine bootstrap entry declares a real main() or a bootstrap annotation
|
|
63
|
+
# / WAR initializer supertype — never merely a `*Application.java` filename.
|
|
64
|
+
_MAIN_METHOD_RE = re.compile(r'\bstatic\b[^;{}()]*\bmain\s*\(\s*(?:final\s+)?String')
|
|
65
|
+
_BOOTSTRAP_ANNOTATION_RE = re.compile(
|
|
66
|
+
r'@(?:SpringBootApplication|QuarkusMain|MicronautApplication|'
|
|
67
|
+
r'EnableAutoConfiguration|SpringBootConfiguration)\b'
|
|
68
|
+
r'|\bextends\s+SpringBootServletInitializer\b'
|
|
69
|
+
)
|
|
58
70
|
|
|
59
71
|
# --- Keycloak / Quarkus SPI ---
|
|
60
72
|
# Matches "implements EventListenerProvider" or "implements Foo, EventListenerProvider, Bar"
|
|
@@ -151,6 +163,18 @@ class JavaDetector(AbstractDetector):
|
|
|
151
163
|
# Spring profiles — check src/main/options/, src/main/resources/
|
|
152
164
|
spring_profiles = self._detect_spring_profiles(context.root, all_paths)
|
|
153
165
|
|
|
166
|
+
# BUG #4 (Alfresco field test): a `org.mybatis` coordinate in the pom is not
|
|
167
|
+
# proof the app USES MyBatis mappers. Alfresco declares org.mybatis:mybatis
|
|
168
|
+
# yet has zero @Mapper interfaces and zero *Mapper.xml — its SQL layer is
|
|
169
|
+
# iBatis-style *-SqlMap.xml (surfaced separately). Emitting "MyBatis" as an
|
|
170
|
+
# architecture framework then contradicted the tool's own evidence block
|
|
171
|
+
# (mapper_interfaces:0, xml_files:0) and mislabeled bean DTO mappers. Keep
|
|
172
|
+
# the label only with real usage evidence: a *Mapper.xml or an @Mapper interface.
|
|
173
|
+
if any(f.name == "MyBatis" for f in frameworks) and not self._has_mybatis_usage(
|
|
174
|
+
context.root, all_paths
|
|
175
|
+
):
|
|
176
|
+
frameworks = [f for f in frameworks if f.name != "MyBatis"]
|
|
177
|
+
|
|
154
178
|
entry_points = self._collect_entry_points(context)
|
|
155
179
|
transactional_classes = self._collect_transactional_classes(context, all_paths)
|
|
156
180
|
stack = StackDetection(
|
|
@@ -211,6 +235,17 @@ class JavaDetector(AbstractDetector):
|
|
|
211
235
|
break
|
|
212
236
|
break
|
|
213
237
|
|
|
238
|
+
# BUG #1 (Alfresco field test): a picked value can be a Maven property
|
|
239
|
+
# reference — Alfresco's <maven.compiler.source>${java.version}</...> chains
|
|
240
|
+
# to <java.version>21</java.version> in the SAME <properties> block. Resolve
|
|
241
|
+
# ${...} references against the pom's own properties so --compact reports the
|
|
242
|
+
# concrete version ("21") instead of the literal "${java.version}", matching
|
|
243
|
+
# what migrate-check already surfaces. Bounded iteration guards ref cycles.
|
|
244
|
+
if "language_version" in result:
|
|
245
|
+
result["language_version"] = _resolve_maven_property(
|
|
246
|
+
result["language_version"], props
|
|
247
|
+
)
|
|
248
|
+
|
|
214
249
|
return result
|
|
215
250
|
|
|
216
251
|
def _detect_spring_profiles(self, root: Path, all_paths: list[str]) -> list[str]:
|
|
@@ -425,15 +460,25 @@ class JavaDetector(AbstractDetector):
|
|
|
425
460
|
# Exclude test trees: test helpers like AdminApplication.java in
|
|
426
461
|
# integration/src/test/java/ must not be treated as production entrypoints.
|
|
427
462
|
from sourcecode.path_filters import is_test_path as _is_test_path
|
|
463
|
+
# BUG #3 (Alfresco field test): a `*Application.java` / `*Main.java` NAME does
|
|
464
|
+
# not make a file a bootstrap entry point. Alfresco has XSD-generated JAXB
|
|
465
|
+
# `Application.java` model classes (no main(), no bootstrap annotation) in a
|
|
466
|
+
# WAR with no own entry point — flagging them as `application` polluted the
|
|
467
|
+
# executive summary. Emit `kind="application"` ONLY when the file actually
|
|
468
|
+
# declares a `main(String[])` method or a bootstrap annotation.
|
|
428
469
|
app_candidates = [
|
|
429
|
-
p for p in
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
entry_points: list[EntryPoint] = [
|
|
434
|
-
EntryPoint(path=p, stack="java", kind="application", source="manifest")
|
|
435
|
-
for p in unique_strings(app_candidates)
|
|
470
|
+
p for p in unique_strings([
|
|
471
|
+
q for q in all_java
|
|
472
|
+
if q.endswith(("Application.java", "Main.java")) and not _is_test_path(q)
|
|
473
|
+
])
|
|
436
474
|
]
|
|
475
|
+
entry_points: list[EntryPoint] = []
|
|
476
|
+
for p in app_candidates:
|
|
477
|
+
verified = self._verify_bootstrap_entry(context.root / p)
|
|
478
|
+
if verified:
|
|
479
|
+
entry_points.append(EntryPoint(
|
|
480
|
+
path=p, stack="java", kind="application", source=verified,
|
|
481
|
+
))
|
|
437
482
|
|
|
438
483
|
# 2. Annotation-based scan: @RestController, @WebFilter, FilterRegistrationBean
|
|
439
484
|
# Prioritize Controller-named files so all REST controllers are detected
|
|
@@ -481,6 +526,52 @@ class JavaDetector(AbstractDetector):
|
|
|
481
526
|
unique_eps.append(ep)
|
|
482
527
|
return unique_eps
|
|
483
528
|
|
|
529
|
+
def _has_mybatis_usage(self, root: Path, all_paths: list[str]) -> bool:
|
|
530
|
+
"""True when the repo shows real MyBatis usage, not just a pom coordinate.
|
|
531
|
+
|
|
532
|
+
Evidence = a *Mapper.xml file OR a *Mapper.java carrying the MyBatis
|
|
533
|
+
@Mapper annotation. iBatis-style *-SqlMap.xml is deliberately NOT counted
|
|
534
|
+
here — it is a distinct legacy pattern surfaced elsewhere (BUG #4).
|
|
535
|
+
"""
|
|
536
|
+
if any(p.endswith("Mapper.xml") for p in all_paths):
|
|
537
|
+
return True
|
|
538
|
+
from sourcecode.path_filters import is_test_path as _is_test_path
|
|
539
|
+
mapper_java = [
|
|
540
|
+
p for p in all_paths
|
|
541
|
+
if p.endswith("Mapper.java") and not _is_test_path(p)
|
|
542
|
+
]
|
|
543
|
+
for rel in mapper_java[:_MAX_JAVA_ENTRY_SCAN]:
|
|
544
|
+
try:
|
|
545
|
+
abs_path = root / rel
|
|
546
|
+
if abs_path.stat().st_size > _MAX_FILE_SIZE:
|
|
547
|
+
continue
|
|
548
|
+
content = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
549
|
+
except OSError:
|
|
550
|
+
continue
|
|
551
|
+
if "org.apache.ibatis.annotations.Mapper" in content or "@Mapper" in content:
|
|
552
|
+
return True
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
def _verify_bootstrap_entry(self, abs_path: Path) -> "str | None":
|
|
556
|
+
"""Return the evidence source when *abs_path* is a real bootstrap entry.
|
|
557
|
+
|
|
558
|
+
A file is a genuine application entry point only if it declares a
|
|
559
|
+
`main(String[])` method OR carries a recognized bootstrap annotation /
|
|
560
|
+
WAR initializer supertype. Returns "main_method" / "bootstrap_annotation"
|
|
561
|
+
accordingly, else None (name alone is never sufficient — see BUG #3).
|
|
562
|
+
"""
|
|
563
|
+
try:
|
|
564
|
+
if abs_path.stat().st_size > _MAX_FILE_SIZE:
|
|
565
|
+
return None
|
|
566
|
+
content = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
567
|
+
except OSError:
|
|
568
|
+
return None
|
|
569
|
+
if _BOOTSTRAP_ANNOTATION_RE.search(content):
|
|
570
|
+
return "bootstrap_annotation"
|
|
571
|
+
if _MAIN_METHOD_RE.search(content):
|
|
572
|
+
return "main_method"
|
|
573
|
+
return None
|
|
574
|
+
|
|
484
575
|
def _augment_deep_java_controllers(self, context: DetectionContext, all_java: list[str]) -> None:
|
|
485
576
|
"""Scan standard Java source roots for *Controller*.java files not in all_java.
|
|
486
577
|
|
sourcecode/detectors/parsers.py
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
from collections.abc import Iterable
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, Optional
|
|
7
8
|
|
|
9
|
+
_MAVEN_PROP_REF_RE = re.compile(r'\$\{([\w.\-]+)\}')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def substitute_maven_properties(text: str, props: "dict[str, str]") -> str:
|
|
13
|
+
"""Resolve Maven ${prop} references in *text* against *props*.
|
|
14
|
+
|
|
15
|
+
Shared single source of truth for Maven property resolution across
|
|
16
|
+
subcommands (BUG #1, Alfresco field test): both the --compact Java-stack
|
|
17
|
+
detector and migrate-check must resolve `${java.version}` → `21` identically.
|
|
18
|
+
Multi-level references (${a} where a=${b}) resolve in up to 3 passes; a
|
|
19
|
+
reference with no matching property is left verbatim (honest, not blanked).
|
|
20
|
+
"""
|
|
21
|
+
if not props or "${" not in text:
|
|
22
|
+
return text
|
|
23
|
+
resolved = text
|
|
24
|
+
for _ in range(3):
|
|
25
|
+
new = _MAVEN_PROP_REF_RE.sub(lambda m: props.get(m.group(1), m.group(0)), resolved)
|
|
26
|
+
if new == resolved:
|
|
27
|
+
break
|
|
28
|
+
resolved = new
|
|
29
|
+
return resolved
|
|
30
|
+
|
|
8
31
|
|
|
9
32
|
def load_json_file(path: Path) -> Optional[dict[str, Any]]:
|
|
10
33
|
"""Carga un fichero JSON sin lanzar si el contenido es invalido."""
|
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"),
|
|
@@ -90,12 +101,33 @@ _TOKEN_CLIENTS: "tuple[tuple[str, str, str], ...]" = (
|
|
|
90
101
|
# JMS / messaging
|
|
91
102
|
("JmsTemplate", "jms", "jmstemplate"),
|
|
92
103
|
("ActiveMQConnectionFactory", "jms", "activemq"),
|
|
104
|
+
# BUG #5 (Alfresco field test): the connection-factory family beyond the plain
|
|
105
|
+
# ActiveMQConnectionFactory — Alfresco wires ActiveMQSslConnectionFactory and
|
|
106
|
+
# pooled/caching factories as Spring @Beans, none of which were recognized.
|
|
107
|
+
("ActiveMQSslConnectionFactory", "jms", "activemq"),
|
|
108
|
+
("PooledConnectionFactory", "jms", "activemq-pooled"),
|
|
109
|
+
("CachingConnectionFactory", "jms", "spring-jms"),
|
|
93
110
|
)
|
|
94
111
|
_TOKEN_RES = tuple(
|
|
95
112
|
(re.compile(r"\b" + re.escape(tok) + r"\b"), kind, client)
|
|
96
113
|
for tok, kind, client in _TOKEN_CLIENTS
|
|
97
114
|
)
|
|
98
115
|
|
|
116
|
+
# BUG #5: Apache Camel route detection. Only applied inside a confirmed Camel file.
|
|
117
|
+
_CAMEL_CONTEXT_RE = re.compile(r"org\.apache\.camel\b|\bextends\s+RouteBuilder\b")
|
|
118
|
+
# A from()/to() endpoint with a literal scheme URI, e.g. from("activemq:queue:foo").
|
|
119
|
+
_CAMEL_URI_RE = re.compile(r'\b(from|to)\s*\(\s*"([a-z][a-z0-9+.\-]*):([^"]*)"')
|
|
120
|
+
# A route anchored by from(<non-string>) — endpoint URI comes from a variable/property.
|
|
121
|
+
_CAMEL_VAR_ROUTE_RE = re.compile(r'\bfrom\s*\(\s*[A-Za-z_]')
|
|
122
|
+
# Camel URI scheme → integration kind (jms-family messaging schemes collapse to "jms").
|
|
123
|
+
_CAMEL_SCHEME_KIND: "dict[str, str]" = {
|
|
124
|
+
"activemq": "jms", "amq": "jms", "jms": "jms", "sjms": "jms", "sjms2": "jms",
|
|
125
|
+
"amqp": "jms", "stomp": "jms",
|
|
126
|
+
"kafka": "kafka", "mqtt": "mqtt", "paho": "mqtt",
|
|
127
|
+
"http": "http", "https": "http", "http4": "http", "rest": "http",
|
|
128
|
+
"smtp": "smtp", "smtps": "smtp",
|
|
129
|
+
}
|
|
130
|
+
|
|
99
131
|
|
|
100
132
|
def _line_of(text: str, idx: int) -> int:
|
|
101
133
|
"""1-based line number of character offset ``idx`` in ``text``."""
|
|
@@ -312,6 +344,28 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
|
|
|
312
344
|
_add(kind, client, url.group(1), rel, lineno)
|
|
313
345
|
break
|
|
314
346
|
|
|
347
|
+
# BUG #5 (Alfresco field test): Apache Camel routes are messaging/integration
|
|
348
|
+
# endpoints declared as a fluent DSL (from(...)/to(...)) inside a RouteBuilder,
|
|
349
|
+
# NOT as JmsTemplate/connection-factory constructs — so they were invisible.
|
|
350
|
+
# Only trust from()/to() inside a confirmed Camel file (import org.apache.camel
|
|
351
|
+
# or `extends RouteBuilder`) to avoid matching unrelated fluent `.to(...)`.
|
|
352
|
+
if _CAMEL_CONTEXT_RE.search(text):
|
|
353
|
+
for m in _CAMEL_URI_RE.finditer(text):
|
|
354
|
+
scheme = m.group(2).lower()
|
|
355
|
+
uri = f"{scheme}:{m.group(3)}"
|
|
356
|
+
kind = _CAMEL_SCHEME_KIND.get(scheme, "messaging")
|
|
357
|
+
_add(kind, f"camel-{scheme}", uri, rel, _line_of(text, m.start()))
|
|
358
|
+
# Routes whose endpoint URI is a variable/property (Alfresco's
|
|
359
|
+
# from(sourceQueue).to(targetTopic)) cannot yield a literal target, but the
|
|
360
|
+
# route IS a real messaging integration point — record it honestly with a
|
|
361
|
+
# null target and low confidence rather than dropping it.
|
|
362
|
+
if not any(r["client"].startswith("camel-") and r["evidence"].startswith(f"{rel}:")
|
|
363
|
+
for r in records):
|
|
364
|
+
vm = _CAMEL_VAR_ROUTE_RE.search(text)
|
|
365
|
+
if vm:
|
|
366
|
+
_add("messaging", "camel-route", None, rel,
|
|
367
|
+
_line_of(text, vm.start()), confidence="low")
|
|
368
|
+
|
|
315
369
|
records.sort(key=lambda r: (r["kind"], r["client"], r["evidence"]))
|
|
316
370
|
|
|
317
371
|
by_kind: "dict[str, int]" = {}
|
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,
|
|
@@ -1119,18 +1168,10 @@ def _resolve_maven_properties(text: str) -> str:
|
|
|
1119
1168
|
props: dict[str, str] = {}
|
|
1120
1169
|
for m in re.finditer(r'<([A-Za-z][\w.\-]*)>\s*([^<${}]+?)\s*</\1>', text):
|
|
1121
1170
|
props[m.group(1)] = m.group(2).strip()
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
for _ in range(3):
|
|
1127
|
-
def _sub(m: re.Match) -> str: # noqa: E306
|
|
1128
|
-
return props.get(m.group(1), m.group(0))
|
|
1129
|
-
resolved_new = re.sub(r'\$\{([\w.\-]+)\}', _sub, resolved)
|
|
1130
|
-
if resolved_new == resolved:
|
|
1131
|
-
break
|
|
1132
|
-
resolved = resolved_new
|
|
1133
|
-
return resolved
|
|
1171
|
+
# Shared ${...} substitution core (BUG #1): identical resolution semantics as
|
|
1172
|
+
# the --compact Java-stack detector.
|
|
1173
|
+
from sourcecode.detectors.parsers import substitute_maven_properties
|
|
1174
|
+
return substitute_maven_properties(text, props)
|
|
1134
1175
|
|
|
1135
1176
|
|
|
1136
1177
|
def _scan_dep_file(text: str, rel_path: str) -> list["MigrationFinding"]:
|
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
|
|
@@ -4434,6 +4735,41 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
4434
4735
|
f"endpoints only."
|
|
4435
4736
|
)
|
|
4436
4737
|
result.setdefault("warnings", []).append(_msg)
|
|
4738
|
+
|
|
4739
|
+
# BUG #7 (Alfresco field test): a bare "total: 0" must never be read by an
|
|
4740
|
+
# automated consumer as "this service exposes no public API". Emit a first-class
|
|
4741
|
+
# structured `zero_result_reason` (mirroring integrations.coverage_note /
|
|
4742
|
+
# security_model_detail.reason) naming the routing patterns that WERE searched and
|
|
4743
|
+
# any non-Spring surface detected, so a 0 is understood as "no RECOGNIZED pattern",
|
|
4744
|
+
# not "no surface". Only when the Spring/annotation surface is genuinely empty.
|
|
4745
|
+
if not endpoints:
|
|
4746
|
+
_searched = (
|
|
4747
|
+
"@RestController/@Controller + @RequestMapping/@GetMapping/@PostMapping/"
|
|
4748
|
+
"@PutMapping/@DeleteMapping/@PatchMapping, JAX-RS @Path, functional "
|
|
4749
|
+
"RouterFunction routing, and OpenAPI-spec-recovered routes"
|
|
4750
|
+
)
|
|
4751
|
+
if _nonspring_total:
|
|
4752
|
+
_fw_names = {
|
|
4753
|
+
"webscripts": "Alfresco WebScripts (XML descriptor + handler class)",
|
|
4754
|
+
"jax_rs": "JAX-RS",
|
|
4755
|
+
"servlets": "mapped Servlets",
|
|
4756
|
+
}
|
|
4757
|
+
_detected = ", ".join(
|
|
4758
|
+
_fw_names.get(k, k) for k, v in _nonspring.items() if v
|
|
4759
|
+
)
|
|
4760
|
+
result["zero_result_reason"] = (
|
|
4761
|
+
f"0 endpoints recognized. Searched: {_searched}. A NON-Spring REST "
|
|
4762
|
+
f"surface WAS detected ({_detected}) but is not statically modeled — "
|
|
4763
|
+
f"do NOT read this as 'no public API'. Inspect that framework's routing "
|
|
4764
|
+
f"(e.g. *.desc.xml descriptors for WebScripts) to enumerate the surface."
|
|
4765
|
+
)
|
|
4766
|
+
else:
|
|
4767
|
+
result["zero_result_reason"] = (
|
|
4768
|
+
f"0 endpoints recognized. Searched: {_searched}. No recognized routing "
|
|
4769
|
+
f"construct was found; a framework this analyzer does not model may "
|
|
4770
|
+
f"still expose an HTTP surface — verify manually before concluding the "
|
|
4771
|
+
f"service has no public API."
|
|
4772
|
+
)
|
|
4437
4773
|
return result
|
|
4438
4774
|
|
|
4439
4775
|
|
sourcecode/serializer.py
CHANGED
|
@@ -702,7 +702,12 @@ def _bootstrap_structured(eps: list) -> "Optional[dict[str, Any]]":
|
|
|
702
702
|
kind = getattr(ep, "kind", "")
|
|
703
703
|
stem = _Path(path).stem
|
|
704
704
|
|
|
705
|
-
|
|
705
|
+
# BUG #3 (Alfresco field test): bootstrap membership is driven by the
|
|
706
|
+
# VERIFIED entry kind ("application" is emitted only when the detector
|
|
707
|
+
# confirmed a main()/bootstrap annotation), never by a `*Application*`
|
|
708
|
+
# filename alone — XSD-generated Application.java model classes must not
|
|
709
|
+
# appear here.
|
|
710
|
+
if kind == "application":
|
|
706
711
|
if path not in seen_b:
|
|
707
712
|
seen_b.add(path)
|
|
708
713
|
bootstrap.append(path)
|
|
@@ -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=5AfqhGbJzMplmuXdrkAIi2e7-XhaUzzC_uaeeU_260s,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=UTcQWF3BGvEZ9I-gPyU9LYDGa-8AmNa_jQYO96hTAqw,288485
|
|
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=9_SeZQXcatdoCfxl9ciSbRJ84IQkSJooAcv0Efh9FgM,21376
|
|
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=Fz12IrMTQWjvbT7melwJGf1aNqQDtzbyRPd_bDA7hdw,105025
|
|
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,21 +47,21 @@ 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=YUuyWIFeUve26RGaWPIk1Qbj04u6sTyR1-qPs-gYznM,245995
|
|
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
|
|
54
54
|
sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
|
|
55
55
|
sourcecode/security_config.py,sha256=_m8oQAy2gP7Ho2I-IIMTFFjFVWbJIGlLEOiAWK2TDjs,3503
|
|
56
56
|
sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIxkw,89176
|
|
57
|
-
sourcecode/serializer.py,sha256=
|
|
57
|
+
sourcecode/serializer.py,sha256=hv5pQQQa20XxPwcKLlLAgvXwWubm-B-cgQept6x-H7U,129373
|
|
58
58
|
sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
|
|
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
|
|
@@ -76,10 +76,10 @@ sourcecode/detectors/elixir.py,sha256=jCpvt5Yi6jvplc80ovRtWh17q-11ZGo9qX7o8b57TJ
|
|
|
76
76
|
sourcecode/detectors/go.py,sha256=2r66uRQfeTWsqxr4HDhT6vExZErby0t46QXLHVBRv9w,2782
|
|
77
77
|
sourcecode/detectors/heuristic.py,sha256=7cRxrip4yIaggYzZJB6ef8yHKh-gHgiH_pXMFcjlyFU,3723
|
|
78
78
|
sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
|
|
79
|
-
sourcecode/detectors/java.py,sha256=
|
|
79
|
+
sourcecode/detectors/java.py,sha256=xlp4mqd26QWJf3MWqmTbD0IND_VEVzPHlGaLC4MNXNA,39028
|
|
80
80
|
sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
|
|
81
81
|
sourcecode/detectors/nodejs.py,sha256=Hg3Gmr7yIMJFiLoDwOTk2wtu00wxIs6kZf-oQujTFUA,13187
|
|
82
|
-
sourcecode/detectors/parsers.py,sha256=
|
|
82
|
+
sourcecode/detectors/parsers.py,sha256=ug9K31tyHqinmv0HkIVQVjdTZpBv67FYKAEf52YXOSM,3178
|
|
83
83
|
sourcecode/detectors/php.py,sha256=W_AQD0WMVDdWHa9h_ilX6W8XSpz0X4ctpMK2WXfXf1I,1887
|
|
84
84
|
sourcecode/detectors/project.py,sha256=ghWWOlqg2_uywzeZX573CCkFWQPlc8bBGvvX1BfIDYI,8315
|
|
85
85
|
sourcecode/detectors/python.py,sha256=i2_Wtk_p0BJx5R8gBQ8NaQByzJ8zEfZkw9NNpKlvOYM,10486
|
|
@@ -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.73.0.dist-info/METADATA,sha256=s089V_1fTpsg8m9aRjhv4mv0miXocG_qV5uAyzr2L60,48339
|
|
108
|
+
sourcecode-1.73.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
109
|
+
sourcecode-1.73.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
|
|
110
|
+
sourcecode-1.73.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
111
|
+
sourcecode-1.73.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|