sourcecode 3.2.0__py3-none-any.whl → 3.2.1__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.
Potentially problematic release.
This version of sourcecode might be problematic. Click here for more details.
- sourcecode/__init__.py +1 -1
- sourcecode/chain_rules.py +49 -8
- sourcecode/cli.py +218 -176
- sourcecode/confidence_analyzer.py +10 -6
- sourcecode/deployment_prefix.py +85 -0
- sourcecode/facts/__init__.py +71 -0
- sourcecode/facts/registry.json +158 -0
- sourcecode/mcp/registry.py +1 -1
- sourcecode/mcp/server.py +1 -1
- sourcecode/metrics_analyzer.py +8 -5
- sourcecode/posture.py +78 -6
- sourcecode/prepare_context.py +44 -19
- sourcecode/reference_facts.py +307 -0
- sourcecode/serializer.py +36 -9
- sourcecode/spring_profiles.py +93 -13
- sourcecode/test_sources.py +178 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/METADATA +100 -10
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/RECORD +21 -17
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""reference_facts.py — one authority for "is this type referenced anywhere?".
|
|
2
|
+
|
|
3
|
+
A static call-graph answers "who calls this class" for the calls it can see. It
|
|
4
|
+
cannot see a class a framework instantiates by reflection, wires by name from
|
|
5
|
+
XML, discovers through the service-loader, or dispatches into because of an
|
|
6
|
+
annotation. Those are not edge cases in a Spring/Jakarta repository: they are how
|
|
7
|
+
the entry points work.
|
|
8
|
+
|
|
9
|
+
So this question has **three** answers, not two:
|
|
10
|
+
|
|
11
|
+
* ``referenced`` — the graph carries an incoming edge, or
|
|
12
|
+
another source file names the type
|
|
13
|
+
* ``unknown_framework_dispatch`` — no static caller, but the type carries a
|
|
14
|
+
dispatch signal (published annotation,
|
|
15
|
+
entry-point signature, or its name wired
|
|
16
|
+
from a configuration file). Whether it runs
|
|
17
|
+
is not decidable here
|
|
18
|
+
* ``no_static_callers`` — no incoming edge and no dispatch signal
|
|
19
|
+
found. Still **not** "dead code": it is the
|
|
20
|
+
absence of evidence, stated as such
|
|
21
|
+
|
|
22
|
+
The previous shape of this fact had two consequences the field caught. It counted
|
|
23
|
+
only types with **no edges in either direction**, while calling the result "zero
|
|
24
|
+
static callers" — a class that calls something but is called by nothing was never
|
|
25
|
+
examined. And when nothing survived that narrower filter it published ``0``, which
|
|
26
|
+
reads as "there is no unreferenced code here" rather than "nothing was found by a
|
|
27
|
+
check that cannot see framework dispatch" (ADR-0008 R9, R5-R8: the count comes
|
|
28
|
+
from the measurement, never from the length of a truncated list).
|
|
29
|
+
|
|
30
|
+
VAI: annotation and configuration names appear only as published open-standard
|
|
31
|
+
DATA (Jakarta / Spring / Java SE vocabulary), the same class of table as the
|
|
32
|
+
security-posture matchers. Nothing branches on a project, vendor or product name,
|
|
33
|
+
and a name found in configuration is recorded as evidence of a possible wire —
|
|
34
|
+
never as proof that a class is alive.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
#: Statuses, in the order a reader cares about them. `unknown_framework_dispatch`
|
|
44
|
+
#: sits between the two decided answers because that is what it means.
|
|
45
|
+
REFERENCED = "referenced"
|
|
46
|
+
UNKNOWN_DISPATCH = "unknown_framework_dispatch"
|
|
47
|
+
NO_STATIC_CALLERS = "no_static_callers"
|
|
48
|
+
STATUSES: tuple[str, ...] = (REFERENCED, UNKNOWN_DISPATCH, NO_STATIC_CALLERS)
|
|
49
|
+
|
|
50
|
+
#: Published signatures and annotations that mean "a framework calls this, not
|
|
51
|
+
#: your code". DATA: a table of open-standard names, extended without changing
|
|
52
|
+
#: any logic. A hit makes the answer *unknown*, never "alive".
|
|
53
|
+
_DYNAMIC_ENTRY_SIGNATURE_RE = re.compile(
|
|
54
|
+
r"\(\s*DispatchContext\b" # service-engine service
|
|
55
|
+
r"|HttpServletRequest\s+\w+\s*,\s*HttpServletResponse" # event / servlet handler
|
|
56
|
+
r"|\bpublic\s+static\s+void\s+main\s*\(" # JVM entry point
|
|
57
|
+
r"|@(?:Scheduled|PostConstruct|PreDestroy|EventListener|Bean|"
|
|
58
|
+
r"RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|"
|
|
59
|
+
r"Path|GET|POST|Provider|ApplicationScoped|Singleton|"
|
|
60
|
+
# Stereotypes and persistence: the container instantiates these itself, so a
|
|
61
|
+
# static call-graph is the wrong instrument to declare them unused.
|
|
62
|
+
r"Component|Service|Repository|Controller|RestController|ControllerAdvice|"
|
|
63
|
+
r"Configuration|SpringBootApplication|Aspect|Entity|Embeddable|"
|
|
64
|
+
r"MappedSuperclass|Converter|WebServlet|WebFilter|WebListener)\b"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: Configuration formats where a framework wires a class by name.
|
|
68
|
+
_CONFIG_REF_EXTS: frozenset = frozenset({".xml", ".properties", ".yml", ".yaml", ".groovy"})
|
|
69
|
+
_SKIP_DIRS: frozenset = frozenset({".git", "build", "out", "target", "node_modules", ".gradle"})
|
|
70
|
+
_SCAN_MAX_FILES: int = 12000
|
|
71
|
+
_SCAN_MAX_BYTES: int = 256 * 1024
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class TypeReference:
|
|
76
|
+
"""One type's reference status, with the evidence that produced it."""
|
|
77
|
+
|
|
78
|
+
fqn: str
|
|
79
|
+
status: str
|
|
80
|
+
basis: str
|
|
81
|
+
type: str = ""
|
|
82
|
+
role: str = ""
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"fqn": self.fqn,
|
|
87
|
+
"type": self.type,
|
|
88
|
+
"role": self.role,
|
|
89
|
+
"status": self.status,
|
|
90
|
+
"basis": self.basis,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ReferenceFacts:
|
|
96
|
+
"""Every type examined, partitioned into the three answers.
|
|
97
|
+
|
|
98
|
+
`by_status` sums to `classes_examined` — the partition is the point. Counts
|
|
99
|
+
are computed here, so no consumer can derive one from a list it truncated
|
|
100
|
+
for display.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
classes_examined: int = 0
|
|
104
|
+
by_status: "dict[str, int]" = field(default_factory=dict)
|
|
105
|
+
entries: "tuple[TypeReference, ...]" = ()
|
|
106
|
+
|
|
107
|
+
def of_status(self, status: str) -> "list[TypeReference]":
|
|
108
|
+
return [e for e in self.entries if e.status == status]
|
|
109
|
+
|
|
110
|
+
def count(self, status: str) -> int:
|
|
111
|
+
return int(self.by_status.get(status, 0))
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def statement(self) -> str:
|
|
115
|
+
"""What the numbers mean, in the words that keep them honest."""
|
|
116
|
+
unknown = self.count(UNKNOWN_DISPATCH)
|
|
117
|
+
none_found = self.count(NO_STATIC_CALLERS)
|
|
118
|
+
if not self.classes_examined:
|
|
119
|
+
return "no class or interface declarations were examined"
|
|
120
|
+
if none_found == 0 and unknown:
|
|
121
|
+
return (
|
|
122
|
+
f"no type is without a caller AND without a dispatch signal; "
|
|
123
|
+
f"{unknown} type(s) have no static caller but carry one, so whether "
|
|
124
|
+
"they are reachable is not decidable from source"
|
|
125
|
+
)
|
|
126
|
+
return (
|
|
127
|
+
f"{none_found} type(s) have no static caller and no dispatch signal "
|
|
128
|
+
f"found; {unknown} more have no static caller but carry one. Neither "
|
|
129
|
+
"group is confirmed dead code"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict:
|
|
133
|
+
return {
|
|
134
|
+
"classes_examined": self.classes_examined,
|
|
135
|
+
**{s: self.count(s) for s in STATUSES},
|
|
136
|
+
"statement": self.statement,
|
|
137
|
+
"note": (
|
|
138
|
+
"A partition of every class/interface declaration: the three counts "
|
|
139
|
+
"sum to classes_examined. `no_static_callers` means no incoming edge "
|
|
140
|
+
"and no dispatch signal was found — absence of evidence, not evidence "
|
|
141
|
+
"of dead code. `unknown_framework_dispatch` means a framework may "
|
|
142
|
+
"invoke it (annotation, entry-point signature, or a name wired from "
|
|
143
|
+
"configuration) and a static call-graph cannot decide."
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read(path: str, limit: int = _SCAN_MAX_BYTES) -> str:
|
|
149
|
+
try:
|
|
150
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
151
|
+
return handle.read(limit)
|
|
152
|
+
except OSError:
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: Identifiers, and `Outer.Nested` qualified references. Two fixed patterns whose
|
|
157
|
+
#: hits are intersected with the candidate names — an alternation over the
|
|
158
|
+
#: candidates instead is 7× slower on a 6 000-type repository and answers the same.
|
|
159
|
+
_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
|
|
160
|
+
_QUALIFIED_TYPE_RE = re.compile(r"\b[A-Z][A-Za-z0-9_$]*\.[A-Z][A-Za-z0-9_$]*")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scan_repository(
|
|
164
|
+
root: Path, simple_names: "set[str]", nested: "dict[str, tuple[str, str]]"
|
|
165
|
+
) -> "tuple[dict[str, str], dict[str, str]]":
|
|
166
|
+
"""One walk, two questions — `(config-wired names, nested types other code names)`.
|
|
167
|
+
|
|
168
|
+
Both scans need the whole tree, and walking it twice doubled the cost of the
|
|
169
|
+
command's slowest step for no answer that a single pass cannot give.
|
|
170
|
+
|
|
171
|
+
Names are matched whole-word, never as substrings: `Order` appearing inside
|
|
172
|
+
`OrderItemImpl` is not a wire, and treating it as one silently marks a
|
|
173
|
+
live-looking class dispatched. A type naming itself proves nothing, so its own
|
|
174
|
+
source file is skipped.
|
|
175
|
+
"""
|
|
176
|
+
config_hits: dict[str, str] = {}
|
|
177
|
+
nested_hits: dict[str, str] = {}
|
|
178
|
+
pending = set(simple_names)
|
|
179
|
+
by_qualified: dict[str, list[str]] = {}
|
|
180
|
+
for fqn, (qualified, _own) in nested.items():
|
|
181
|
+
by_qualified.setdefault(qualified, []).append(fqn)
|
|
182
|
+
|
|
183
|
+
scan_config = bool(pending)
|
|
184
|
+
scan_java = bool(by_qualified)
|
|
185
|
+
if not scan_config and not scan_java:
|
|
186
|
+
return config_hits, nested_hits
|
|
187
|
+
|
|
188
|
+
scanned = 0
|
|
189
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
190
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
191
|
+
for filename in filenames:
|
|
192
|
+
is_java = filename.endswith(".java")
|
|
193
|
+
is_config = os.path.splitext(filename)[1].lower() in _CONFIG_REF_EXTS
|
|
194
|
+
if not ((is_java and scan_java) or (is_config and scan_config)):
|
|
195
|
+
continue
|
|
196
|
+
if scanned >= _SCAN_MAX_FILES:
|
|
197
|
+
break
|
|
198
|
+
full = os.path.join(dirpath, filename)
|
|
199
|
+
text = _read(full)
|
|
200
|
+
scanned += 1
|
|
201
|
+
if not text:
|
|
202
|
+
continue
|
|
203
|
+
rel = os.path.relpath(full, root).replace("\\", "/")
|
|
204
|
+
if is_config and scan_config:
|
|
205
|
+
for hit in set(_IDENTIFIER_RE.findall(text)) & pending:
|
|
206
|
+
pending.discard(hit)
|
|
207
|
+
config_hits[hit] = rel
|
|
208
|
+
scan_config = bool(pending)
|
|
209
|
+
if is_java and scan_java:
|
|
210
|
+
for hit in set(_QUALIFIED_TYPE_RE.findall(text)) & set(by_qualified):
|
|
211
|
+
for fqn in by_qualified[hit]:
|
|
212
|
+
if fqn not in nested_hits and nested[fqn][1] != rel:
|
|
213
|
+
nested_hits[fqn] = rel
|
|
214
|
+
scan_java = len(nested_hits) < len(nested)
|
|
215
|
+
if not scan_config and not scan_java:
|
|
216
|
+
return config_hits, nested_hits
|
|
217
|
+
if scanned >= _SCAN_MAX_FILES:
|
|
218
|
+
break
|
|
219
|
+
return config_hits, nested_hits
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def analyze_type_references(nodes: "list[dict]", root: Path) -> ReferenceFacts:
|
|
223
|
+
"""The reference status of every class/interface node, and why.
|
|
224
|
+
|
|
225
|
+
`nodes` is the graph's node list; only class and interface declarations are
|
|
226
|
+
examined, because a method with no callers is a different question.
|
|
227
|
+
"""
|
|
228
|
+
root = Path(root)
|
|
229
|
+
types = [n for n in nodes or [] if n.get("type") in ("class", "interface") and n.get("fqn")]
|
|
230
|
+
if not types:
|
|
231
|
+
return ReferenceFacts(0, {s: 0 for s in STATUSES}, ())
|
|
232
|
+
|
|
233
|
+
entries: list[TypeReference] = []
|
|
234
|
+
candidates: list[dict] = []
|
|
235
|
+
for node in types:
|
|
236
|
+
if int(node.get("in_degree") or 0) > 0:
|
|
237
|
+
entries.append(TypeReference(
|
|
238
|
+
fqn=str(node["fqn"]), status=REFERENCED,
|
|
239
|
+
basis="incoming edge in the call-graph",
|
|
240
|
+
type=str(node.get("type") or ""), role=str(node.get("role") or "other"),
|
|
241
|
+
))
|
|
242
|
+
else:
|
|
243
|
+
candidates.append(node)
|
|
244
|
+
|
|
245
|
+
# 1. A dispatch signal in the type's own source.
|
|
246
|
+
dispatch_basis: dict[str, str] = {}
|
|
247
|
+
for node in candidates:
|
|
248
|
+
source = node.get("source_file")
|
|
249
|
+
if not source:
|
|
250
|
+
continue
|
|
251
|
+
text = _read(str(root / str(source)))
|
|
252
|
+
if text and _DYNAMIC_ENTRY_SIGNATURE_RE.search(text):
|
|
253
|
+
dispatch_basis[str(node["fqn"])] = (
|
|
254
|
+
f"a framework-dispatch annotation or entry-point signature in {source}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# 2. The type's name wired from a configuration file, and 3. a nested type
|
|
258
|
+
# another source names as `Outer.Nested` — both answered in one walk.
|
|
259
|
+
by_simple: dict[str, list[dict]] = {}
|
|
260
|
+
nested_candidates: dict[str, tuple[str, str]] = {}
|
|
261
|
+
for node in candidates:
|
|
262
|
+
fqn = str(node["fqn"])
|
|
263
|
+
if fqn in dispatch_basis:
|
|
264
|
+
continue
|
|
265
|
+
simple = fqn.rsplit(".", 1)[-1]
|
|
266
|
+
if simple:
|
|
267
|
+
by_simple.setdefault(simple, []).append(node)
|
|
268
|
+
parts = fqn.split(".")
|
|
269
|
+
if len(parts) >= 2 and parts[-2][:1].isupper():
|
|
270
|
+
nested_candidates[fqn] = (
|
|
271
|
+
f"{parts[-2]}.{parts[-1]}",
|
|
272
|
+
str(node.get("source_file") or "").replace("\\", "/"),
|
|
273
|
+
)
|
|
274
|
+
config_hits, nested_hits = _scan_repository(root, set(by_simple), nested_candidates)
|
|
275
|
+
for simple, config_file in config_hits.items():
|
|
276
|
+
for node in by_simple.get(simple, []):
|
|
277
|
+
dispatch_basis[str(node["fqn"])] = f"named in {config_file}"
|
|
278
|
+
|
|
279
|
+
for node in candidates:
|
|
280
|
+
fqn = str(node["fqn"])
|
|
281
|
+
common = {
|
|
282
|
+
"fqn": fqn,
|
|
283
|
+
"type": str(node.get("type") or ""),
|
|
284
|
+
"role": str(node.get("role") or "other"),
|
|
285
|
+
}
|
|
286
|
+
if fqn in nested_hits:
|
|
287
|
+
entries.append(TypeReference(
|
|
288
|
+
status=REFERENCED,
|
|
289
|
+
basis=f"named as a qualified nested type in {nested_hits[fqn]}",
|
|
290
|
+
**common,
|
|
291
|
+
))
|
|
292
|
+
elif fqn in dispatch_basis:
|
|
293
|
+
entries.append(TypeReference(
|
|
294
|
+
status=UNKNOWN_DISPATCH, basis=dispatch_basis[fqn], **common,
|
|
295
|
+
))
|
|
296
|
+
else:
|
|
297
|
+
entries.append(TypeReference(
|
|
298
|
+
status=NO_STATIC_CALLERS,
|
|
299
|
+
basis="no incoming edge, and no dispatch signal was found",
|
|
300
|
+
**common,
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
entries.sort(key=lambda e: (STATUSES.index(e.status), e.fqn))
|
|
304
|
+
counts = {status: sum(1 for e in entries if e.status == status) for status in STATUSES}
|
|
305
|
+
return ReferenceFacts(
|
|
306
|
+
classes_examined=len(types), by_status=counts, entries=tuple(entries),
|
|
307
|
+
)
|
sourcecode/serializer.py
CHANGED
|
@@ -564,19 +564,30 @@ def _spring_profiles_context(sm: "SourceMap") -> "Optional[dict[str, Any]]":
|
|
|
564
564
|
# profile is routinely the security filter chain.
|
|
565
565
|
_declared: list[str] = []
|
|
566
566
|
_conditional: dict[str, list[str]] = {}
|
|
567
|
+
_conditional_beans: list[dict[str, Any]] = []
|
|
567
568
|
_root = getattr(sm.metadata, "analyzed_path", None)
|
|
568
569
|
if _root:
|
|
569
570
|
try:
|
|
570
571
|
from sourcecode.spring_profiles import (
|
|
571
|
-
|
|
572
|
+
conditional_beans_from_sources_detailed,
|
|
572
573
|
profiles_from_config,
|
|
573
574
|
)
|
|
574
575
|
|
|
575
|
-
# One source pass:
|
|
576
|
-
#
|
|
577
|
-
|
|
576
|
+
# One source pass, one fact: every rendering below is a projection
|
|
577
|
+
# of this list, so the map cannot disagree with the expressions
|
|
578
|
+
# (ADR-0008 R1). The keys ARE the annotation-declared profile
|
|
579
|
+
# names, so there is no second scan for the name list either.
|
|
580
|
+
_beans = conditional_beans_from_sources_detailed(
|
|
578
581
|
Path(_root), list(sm.file_paths or [])
|
|
579
582
|
)
|
|
583
|
+
_conditional_beans = [b.to_dict() for b in _beans]
|
|
584
|
+
_by_profile: dict[str, set[str]] = {}
|
|
585
|
+
for _bean in _beans:
|
|
586
|
+
for _token in _bean.profiles:
|
|
587
|
+
_by_profile.setdefault(_token.strip(), set()).add(_bean.symbol)
|
|
588
|
+
_conditional = {
|
|
589
|
+
_p: sorted(_names) for _p, _names in sorted(_by_profile.items())
|
|
590
|
+
}
|
|
580
591
|
_declared = sorted(set(
|
|
581
592
|
profiles_from_config(Path(_root))
|
|
582
593
|
+ [p.lstrip("!").strip() for p in _conditional]
|
|
@@ -619,6 +630,19 @@ def _spring_profiles_context(sm: "SourceMap") -> "Optional[dict[str, Any]]":
|
|
|
619
630
|
result["declared_in_code_or_config"] = _declared
|
|
620
631
|
if _conditional:
|
|
621
632
|
result["profile_conditional_beans"] = _conditional
|
|
633
|
+
if _conditional_beans:
|
|
634
|
+
# The map above buckets by token, so `@Profile("a & !b")` appears
|
|
635
|
+
# under both `a` and `!b` with the operator lost. This list keeps
|
|
636
|
+
# the expression as written — it is what `ask posture` resolves,
|
|
637
|
+
# and the only rendering here that can be read as a condition.
|
|
638
|
+
result["conditional_beans"] = _conditional_beans
|
|
639
|
+
result["conditional_beans_note"] = (
|
|
640
|
+
"Symbols carrying @Profile, with the expression as written. "
|
|
641
|
+
"A symbol listed under a token is conditional on that token as "
|
|
642
|
+
"written — '!m3' means active when m3 is ABSENT. Whether a bean "
|
|
643
|
+
"is actually wired under a given profile set is resolved by "
|
|
644
|
+
"`ask posture --profile <set>`, which is the authority."
|
|
645
|
+
)
|
|
622
646
|
result["detection_basis"] = [
|
|
623
647
|
"application-{profile} file naming",
|
|
624
648
|
"spring.config.activate.on-profile / spring.profiles in config documents",
|
|
@@ -2211,11 +2235,14 @@ def agent_view(sm: SourceMap, *, full: bool = False) -> dict[str, Any]:
|
|
|
2211
2235
|
if sm.code_notes_summary.adr_count > 0:
|
|
2212
2236
|
signals["adrs"] = sm.code_notes_summary.adr_count
|
|
2213
2237
|
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2238
|
+
# One authority for "does this repository have tests" (ADR-0008 R1). The
|
|
2239
|
+
# substring rule this replaces answered `true` for a repository whose
|
|
2240
|
+
# production code lives in a package called `test`, contradicting the
|
|
2241
|
+
# `analysis_gaps` block of the same document.
|
|
2242
|
+
from sourcecode.test_sources import analyze_test_sources
|
|
2243
|
+
|
|
2244
|
+
_test_facts = analyze_test_sources(list(sm.file_paths or []))
|
|
2245
|
+
if _test_facts.has_tests:
|
|
2219
2246
|
signals["has_tests"] = True
|
|
2220
2247
|
|
|
2221
2248
|
# Semantic hotspots (populated when --semantics was passed)
|
sourcecode/spring_profiles.py
CHANGED
|
@@ -153,18 +153,58 @@ _TYPE_AFTER_ANN_RE = re.compile(
|
|
|
153
153
|
r"\b(?:class|interface|enum|record)\s+(?P<name>[A-Za-z_$][\w$]*)"
|
|
154
154
|
)
|
|
155
155
|
|
|
156
|
+
# What may stand between `@Profile(...)` and the type declaration it annotates:
|
|
157
|
+
# further annotations and modifiers, nothing else. Anything beyond this means
|
|
158
|
+
# the annotation belongs to a member, not to the type.
|
|
159
|
+
_ANNOTATED_TYPE_RE = re.compile(
|
|
160
|
+
r"\A(?:\s|//[^\n]*\n|/\*.*?\*/"
|
|
161
|
+
r"|@[\w.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?"
|
|
162
|
+
r"|public|protected|private|static|final|abstract|strictfp|sealed|non-sealed)*"
|
|
163
|
+
r"\s*(?:class|interface|enum|record)\s+(?P<name>[A-Za-z_$][\w$]*)",
|
|
164
|
+
re.DOTALL,
|
|
165
|
+
)
|
|
156
166
|
|
|
157
|
-
|
|
167
|
+
# The member an annotation sits on, when it is not a type: the declared name of
|
|
168
|
+
# the next method or field.
|
|
169
|
+
_MEMBER_AFTER_ANN_RE = re.compile(
|
|
170
|
+
r"\A(?:\s|//[^\n]*\n|/\*.*?\*/"
|
|
171
|
+
r"|@[\w.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?"
|
|
172
|
+
r"|public|protected|private|static|final|abstract|synchronized|native|transient|volatile|default"
|
|
173
|
+
r"|<[^<>]*>|[\w.$]+(?:\s*<[^<>]*>)?(?:\s*\[\s*\])*)*?"
|
|
174
|
+
r"\s(?P<name>[A-Za-z_$][\w$]*)\s*[(=;]",
|
|
175
|
+
re.DOTALL,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _enclosing_type_name(text: str, pos: int, fallback: str) -> str:
|
|
180
|
+
"""Name of the type declaration that encloses ``pos``."""
|
|
181
|
+
last = None
|
|
182
|
+
for m in _TYPE_AFTER_ANN_RE.finditer(text, 0, pos):
|
|
183
|
+
last = m
|
|
184
|
+
return last.group("name") if last else fallback
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def conditional_beans_from_sources_detailed(
|
|
158
188
|
root: Path, file_paths: "Optional[list[str]]" = None
|
|
159
|
-
) -> "
|
|
160
|
-
"""`
|
|
189
|
+
) -> "list[ProfileConditionalBean]":
|
|
190
|
+
"""`@Profile` symbols and the expression each one declares, from sources.
|
|
191
|
+
|
|
192
|
+
The single derivation of this fact for callers that hold no CIR;
|
|
193
|
+
:func:`profile_conditional_beans` is the same fact read from the IR, and
|
|
194
|
+
both must agree — a symbol this reports under a profile must be a symbol
|
|
195
|
+
`posture` resolves the same way.
|
|
161
196
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
197
|
+
**Attribution rule (the defect this exists to prevent):** a `@Profile` is
|
|
198
|
+
attributed to the declaration it actually annotates. Only annotations and
|
|
199
|
+
modifiers may stand between it and a type declaration; anything else means
|
|
200
|
+
the annotation sits on a member, which is then reported as ``Type#member``.
|
|
201
|
+
The previous scan searched for the *next* type declaration anywhere ahead
|
|
202
|
+
and fell back to the file name, so a `@Profile("m3")` on a `@Bean` method
|
|
203
|
+
inside `DevSecurityConfig` was reported as `DevSecurityConfig` being
|
|
204
|
+
conditional on `m3` — the exact opposite of its class-level
|
|
205
|
+
`@Profile("!m3")`, in the same document, contradicting `posture`.
|
|
166
206
|
"""
|
|
167
|
-
|
|
207
|
+
beans: list[ProfileConditionalBean] = []
|
|
168
208
|
try:
|
|
169
209
|
paths = (
|
|
170
210
|
[Path(root) / p for p in file_paths if p.endswith(".java")]
|
|
@@ -172,7 +212,7 @@ def conditional_beans_from_sources(
|
|
|
172
212
|
else list(Path(root).rglob("*.java"))
|
|
173
213
|
)
|
|
174
214
|
except Exception:
|
|
175
|
-
return
|
|
215
|
+
return []
|
|
176
216
|
for path in paths[:20000]:
|
|
177
217
|
try:
|
|
178
218
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
@@ -180,11 +220,51 @@ def conditional_beans_from_sources(
|
|
|
180
220
|
continue
|
|
181
221
|
if "@Profile" not in text:
|
|
182
222
|
continue
|
|
223
|
+
try:
|
|
224
|
+
rel = str(path.relative_to(Path(root)))
|
|
225
|
+
except Exception:
|
|
226
|
+
rel = str(path)
|
|
183
227
|
for match in _PROFILE_ANN_RE.finditer(text):
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
228
|
+
tail = text[match.end():match.end() + 4000]
|
|
229
|
+
type_match = _ANNOTATED_TYPE_RE.match(tail)
|
|
230
|
+
if type_match:
|
|
231
|
+
symbol = type_match.group("name")
|
|
232
|
+
else:
|
|
233
|
+
member_match = _MEMBER_AFTER_ANN_RE.match(tail)
|
|
234
|
+
if not member_match:
|
|
235
|
+
# Neither a type nor a readable member: report nothing
|
|
236
|
+
# rather than attribute the annotation to a guess.
|
|
237
|
+
continue
|
|
238
|
+
owner = _enclosing_type_name(text, match.start(), path.stem)
|
|
239
|
+
symbol = f"{owner}#{member_match.group('name')}"
|
|
240
|
+
args = match.group("args")
|
|
241
|
+
names = _split_profile_expression(args)
|
|
242
|
+
if not names:
|
|
243
|
+
continue
|
|
244
|
+
beans.append(ProfileConditionalBean(
|
|
245
|
+
symbol=symbol,
|
|
246
|
+
profiles=tuple(names),
|
|
247
|
+
source_file=rel,
|
|
248
|
+
expression=args.strip(),
|
|
249
|
+
))
|
|
250
|
+
beans.sort(key=lambda b: (b.symbol, b.profiles))
|
|
251
|
+
return beans
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def conditional_beans_from_sources(
|
|
255
|
+
root: Path, file_paths: "Optional[list[str]]" = None
|
|
256
|
+
) -> "dict[str, list[str]]":
|
|
257
|
+
"""`{profile: [symbol, …]}` — a projection of
|
|
258
|
+
:func:`conditional_beans_from_sources_detailed`.
|
|
259
|
+
|
|
260
|
+
Kept as the map shape published in the signals payload. It loses the
|
|
261
|
+
operators (`"a & !b"` appears under `a` and under `!b`), which is why the
|
|
262
|
+
detailed list carries `expression` and is what a reader should consume.
|
|
263
|
+
"""
|
|
264
|
+
out: dict[str, set[str]] = {}
|
|
265
|
+
for bean in conditional_beans_from_sources_detailed(root, file_paths):
|
|
266
|
+
for token in bean.profiles:
|
|
267
|
+
out.setdefault(token.strip(), set()).add(bean.symbol)
|
|
188
268
|
return {profile: sorted(names) for profile, names in sorted(out.items())}
|
|
189
269
|
|
|
190
270
|
|