sourcecode 2.5.2__py3-none-any.whl → 2.5.4__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/architecture_analyzer.py +101 -21
- sourcecode/canonical_ir.py +3 -2
- sourcecode/classifier.py +4 -2
- sourcecode/detectors/java.py +27 -10
- sourcecode/reconciliation.py +201 -0
- sourcecode/repository_ir.py +443 -66
- sourcecode/semantic_impact_engine.py +8 -5
- sourcecode/semantic_services.py +5 -3
- sourcecode/spring_impact.py +213 -24
- sourcecode/summarizer.py +35 -14
- {sourcecode-2.5.2.dist-info → sourcecode-2.5.4.dist-info}/METADATA +1 -1
- {sourcecode-2.5.2.dist-info → sourcecode-2.5.4.dist-info}/RECORD +16 -15
- {sourcecode-2.5.2.dist-info → sourcecode-2.5.4.dist-info}/WHEEL +0 -0
- {sourcecode-2.5.2.dist-info → sourcecode-2.5.4.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.5.2.dist-info → sourcecode-2.5.4.dist-info}/licenses/LICENSE +0 -0
sourcecode/repository_ir.py
CHANGED
|
@@ -115,6 +115,11 @@ class EvidenceBundle:
|
|
|
115
115
|
|
|
116
116
|
_PKG_RE = re.compile(r'^package\s+([\w.]+)\s*;', re.MULTILINE)
|
|
117
117
|
_IMPORT_RE = re.compile(r'^import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;', re.MULTILINE)
|
|
118
|
+
# Single-type imports only (`import a.b.Foo;`) — the declaration that binds a simple
|
|
119
|
+
# name to exactly one type. Static imports bind a member, not a type, and wildcards
|
|
120
|
+
# bind nothing resolvable; both fail to match ([\w.]+ cannot span the space before
|
|
121
|
+
# a static FQN, and `*` is outside the class).
|
|
122
|
+
_SINGLE_TYPE_IMPORT_RE = re.compile(r'^\s*import\s+([\w.]+)\s*;', re.MULTILINE)
|
|
118
123
|
_ANN_RE = re.compile(r'^(@[\w.]+)')
|
|
119
124
|
_ANN_WITH_ARGS_RE = re.compile(
|
|
120
125
|
r'^(@[\w.]+)\s*'
|
|
@@ -125,9 +130,15 @@ _ANN_WITH_ARGS_RE = re.compile(
|
|
|
125
130
|
|
|
126
131
|
_CLASS_DECL_RE = re.compile(
|
|
127
132
|
r'(?:^|(?<=\s))'
|
|
128
|
-
r'(?P<kind>class|interface|enum|@interface)\s+'
|
|
133
|
+
r'(?P<kind>class|interface|enum|record|@interface)\s+'
|
|
129
134
|
r'(?P<name>[A-Z]\w*)'
|
|
130
135
|
r'(?:\s*<[^{;]*?(?=>|\{))?'
|
|
136
|
+
# P1-F: record component list `record Point(int x, int y)`. `[^{;]*` is greedy
|
|
137
|
+
# to the LAST ')' before the body brace, so annotated components with
|
|
138
|
+
# argument parens (`@JsonProperty("x") String name`) stay inside the group.
|
|
139
|
+
# `record` is a contextual keyword: requiring `Name(` + a terminal '{' keeps
|
|
140
|
+
# identifiers named record (variables, methods) from matching.
|
|
141
|
+
r'(?:\s*\((?P<components>[^{;]*)\))?'
|
|
131
142
|
r'(?:\s+extends\s+(?P<extends>[\w.<>?,\s]+?))?'
|
|
132
143
|
r'(?:\s+implements\s+(?P<implements>[\w.<>?,\s]+?))?'
|
|
133
144
|
r'(?:\s+permits\s+[\w,\s]+?)?'
|
|
@@ -140,7 +151,7 @@ _CLASS_DECL_RE = re.compile(
|
|
|
140
151
|
# edges the impact graph needs. `[^{;]*?` keeps the match within a single declaration
|
|
141
152
|
# head (stops at the body `{` or a `;`), matching the precision of _CLASS_DECL_RE.
|
|
142
153
|
_INHERIT_PRESCAN_RE = re.compile(
|
|
143
|
-
r'\b(?:class|interface)\s+[A-Z]\w*[^{;]*?\b(?:extends|implements)\b'
|
|
154
|
+
r'\b(?:class|interface|record)\s+[A-Z]\w*[^{;]*?\b(?:extends|implements)\b'
|
|
144
155
|
)
|
|
145
156
|
|
|
146
157
|
_METHOD_DECL_RE = re.compile(
|
|
@@ -192,6 +203,84 @@ _JAXRS_HTTP_ANNOTATIONS: frozenset[str] = frozenset({
|
|
|
192
203
|
# Annotations whose args contain a path value and must be captured.
|
|
193
204
|
_PATH_ANNOTATIONS: frozenset[str] = frozenset({"@Path"})
|
|
194
205
|
|
|
206
|
+
# P1-E cheap pre-filter: only a zero-symbol file containing a type keyword
|
|
207
|
+
# anywhere pays the linear mask + declaration check (reconciliation rule).
|
|
208
|
+
_TYPE_DECL_KEYWORD_RE = re.compile(r"\b(?:class|interface|enum|record)\b")
|
|
209
|
+
|
|
210
|
+
# P1-G: an inline-CLOSED block comment inside a declaration line.
|
|
211
|
+
_INLINE_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/")
|
|
212
|
+
|
|
213
|
+
# SIM-1: `return new X(...)` inside a method body — the concrete type a locator
|
|
214
|
+
# method instantiates when its DECLARED return type is unusable (`Object`).
|
|
215
|
+
# Tolerates a package-qualified head (`return new org.x.Y(...)`).
|
|
216
|
+
_RETURN_NEW_RE = re.compile(r"\breturn\s+new\s+(?:[a-z_]\w*\.)*([A-Z]\w*)\s*[(<]")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _note_return_new(annotation_values: dict, text: str) -> None:
|
|
220
|
+
"""Accumulate distinct instantiated-return types on a method's annotation_values."""
|
|
221
|
+
for _rm in _RETURN_NEW_RE.finditer(text):
|
|
222
|
+
_t = _rm.group(1)
|
|
223
|
+
_prev = annotation_values.get("_returns_new", "")
|
|
224
|
+
_seen = _prev.split(",") if _prev else []
|
|
225
|
+
if _t not in _seen:
|
|
226
|
+
annotation_values["_returns_new"] = ",".join(_seen + [_t])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _scan_line_comments(line: str, in_block: bool) -> tuple[str, bool]:
|
|
230
|
+
"""Remove block-comment spans from one line, STRING-LITERAL AWARE (P1-G).
|
|
231
|
+
|
|
232
|
+
Naive `"/*" in line` checks misread comment markers inside string literals
|
|
233
|
+
(`@Consumes("*/*")`, eureka DiscoveryJerseyProvider) and poison the block-
|
|
234
|
+
comment state for the rest of the file. This scanner tracks quotes so
|
|
235
|
+
markers inside strings/chars are code, drops `//` tails, and returns the
|
|
236
|
+
residual code text plus the new in-block state. Callers fast-path: only a
|
|
237
|
+
line containing a marker (or scanned while inside a block) pays this loop.
|
|
238
|
+
"""
|
|
239
|
+
out: list[str] = []
|
|
240
|
+
i, n = 0, len(line)
|
|
241
|
+
in_str = in_chr = False
|
|
242
|
+
while i < n:
|
|
243
|
+
c = line[i]
|
|
244
|
+
if in_block:
|
|
245
|
+
if c == "*" and i + 1 < n and line[i + 1] == "/":
|
|
246
|
+
in_block = False
|
|
247
|
+
i += 2
|
|
248
|
+
continue
|
|
249
|
+
i += 1
|
|
250
|
+
continue
|
|
251
|
+
if in_str or in_chr:
|
|
252
|
+
out.append(c)
|
|
253
|
+
if c == "\\" and i + 1 < n:
|
|
254
|
+
out.append(line[i + 1])
|
|
255
|
+
i += 2
|
|
256
|
+
continue
|
|
257
|
+
if in_str and c == '"':
|
|
258
|
+
in_str = False
|
|
259
|
+
elif in_chr and c == "'":
|
|
260
|
+
in_chr = False
|
|
261
|
+
i += 1
|
|
262
|
+
continue
|
|
263
|
+
if c == '"':
|
|
264
|
+
in_str = True
|
|
265
|
+
out.append(c)
|
|
266
|
+
i += 1
|
|
267
|
+
continue
|
|
268
|
+
if c == "'":
|
|
269
|
+
in_chr = True
|
|
270
|
+
out.append(c)
|
|
271
|
+
i += 1
|
|
272
|
+
continue
|
|
273
|
+
if c == "/" and i + 1 < n:
|
|
274
|
+
if line[i + 1] == "/":
|
|
275
|
+
break # line comment — rest of the line is not code
|
|
276
|
+
if line[i + 1] == "*":
|
|
277
|
+
in_block = True
|
|
278
|
+
i += 2
|
|
279
|
+
continue
|
|
280
|
+
out.append(c)
|
|
281
|
+
i += 1
|
|
282
|
+
return "".join(out), in_block
|
|
283
|
+
|
|
195
284
|
# Security / authorization annotations whose args must be captured.
|
|
196
285
|
# Includes standard Jakarta EE, JAX-RS, Quarkus/MicroProfile, and custom patterns.
|
|
197
286
|
_PERMISSION_ANNOTATIONS: frozenset[str] = frozenset({
|
|
@@ -558,7 +647,7 @@ _ANN_PREFIX_RE = re.compile(r'^(?:@\w+\s*(?:\([^)]*\))?\s*)+')
|
|
|
558
647
|
_STRING_LITERAL_RE = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
|
|
559
648
|
|
|
560
649
|
# Module-level cache for class-keyword detection (avoids recompilation per _extract_symbols call)
|
|
561
|
-
_CLASS_KW_RE = re.compile(r'\b(?:class|interface|enum)\s+[A-Z]')
|
|
650
|
+
_CLASS_KW_RE = re.compile(r'\b(?:class|interface|enum|record)\s+[A-Z]')
|
|
562
651
|
|
|
563
652
|
|
|
564
653
|
# ---------------------------------------------------------------------------
|
|
@@ -769,6 +858,9 @@ def _extract_symbols(
|
|
|
769
858
|
pending_anns: list[str] = []
|
|
770
859
|
pending_ann_values: dict[str, str] = {}
|
|
771
860
|
in_block_comment = False
|
|
861
|
+
# SIM-1: (symbol index, declaration-line depth) of the method whose body the
|
|
862
|
+
# scan is currently inside — target for `return new X(...)` attribution.
|
|
863
|
+
_ret_new_owner: Optional[tuple[int, int]] = None
|
|
772
864
|
|
|
773
865
|
# BUG-PARSER-001: normalize multi-line class declarations where the opening brace
|
|
774
866
|
# appears on a continuation line (e.g. "implements A,\n B, C {").
|
|
@@ -832,14 +924,18 @@ def _extract_symbols(
|
|
|
832
924
|
cur_line = _normalized_src_lines[_ni] if _ni < len(_normalized_src_lines) else None
|
|
833
925
|
stripped = line.strip()
|
|
834
926
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
in_block_comment = True
|
|
927
|
+
# P1-G: comment handling. Old code dropped ANY line containing a block-
|
|
928
|
+
# comment marker, silently erasing declarations glued to comments
|
|
929
|
+
# (`class Proc /* impl */{`, `*/public class X {`) — and a naive marker
|
|
930
|
+
# check misreads markers inside string literals (`@Consumes("*/*")`).
|
|
931
|
+
# Fast path: a line with no marker while outside a block skips the scan.
|
|
932
|
+
if in_block_comment and "*/" not in stripped:
|
|
842
933
|
continue
|
|
934
|
+
if in_block_comment or "/*" in stripped:
|
|
935
|
+
stripped, in_block_comment = _scan_line_comments(stripped, in_block_comment)
|
|
936
|
+
stripped = stripped.strip()
|
|
937
|
+
if not stripped:
|
|
938
|
+
continue
|
|
843
939
|
if stripped.startswith("//") or stripped.startswith("*"):
|
|
844
940
|
continue
|
|
845
941
|
|
|
@@ -873,27 +969,38 @@ def _extract_symbols(
|
|
|
873
969
|
modifiers = _extract_modifiers(stripped[:cls_m.start()])
|
|
874
970
|
extends_str = (cls_m.group("extends") or "").strip()
|
|
875
971
|
implements_str = (cls_m.group("implements") or "").strip()
|
|
972
|
+
# P1-F: record components carry the type's state — their types are
|
|
973
|
+
# real dependencies (imports_used) and the list belongs in the
|
|
974
|
+
# signature so consumers see the shape without re-reading source.
|
|
975
|
+
components_str = (cls_m.group("components") or "").strip()
|
|
876
976
|
|
|
877
977
|
sig_types = ([extends_str] if extends_str else []) + (
|
|
878
978
|
[s.strip() for s in implements_str.split(",") if s.strip()]
|
|
879
979
|
if implements_str else []
|
|
880
980
|
)
|
|
881
|
-
used = _resolve_types_from_text(
|
|
981
|
+
used = _resolve_types_from_text(
|
|
982
|
+
" ".join(sig_types) + (" " + components_str if components_str else ""),
|
|
983
|
+
import_map,
|
|
984
|
+
)
|
|
882
985
|
|
|
883
986
|
sym_type = "interface" if kind_kw == "interface" else "class"
|
|
884
987
|
|
|
885
|
-
# symbol_kind distinguishes enum/annotation from class/interface
|
|
988
|
+
# symbol_kind distinguishes enum/annotation/record from class/interface
|
|
886
989
|
if kind_kw == "enum":
|
|
887
990
|
sym_kind = "enum"
|
|
888
991
|
elif kind_kw == "@interface":
|
|
889
992
|
sym_kind = "annotation"
|
|
890
993
|
elif kind_kw == "interface":
|
|
891
994
|
sym_kind = "interface"
|
|
995
|
+
elif kind_kw == "record":
|
|
996
|
+
sym_kind = "record"
|
|
892
997
|
else:
|
|
893
998
|
sym_kind = "class"
|
|
894
999
|
|
|
895
1000
|
_stable_id = _compute_stable_id(package, name, sym_kind, name)
|
|
896
1001
|
_sig_parts = [kind_kw, name]
|
|
1002
|
+
if components_str:
|
|
1003
|
+
_sig_parts.append(f"({components_str})")
|
|
897
1004
|
if extends_str:
|
|
898
1005
|
_sig_parts.append(f"extends {extends_str}")
|
|
899
1006
|
if implements_str:
|
|
@@ -1003,6 +1110,10 @@ def _extract_symbols(
|
|
|
1003
1110
|
))
|
|
1004
1111
|
pending_anns = []
|
|
1005
1112
|
pending_ann_values = {}
|
|
1113
|
+
# SIM-1: remember this method as the enclosing body for
|
|
1114
|
+
# `return new X(...)` attribution (locator child binding).
|
|
1115
|
+
_ret_new_owner = (len(symbols) - 1, depth)
|
|
1116
|
+
_note_return_new(symbols[-1].annotation_values, stripped)
|
|
1006
1117
|
depth += net
|
|
1007
1118
|
_pop_closed(class_stack, depth)
|
|
1008
1119
|
continue
|
|
@@ -1084,6 +1195,15 @@ def _extract_symbols(
|
|
|
1084
1195
|
_pop_closed(class_stack, depth)
|
|
1085
1196
|
continue
|
|
1086
1197
|
|
|
1198
|
+
# SIM-1: attribute `return new X(...)` body statements to the enclosing
|
|
1199
|
+
# method. Owner is cleared as soon as a line at (or above) the method's
|
|
1200
|
+
# declaration depth is seen — the method body has closed.
|
|
1201
|
+
if _ret_new_owner is not None:
|
|
1202
|
+
if depth <= _ret_new_owner[1]:
|
|
1203
|
+
_ret_new_owner = None
|
|
1204
|
+
elif "return new" in stripped:
|
|
1205
|
+
_note_return_new(symbols[_ret_new_owner[0]].annotation_values, stripped)
|
|
1206
|
+
|
|
1087
1207
|
pending_anns = []
|
|
1088
1208
|
pending_ann_values = {}
|
|
1089
1209
|
depth += net
|
|
@@ -1238,7 +1358,7 @@ _ANN_REF_TOKEN_RE = re.compile(r'\b[A-Z]\w*(?:\.\w+)+')
|
|
|
1238
1358
|
# the class whose body encloses them.
|
|
1239
1359
|
_ANN_OR_DECL_OR_BRACE_RE = re.compile(
|
|
1240
1360
|
r'@([A-Z]\w*)\s*\('
|
|
1241
|
-
r'|\b(?:class|interface|enum)\s+([A-Z]\w*)'
|
|
1361
|
+
r'|\b(?:class|interface|enum|record)\s+([A-Z]\w*)'
|
|
1242
1362
|
r'|([{}])'
|
|
1243
1363
|
)
|
|
1244
1364
|
|
|
@@ -2000,33 +2120,85 @@ def _strip_leading_annotations(line: str) -> str:
|
|
|
2000
2120
|
return line.strip()
|
|
2001
2121
|
|
|
2002
2122
|
|
|
2003
|
-
def
|
|
2004
|
-
"""{
|
|
2005
|
-
|
|
2006
|
-
|
|
2123
|
+
def _file_import_map(source: str) -> dict[str, str]:
|
|
2124
|
+
"""{simple name -> FQN} for the file's single-type imports — the per-file fact
|
|
2125
|
+
that disambiguates a declared type whose simple name is not unique in the repo
|
|
2126
|
+
(Broadleaf declares two `AdminSecurityService`; only the import says which)."""
|
|
2127
|
+
out: dict[str, str] = {}
|
|
2128
|
+
for fqn in _SINGLE_TYPE_IMPORT_RE.findall(source):
|
|
2129
|
+
if "." in fqn:
|
|
2130
|
+
out[fqn.rsplit(".", 1)[-1]] = fqn
|
|
2131
|
+
return out
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
def _record_component_pairs(components: str) -> list[tuple[str, str]]:
|
|
2135
|
+
"""(name, declared_type) pairs from a record component list (P1-F).
|
|
2136
|
+
|
|
2137
|
+
Depth-aware comma split so generic commas (`Map<String, X> m`) and
|
|
2138
|
+
annotation-argument parens don't break components apart; leading
|
|
2139
|
+
annotations stripped; varargs normalized to arrays.
|
|
2140
|
+
"""
|
|
2141
|
+
parts: list[str] = []
|
|
2142
|
+
buf: list[str] = []
|
|
2143
|
+
d = 0
|
|
2144
|
+
for ch in components:
|
|
2145
|
+
if ch in "<(":
|
|
2146
|
+
d += 1
|
|
2147
|
+
elif ch in ">)":
|
|
2148
|
+
d -= 1
|
|
2149
|
+
if ch == "," and d == 0:
|
|
2150
|
+
parts.append("".join(buf))
|
|
2151
|
+
buf = []
|
|
2152
|
+
else:
|
|
2153
|
+
buf.append(ch)
|
|
2154
|
+
if buf:
|
|
2155
|
+
parts.append("".join(buf))
|
|
2156
|
+
out: list[tuple[str, str]] = []
|
|
2157
|
+
for p in parts:
|
|
2158
|
+
p = re.sub(r"@[\w.]+(?:\([^)]*\))?\s*", "", p).strip()
|
|
2159
|
+
p = p.replace("...", "[] ")
|
|
2160
|
+
toks = p.rsplit(None, 1)
|
|
2161
|
+
if len(toks) == 2 and toks[1].isidentifier():
|
|
2162
|
+
out.append((toks[1], toks[0].strip()))
|
|
2163
|
+
return out
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
def _extract_field_types(
|
|
2167
|
+
source: str, *, imports: Optional[dict[str, str]] = None
|
|
2168
|
+
) -> dict[str, dict[str, str]]:
|
|
2169
|
+
"""{class_fqn -> {field_name -> declared_type}} for EVERY field (annotated or
|
|
2170
|
+
plain), the fact that binds an invocation receiver to a typed dependency.
|
|
2171
|
+
Complements the injection graph, which only sees @-annotated /
|
|
2007
2172
|
constructor-injected fields — plain setter/XML-injected fields (e.g. openmrs
|
|
2008
2173
|
`private TemplateDAO templateDAO;`) are invisible to it but common.
|
|
2009
2174
|
|
|
2175
|
+
The declared type is the file-import-resolved FQN when the owning file imports
|
|
2176
|
+
it explicitly, else the bare simple name (wildcard import, same package, or
|
|
2177
|
+
JDK-implicit). Consumers must accept both shapes — see `_resolve_declared_type`,
|
|
2178
|
+
which finishes resolution against the repo symbol table.
|
|
2179
|
+
|
|
2010
2180
|
A hash-neutral side-stream (no new symbols/edges; not a cir_hash input),
|
|
2011
2181
|
emitted only under emit_body_facts. Class-scope only: a field sits directly in
|
|
2012
2182
|
a class body (depth == class-decl depth + 1), never inside a method body, so
|
|
2013
2183
|
method locals are excluded. `_FIELD_DECL_RE` (name followed by ';'/'=') is
|
|
2014
2184
|
disjoint from method decls (name followed by '(')."""
|
|
2015
2185
|
out: dict[str, dict[str, str]] = {}
|
|
2186
|
+
if imports is None:
|
|
2187
|
+
imports = _file_import_map(source)
|
|
2016
2188
|
depth = 0
|
|
2017
2189
|
class_stack: list[tuple[str, int]] = []
|
|
2018
2190
|
package = ""
|
|
2019
2191
|
in_block = False
|
|
2020
2192
|
for raw in source.splitlines():
|
|
2021
2193
|
stripped = raw.strip()
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
in_block = False
|
|
2025
|
-
continue
|
|
2026
|
-
if stripped.startswith("/*"):
|
|
2027
|
-
if "*/" not in stripped:
|
|
2028
|
-
in_block = True
|
|
2194
|
+
# P1-G: string-literal-aware comment handling (see _extract_symbols).
|
|
2195
|
+
if in_block and "*/" not in stripped:
|
|
2029
2196
|
continue
|
|
2197
|
+
if in_block or "/*" in stripped:
|
|
2198
|
+
stripped, in_block = _scan_line_comments(stripped, in_block)
|
|
2199
|
+
stripped = stripped.strip()
|
|
2200
|
+
if not stripped:
|
|
2201
|
+
continue
|
|
2030
2202
|
if not stripped or stripped.startswith("//") or stripped.startswith("*"):
|
|
2031
2203
|
continue
|
|
2032
2204
|
if stripped.startswith("@"):
|
|
@@ -2048,6 +2220,16 @@ def _extract_field_types(source: str) -> dict[str, dict[str, str]]:
|
|
|
2048
2220
|
fqn = f"{class_stack[-1][0]}.{name}" if class_stack else (
|
|
2049
2221
|
f"{package}.{name}" if package else name)
|
|
2050
2222
|
class_stack.append((fqn, depth))
|
|
2223
|
+
# P1-F: record components are implicit final fields — register them
|
|
2224
|
+
# so a `component.method()` receiver binds to its declared type the
|
|
2225
|
+
# same way an ordinary injected field does.
|
|
2226
|
+
_comps = (cls_m.group("components") or "").strip()
|
|
2227
|
+
if cls_m.group("kind") == "record" and _comps:
|
|
2228
|
+
for _cn, _ct in _record_component_pairs(_comps):
|
|
2229
|
+
_outer, _ = _type_tokens(_ct)
|
|
2230
|
+
if _cn and _outer and _cn not in _JAVA_KEYWORDS:
|
|
2231
|
+
out.setdefault(fqn, {}).setdefault(
|
|
2232
|
+
_cn, imports.get(_outer, _outer))
|
|
2051
2233
|
depth += net
|
|
2052
2234
|
_pop_closed(class_stack, depth)
|
|
2053
2235
|
continue
|
|
@@ -2060,7 +2242,8 @@ def _extract_field_types(source: str) -> dict[str, dict[str, str]]:
|
|
|
2060
2242
|
fname = fld_m.group("name")
|
|
2061
2243
|
outer, _ = _type_tokens(fld_m.group("type").strip())
|
|
2062
2244
|
if fname and outer and fname not in _JAVA_KEYWORDS:
|
|
2063
|
-
out.setdefault(class_stack[-1][0], {}).setdefault(
|
|
2245
|
+
out.setdefault(class_stack[-1][0], {}).setdefault(
|
|
2246
|
+
fname, imports.get(outer, outer))
|
|
2064
2247
|
depth += net
|
|
2065
2248
|
_pop_closed(class_stack, depth)
|
|
2066
2249
|
return out
|
|
@@ -2839,14 +3022,22 @@ def _build_jaxrs_locator_map(symbols: list["SymbolRecord"]) -> dict[str, list[tu
|
|
|
2839
3022
|
locator_path = _parse_route_path(sym.annotation_values.get("@Path", ""))
|
|
2840
3023
|
|
|
2841
3024
|
ret = sym.return_type.strip()
|
|
2842
|
-
if not ret:
|
|
2843
|
-
continue
|
|
2844
3025
|
# Simplify: strip package prefix and generics
|
|
2845
|
-
ret_simple = ret.split(".")[-1].split("<")[0].strip()
|
|
2846
|
-
if not ret_simple or ret_simple in _LOCATOR_SKIP_RETURN_TYPES
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
3026
|
+
ret_simple = ret.split(".")[-1].split("<")[0].strip() if ret else ""
|
|
3027
|
+
if (not ret_simple or ret_simple in _LOCATOR_SKIP_RETURN_TYPES
|
|
3028
|
+
or ret_simple[0].islower()):
|
|
3029
|
+
# SIM-1: JAX-RS locators commonly declare `Object` while the body
|
|
3030
|
+
# instantiates the concrete sub-resource (keycloak
|
|
3031
|
+
# `@Path("auth") public Object auth() { return new
|
|
3032
|
+
# AuthorizationEndpoint(...); }`). When the declared type is
|
|
3033
|
+
# unusable, bind through the instantiated type — but only when it
|
|
3034
|
+
# is UNAMBIGUOUS (exactly one distinct `return new` type; a
|
|
3035
|
+
# dynamic/multi-branch locator stays unresolved, honestly).
|
|
3036
|
+
_rn = sym.annotation_values.get("_returns_new", "")
|
|
3037
|
+
_cands = [t for t in _rn.split(",") if t] if _rn else []
|
|
3038
|
+
if len(set(_cands)) != 1 or _cands[0] in _LOCATOR_SKIP_RETURN_TYPES:
|
|
3039
|
+
continue
|
|
3040
|
+
ret_simple = _cands[0]
|
|
2850
3041
|
|
|
2851
3042
|
parent_fqn = _enclosing_class(sym.symbol)
|
|
2852
3043
|
parent_simple = parent_fqn.split(".")[-1]
|
|
@@ -2878,27 +3069,40 @@ def _resolve_jaxrs_prefixes(
|
|
|
2878
3069
|
return own_prefixes
|
|
2879
3070
|
|
|
2880
3071
|
new_visited = visited | {cls_simple}
|
|
2881
|
-
|
|
3072
|
+
# Rooted = composed through a parent that has a resolvable prefix (or a
|
|
3073
|
+
# class-level @Path). Unrooted = the parent chain dead-ends in a class with
|
|
3074
|
+
# no static mount point (keycloak: OIDCLoginProtocolService is mounted via a
|
|
3075
|
+
# fully dynamic `getProtocol` SPI locator).
|
|
3076
|
+
rooted_prefixes: list[str] = []
|
|
3077
|
+
unrooted_prefixes: list[str] = []
|
|
2882
3078
|
|
|
2883
3079
|
for parent_simple, locator_path in locator_map[cls_simple]:
|
|
2884
3080
|
parent_full = _resolve_jaxrs_prefixes(parent_simple, class_info, locator_map, new_visited)
|
|
2885
|
-
#
|
|
2886
|
-
#
|
|
2887
|
-
# (e.g. DefaultClientsApi implements ClientsApi)
|
|
2888
|
-
#
|
|
2889
|
-
#
|
|
2890
|
-
#
|
|
3081
|
+
# Impl/unrooted parent split: a parent resolving to only empty prefixes
|
|
3082
|
+
# with no class-level @Path is either a concrete impl class duplicating
|
|
3083
|
+
# its interface's locator (e.g. DefaultClientsApi implements ClientsApi)
|
|
3084
|
+
# or a genuinely unrooted chain. When ANY rooted composition exists it
|
|
3085
|
+
# wins and the unrooted ones are dropped (the impl-duplicate case, as
|
|
3086
|
+
# before). When NONE exists, the RELATIVE composition is still far more
|
|
3087
|
+
# truthful than collapsing 39 endpoints to path "/" (SIM-1): emit
|
|
3088
|
+
# locator_path + own path with the unresolved head simply absent.
|
|
2891
3089
|
_parent_has_path_ann = class_info.get(parent_simple, {}).get("has_path_ann", False)
|
|
2892
3090
|
_non_empty_parent = [p for p in parent_full if p]
|
|
2893
|
-
if not _non_empty_parent and not _parent_has_path_ann:
|
|
2894
|
-
continue
|
|
2895
3091
|
use_parent_paths = _non_empty_parent if _non_empty_parent else parent_full
|
|
3092
|
+
_bucket = (
|
|
3093
|
+
rooted_prefixes if (_non_empty_parent or _parent_has_path_ann)
|
|
3094
|
+
else unrooted_prefixes
|
|
3095
|
+
)
|
|
2896
3096
|
for pp in use_parent_paths:
|
|
2897
3097
|
for op in own_prefixes:
|
|
2898
3098
|
combined = _join_path_segments(pp, locator_path, op)
|
|
2899
|
-
|
|
3099
|
+
_bucket.append(combined)
|
|
2900
3100
|
|
|
2901
|
-
|
|
3101
|
+
if rooted_prefixes:
|
|
3102
|
+
return rooted_prefixes
|
|
3103
|
+
if unrooted_prefixes:
|
|
3104
|
+
return unrooted_prefixes
|
|
3105
|
+
return own_prefixes
|
|
2902
3106
|
|
|
2903
3107
|
|
|
2904
3108
|
# ---------------------------------------------------------------------------
|
|
@@ -3624,6 +3828,7 @@ def _compute_analysis_gaps(
|
|
|
3624
3828
|
spring_summary: dict,
|
|
3625
3829
|
route_surface: list[dict],
|
|
3626
3830
|
relations: list[RelationEdge],
|
|
3831
|
+
unparsed_type_files: Optional[list[str]] = None,
|
|
3627
3832
|
) -> list[dict]:
|
|
3628
3833
|
"""Compute structural analysis gaps — real system failures, not cosmetic issues."""
|
|
3629
3834
|
gaps: list[dict] = []
|
|
@@ -3636,6 +3841,26 @@ def _compute_analysis_gaps(
|
|
|
3636
3841
|
})
|
|
3637
3842
|
return gaps
|
|
3638
3843
|
|
|
3844
|
+
# P1-E: parse-coverage reconciliation (file inventory × symbol index). A file
|
|
3845
|
+
# that declares a type but produced zero symbols is silently invisible to the
|
|
3846
|
+
# graph, callers and endpoints — an agent reading "0 callers" cannot know the
|
|
3847
|
+
# defining or calling code was never modeled unless this gap says so.
|
|
3848
|
+
if unparsed_type_files:
|
|
3849
|
+
from sourcecode.reconciliation import reconcile_extraction_evidence
|
|
3850
|
+
extracted = frozenset(s.declaring_file for s in symbols)
|
|
3851
|
+
for rf in reconcile_extraction_evidence(
|
|
3852
|
+
type_decl_files=frozenset(unparsed_type_files),
|
|
3853
|
+
extracted_files=extracted,
|
|
3854
|
+
):
|
|
3855
|
+
_sample = list(rf.symbols[:5])
|
|
3856
|
+
gaps.append({
|
|
3857
|
+
"area": rf.rule,
|
|
3858
|
+
"reason": rf.detail + " Files: " + ", ".join(_sample)
|
|
3859
|
+
+ (", …" if len(rf.symbols) > 5 else ""),
|
|
3860
|
+
"impact": "high",
|
|
3861
|
+
"files": list(rf.symbols),
|
|
3862
|
+
})
|
|
3863
|
+
|
|
3639
3864
|
controllers = spring_summary.get("controllers", [])
|
|
3640
3865
|
if controllers and not route_surface:
|
|
3641
3866
|
gaps.append({
|
|
@@ -3686,6 +3911,7 @@ def _assemble(
|
|
|
3686
3911
|
spring_summary: dict, # noqa: ARG001 — used internally via _spring_role on symbols
|
|
3687
3912
|
route_diffs: list[dict] | None = None,
|
|
3688
3913
|
custom_security: "tuple[CustomSecuritySpec, ...]" = (),
|
|
3914
|
+
unparsed_type_files: Optional[list[str]] = None,
|
|
3689
3915
|
) -> dict:
|
|
3690
3916
|
"""Phase 5: Final assembly — single deterministic output contract."""
|
|
3691
3917
|
sorted_syms = sorted(symbols, key=lambda s: s.symbol)
|
|
@@ -3950,7 +4176,10 @@ def _assemble(
|
|
|
3950
4176
|
_route_surface = _build_route_surface(
|
|
3951
4177
|
sorted_syms, route_diffs, extends_map=_extends_map, custom_security=custom_security
|
|
3952
4178
|
)
|
|
3953
|
-
_analysis_gaps = _compute_analysis_gaps(
|
|
4179
|
+
_analysis_gaps = _compute_analysis_gaps(
|
|
4180
|
+
sorted_syms, spring_summary, _route_surface, sorted_rels,
|
|
4181
|
+
unparsed_type_files=unparsed_type_files,
|
|
4182
|
+
)
|
|
3954
4183
|
|
|
3955
4184
|
# Detect filter-based security model for the assembled IR.
|
|
3956
4185
|
# Stored here so CIR projections (project_endpoint_surface) can read it without
|
|
@@ -4393,6 +4622,121 @@ def extract_file_ir(
|
|
|
4393
4622
|
return _assemble(symbols, relations, changed_symbols, spring_summary, route_diffs)
|
|
4394
4623
|
|
|
4395
4624
|
|
|
4625
|
+
def _resolve_declared_type(
|
|
4626
|
+
declared: str,
|
|
4627
|
+
owner_cls: str,
|
|
4628
|
+
type_simple_to_fqn: dict[str, list[str]],
|
|
4629
|
+
known_types: set[str],
|
|
4630
|
+
) -> Optional[str]:
|
|
4631
|
+
"""The in-repo class FQN a field's declared type names, or None when it cannot
|
|
4632
|
+
be pinned to exactly one.
|
|
4633
|
+
|
|
4634
|
+
`declared` is what `_extract_field_types` recorded: an import-resolved FQN when
|
|
4635
|
+
the owning file imported the type explicitly, else a bare simple name. Resolution
|
|
4636
|
+
follows Java scoping order — an explicit import shadows a same-package type
|
|
4637
|
+
(JLS 6.4.1), which in turn beats a repo-wide simple-name guess. A simple name
|
|
4638
|
+
that is neither same-package nor unique stays unresolved rather than guessed
|
|
4639
|
+
(INV honesty): the wrong caller edge is worse than a missing one.
|
|
4640
|
+
"""
|
|
4641
|
+
if not declared:
|
|
4642
|
+
return None
|
|
4643
|
+
if "." in declared:
|
|
4644
|
+
return declared if declared in known_types else None
|
|
4645
|
+
if "." in owner_cls:
|
|
4646
|
+
scoped = f"{owner_cls.rsplit('.', 1)[0]}.{declared}"
|
|
4647
|
+
if scoped in known_types:
|
|
4648
|
+
return scoped
|
|
4649
|
+
fqns = type_simple_to_fqn.get(declared)
|
|
4650
|
+
return fqns[0] if fqns and len(fqns) == 1 else None
|
|
4651
|
+
|
|
4652
|
+
|
|
4653
|
+
def _type_indices(
|
|
4654
|
+
symbols: list[SymbolRecord],
|
|
4655
|
+
) -> tuple[dict[str, list[str]], set[str], dict[str, dict[str, list[str]]]]:
|
|
4656
|
+
"""(type simple name → in-repo class FQN(s), all class FQNs, class FQN →
|
|
4657
|
+
{method name → [method FQN]}) — the symbol-table views the call-edge post-passes
|
|
4658
|
+
join against."""
|
|
4659
|
+
type_simple_to_fqn: dict[str, list[str]] = {}
|
|
4660
|
+
known_types: set[str] = set()
|
|
4661
|
+
class_methods: dict[str, dict[str, list[str]]] = {}
|
|
4662
|
+
for s in symbols:
|
|
4663
|
+
if s.type in ("class", "interface", "enum", "record") and "#" not in s.symbol:
|
|
4664
|
+
type_simple_to_fqn.setdefault(s.symbol.rsplit(".", 1)[-1], []).append(s.symbol)
|
|
4665
|
+
known_types.add(s.symbol)
|
|
4666
|
+
elif s.symbol_kind == "method" and "#" in s.symbol:
|
|
4667
|
+
cls = _enclosing_class(s.symbol)
|
|
4668
|
+
name = s.symbol.rsplit("#", 1)[1]
|
|
4669
|
+
class_methods.setdefault(cls, {}).setdefault(name, []).append(s.symbol)
|
|
4670
|
+
return type_simple_to_fqn, known_types, class_methods
|
|
4671
|
+
|
|
4672
|
+
|
|
4673
|
+
def _static_typed_call_edges(
|
|
4674
|
+
body_facts: dict[str, list[dict]],
|
|
4675
|
+
class_imports: dict[str, dict[str, str]],
|
|
4676
|
+
field_types: dict[str, dict[str, str]],
|
|
4677
|
+
symbols: list[SymbolRecord],
|
|
4678
|
+
) -> list[RelationEdge]:
|
|
4679
|
+
"""Method-level `calls` edges for a static invocation through a type receiver
|
|
4680
|
+
(`Type.method(...)`).
|
|
4681
|
+
|
|
4682
|
+
The per-file static scan in `_build_relations` throws the callee name away and
|
|
4683
|
+
attributes the call to every class in the file, so it can only say
|
|
4684
|
+
`CallerClass --calls--> TargetClass`. Nothing ever lands on the method node, so
|
|
4685
|
+
`impact-chain Utility#method` resolved 0 callers however many static call sites
|
|
4686
|
+
existed: Broadleaf's `BroadleafCurrencyUtils#getNumberFormatFromCache` has 5+
|
|
4687
|
+
in-repo callers and reported none, which P1-B could only flag as unproven
|
|
4688
|
+
because the edge itself was missing.
|
|
4689
|
+
|
|
4690
|
+
Joins facts already extracted (no new parse — Prime Directive): the invocation
|
|
4691
|
+
atom already carries `receiver`/`callee` for `Type.method(...)`, the owner file's
|
|
4692
|
+
imports resolve the receiver to a type (`_resolve_declared_type`), and the symbol
|
|
4693
|
+
table supplies that type's methods. A receiver that is a field, a lower-case
|
|
4694
|
+
variable, or an unresolvable/library type yields nothing — unresolved stays
|
|
4695
|
+
unresolved (INV honesty).
|
|
4696
|
+
|
|
4697
|
+
These edges add to the coarse class-level ones; see the call site for why
|
|
4698
|
+
substituting them loses reach.
|
|
4699
|
+
"""
|
|
4700
|
+
if not body_facts or not class_imports:
|
|
4701
|
+
return []
|
|
4702
|
+
|
|
4703
|
+
type_simple_to_fqn, known_types, class_methods = _type_indices(symbols)
|
|
4704
|
+
edges: list[RelationEdge] = []
|
|
4705
|
+
for owner_fqn in sorted(body_facts):
|
|
4706
|
+
owner_cls = _enclosing_class(owner_fqn)
|
|
4707
|
+
imports = class_imports.get(owner_cls)
|
|
4708
|
+
if imports is None:
|
|
4709
|
+
continue
|
|
4710
|
+
fields = field_types.get(owner_cls) or {}
|
|
4711
|
+
for atom in body_facts[owner_fqn]:
|
|
4712
|
+
if atom.get("fact") != "invocation":
|
|
4713
|
+
continue
|
|
4714
|
+
receiver, callee = atom.get("receiver"), atom.get("callee")
|
|
4715
|
+
if not receiver or not callee or receiver == "this":
|
|
4716
|
+
continue
|
|
4717
|
+
if not receiver[0].isupper():
|
|
4718
|
+
continue # variable/field receiver — `_receiver_typed_call_edges`
|
|
4719
|
+
if receiver in fields:
|
|
4720
|
+
continue # a field shadows the type name → the field pass owns it
|
|
4721
|
+
target_cls = _resolve_declared_type(
|
|
4722
|
+
imports.get(receiver, receiver), owner_cls, type_simple_to_fqn, known_types
|
|
4723
|
+
)
|
|
4724
|
+
if not target_cls or target_cls == owner_cls:
|
|
4725
|
+
continue
|
|
4726
|
+
target_methods = class_methods.get(target_cls, {}).get(callee)
|
|
4727
|
+
if not target_methods:
|
|
4728
|
+
continue # callee not declared on the target type
|
|
4729
|
+
for mfqn in target_methods:
|
|
4730
|
+
edges.append(RelationEdge(
|
|
4731
|
+
from_symbol=owner_fqn,
|
|
4732
|
+
to_symbol=mfqn,
|
|
4733
|
+
type="calls",
|
|
4734
|
+
confidence="medium",
|
|
4735
|
+
evidence={"type": "method_call", "value": f"{receiver}.{callee}(...)"},
|
|
4736
|
+
))
|
|
4737
|
+
return edges
|
|
4738
|
+
|
|
4739
|
+
|
|
4396
4740
|
def _receiver_typed_call_edges(
|
|
4397
4741
|
body_facts: dict[str, list[dict]],
|
|
4398
4742
|
field_types: dict[str, dict[str, str]],
|
|
@@ -4412,30 +4756,22 @@ def _receiver_typed_call_edges(
|
|
|
4412
4756
|
Joins facts already extracted (no new parse — Prime Directive):
|
|
4413
4757
|
- invocation atom (owner method → {receiver, callee}) [body_facts]
|
|
4414
4758
|
- declared type of `receiver` on the owner's class [field_types]
|
|
4415
|
-
- target type resolved to a SINGLE in-repo class FQN
|
|
4759
|
+
- target type resolved to a SINGLE in-repo class FQN, by the owner
|
|
4760
|
+
file's import first and only then by simple name [symbols]
|
|
4416
4761
|
- callee resolved to that class's method(s) of that name [symbols]
|
|
4417
4762
|
|
|
4418
4763
|
Emits `owner_method --calls--> target_class#callee` (every overload of the name —
|
|
4419
4764
|
arity is not resolved by name alone, matching `_intra_class_call_edges`). Anything
|
|
4420
|
-
unresolved — a local variable (no field entry), a JDK/library type,
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
a repo-wide post-pass because
|
|
4765
|
+
unresolved — a local variable (no field entry), a JDK/library type, a simple name
|
|
4766
|
+
that is still ambiguous after `_resolve_declared_type`, or a callee the target
|
|
4767
|
+
class does not declare — yields nothing: an unresolved fact stays unresolved
|
|
4768
|
+
rather than being guessed (INV honesty). Runs as a repo-wide post-pass because
|
|
4769
|
+
the target type + its methods live in other files.
|
|
4424
4770
|
"""
|
|
4425
4771
|
if not body_facts or not field_types:
|
|
4426
4772
|
return []
|
|
4427
4773
|
|
|
4428
|
-
|
|
4429
|
-
type_simple_to_fqn: dict[str, list[str]] = {}
|
|
4430
|
-
class_methods: dict[str, dict[str, list[str]]] = {}
|
|
4431
|
-
for s in symbols:
|
|
4432
|
-
if s.type in ("class", "interface", "enum", "record") and "#" not in s.symbol:
|
|
4433
|
-
type_simple_to_fqn.setdefault(s.symbol.rsplit(".", 1)[-1], []).append(s.symbol)
|
|
4434
|
-
elif s.symbol_kind == "method" and "#" in s.symbol:
|
|
4435
|
-
cls = _enclosing_class(s.symbol)
|
|
4436
|
-
name = s.symbol.rsplit("#", 1)[1]
|
|
4437
|
-
class_methods.setdefault(cls, {}).setdefault(name, []).append(s.symbol)
|
|
4438
|
-
|
|
4774
|
+
type_simple_to_fqn, known_types, class_methods = _type_indices(symbols)
|
|
4439
4775
|
edges: list[RelationEdge] = []
|
|
4440
4776
|
for owner_fqn in sorted(body_facts):
|
|
4441
4777
|
owner_cls = _enclosing_class(owner_fqn)
|
|
@@ -4448,13 +4784,13 @@ def _receiver_typed_call_edges(
|
|
|
4448
4784
|
receiver, callee = atom.get("receiver"), atom.get("callee")
|
|
4449
4785
|
if not receiver or receiver == "this" or not callee:
|
|
4450
4786
|
continue
|
|
4451
|
-
|
|
4452
|
-
if not
|
|
4787
|
+
declared = fmap.get(receiver)
|
|
4788
|
+
if not declared:
|
|
4453
4789
|
continue # not a typed field (local / unknown)
|
|
4454
|
-
|
|
4455
|
-
|
|
4790
|
+
target_cls = _resolve_declared_type(
|
|
4791
|
+
declared, owner_cls, type_simple_to_fqn, known_types)
|
|
4792
|
+
if not target_cls:
|
|
4456
4793
|
continue # unresolved / ambiguous type → skip (honest)
|
|
4457
|
-
target_cls = tfqns[0]
|
|
4458
4794
|
if target_cls == owner_cls:
|
|
4459
4795
|
continue # intra-class handled by _intra_class_call_edges
|
|
4460
4796
|
target_methods = class_methods.get(target_cls, {}).get(callee)
|
|
@@ -4508,6 +4844,7 @@ def build_repo_ir(
|
|
|
4508
4844
|
_all_class_type_refs: dict[str, list[dict]] = {}
|
|
4509
4845
|
_all_annotation_arg_values: dict[str, dict[str, dict[str, str]]] = {}
|
|
4510
4846
|
_all_field_types: dict[str, dict[str, str]] = {}
|
|
4847
|
+
_all_class_imports: dict[str, dict[str, str]] = {}
|
|
4511
4848
|
|
|
4512
4849
|
# H-04: prefetch changed-file list once; avoids O(n) `git show` calls.
|
|
4513
4850
|
# _since_changed=None means git unavailable → fall back to per-file fetch.
|
|
@@ -4542,6 +4879,9 @@ def build_repo_ir(
|
|
|
4542
4879
|
# (package, imports, annotation-ref-pairs) for the reference-graph post-pass,
|
|
4543
4880
|
# collected from BOTH fully-parsed and pre-scan-skipped files (BUG #2).
|
|
4544
4881
|
_ann_ref_sources: list[tuple[str, list[str], list[tuple[str, str]]]] = []
|
|
4882
|
+
# P1-E: files whose masked source declares a type but extraction yielded
|
|
4883
|
+
# zero symbols — fed to the parse_coverage reconciliation rule at assembly.
|
|
4884
|
+
_unparsed_type_files: list[str] = []
|
|
4545
4885
|
for rel_path in sorted(file_paths):
|
|
4546
4886
|
abs_path = root / rel_path
|
|
4547
4887
|
try:
|
|
@@ -4555,6 +4895,15 @@ def build_repo_ir(
|
|
|
4555
4895
|
source, rel_path, extra_capture=_extra_capture
|
|
4556
4896
|
)
|
|
4557
4897
|
all_symbols.extend(symbols)
|
|
4898
|
+
# P1-E (parse-coverage reconciliation): a file that declares a type but
|
|
4899
|
+
# yielded zero symbols is silently invisible to the graph. Record the
|
|
4900
|
+
# candidate here (source is already in memory; masking only runs for the
|
|
4901
|
+
# rare zero-symbol file) — the reconciliation rule turns the set into an
|
|
4902
|
+
# analysis_gaps entry at assembly.
|
|
4903
|
+
if not symbols and _TYPE_DECL_KEYWORD_RE.search(source):
|
|
4904
|
+
from sourcecode.reconciliation import declares_java_type
|
|
4905
|
+
if declares_java_type(_mask_java(source)):
|
|
4906
|
+
_unparsed_type_files.append(rel_path)
|
|
4558
4907
|
_per_file.append((rel_path, source, package, raw_imports, symbols))
|
|
4559
4908
|
if "@" in source:
|
|
4560
4909
|
_parsed_refs = _extract_annotation_type_refs(source, package)
|
|
@@ -4580,7 +4929,14 @@ def build_repo_ir(
|
|
|
4580
4929
|
_all_guard_facts.update(_extract_guard_facts(symbols, source))
|
|
4581
4930
|
_all_class_type_refs.update(_extract_class_type_refs(symbols))
|
|
4582
4931
|
_all_annotation_arg_values.update(_parse_annotation_arg_values(symbols))
|
|
4583
|
-
|
|
4932
|
+
_f_imports = _file_import_map(source)
|
|
4933
|
+
_all_field_types.update(_extract_field_types(source, imports=_f_imports))
|
|
4934
|
+
# Every type declared in this file resolves simple names through this
|
|
4935
|
+
# file's imports — the fact `_static_typed_call_edges` needs to bind a
|
|
4936
|
+
# `Type.method(...)` receiver to a type (P1-A's rule, applied to statics).
|
|
4937
|
+
for _s in symbols:
|
|
4938
|
+
if _s.type in ("class", "interface", "enum", "record") and "#" not in _s.symbol:
|
|
4939
|
+
_all_class_imports[_s.symbol] = _f_imports
|
|
4584
4940
|
|
|
4585
4941
|
old_source: Optional[str] = None
|
|
4586
4942
|
if since:
|
|
@@ -4626,6 +4982,26 @@ def build_repo_ir(
|
|
|
4626
4982
|
_receiver_typed_call_edges(_all_body_facts, _all_field_types, all_symbols)
|
|
4627
4983
|
)
|
|
4628
4984
|
|
|
4985
|
+
# Static calls through a type receiver (`Type.method(...)`). The per-file scan can
|
|
4986
|
+
# only reach the target CLASS (it discards the callee), so no method-level query on
|
|
4987
|
+
# a static utility ever resolved a caller.
|
|
4988
|
+
#
|
|
4989
|
+
# These edges are ADDED, never substituted for the class-level ones. Superseding the
|
|
4990
|
+
# class edge was tried and measurably wrong: body facts cover method bodies only, so
|
|
4991
|
+
# a static call in a field initializer or static block produces no invocation atom,
|
|
4992
|
+
# and dropping the class edge there erased the coupling — Broadleaf's
|
|
4993
|
+
# BroadleafCurrencyUtils lost transitive reach (1325 indirect callers → 561) and all
|
|
4994
|
+
# 20 affected endpoints. The class-level edge is a coarse over-approximation (the
|
|
4995
|
+
# scan attributes a call to every class in the file); this pass is precise but
|
|
4996
|
+
# partial. Keeping both leaves a caller reported at both granularities (`X` and
|
|
4997
|
+
# `X#m`) — redundant, but each statement is true, and no reach is lost.
|
|
4998
|
+
if emit_body_facts:
|
|
4999
|
+
all_relations.extend(
|
|
5000
|
+
_static_typed_call_edges(
|
|
5001
|
+
_all_body_facts, _all_class_imports, _all_field_types, all_symbols
|
|
5002
|
+
)
|
|
5003
|
+
)
|
|
5004
|
+
|
|
4629
5005
|
spring_summary = _build_spring_summary(all_symbols)
|
|
4630
5006
|
|
|
4631
5007
|
# Deduplicate relations
|
|
@@ -4645,6 +5021,7 @@ def build_repo_ir(
|
|
|
4645
5021
|
ir = _assemble(
|
|
4646
5022
|
all_symbols, unique_relations, all_changed, spring_summary, route_diffs_arg,
|
|
4647
5023
|
custom_security=_custom_sec_tuple,
|
|
5024
|
+
unparsed_type_files=_unparsed_type_files,
|
|
4648
5025
|
)
|
|
4649
5026
|
|
|
4650
5027
|
# Fase 21: merge OpenAPI-spec-recovered routes for interface-defined controllers
|