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.
@@ -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
- if in_block_comment:
836
- if "*/" in stripped:
837
- in_block_comment = False
838
- continue
839
- if "/*" in stripped:
840
- if "*/" not in stripped:
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(" ".join(sig_types), import_map)
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 _extract_field_types(source: str) -> dict[str, dict[str, str]]:
2004
- """{class_fqn -> {field_name -> declared_type_simple_name}} for EVERY field
2005
- (annotated or plain), the fact that binds an invocation receiver to a typed
2006
- dependency. Complements the injection graph, which only sees @-annotated /
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
- if in_block:
2023
- if "*/" in stripped:
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(fname, outer)
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
- continue
2848
- if ret_simple[0].islower(): # lowercase primitive/variable, not a class
2849
- continue
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
- full_prefixes: list[str] = []
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
- # Skip implementation/unrooted parents: if the parent resolves to only empty
2886
- # prefixes AND has no class-level @Path annotation, it is a concrete impl class
2887
- # (e.g. DefaultClientsApi implements ClientsApi) that duplicates a locator method
2888
- # from its interface. Including it would produce spurious short paths like /{id}
2889
- # alongside the correctly-resolved full path. The interface version is already
2890
- # in the locator_map and will produce the correct full path.
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
- full_prefixes.append(combined)
3099
+ _bucket.append(combined)
2900
3100
 
2901
- return full_prefixes if full_prefixes else own_prefixes
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(sorted_syms, spring_summary, _route_surface, sorted_rels)
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 [symbols]
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, an ambiguous
4421
- simple name, or a callee the target class does not declare — yields nothing: an
4422
- unresolved fact stays unresolved rather than being guessed (INV honesty). Runs as
4423
- a repo-wide post-pass because the target type + its methods live in other files.
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
- # type simple name → in-repo class FQN(s); class FQN → {method name → [method FQN]}
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
- tsimple = fmap.get(receiver)
4452
- if not tsimple:
4787
+ declared = fmap.get(receiver)
4788
+ if not declared:
4453
4789
  continue # not a typed field (local / unknown)
4454
- tfqns = type_simple_to_fqn.get(tsimple)
4455
- if not tfqns or len(tfqns) != 1:
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
- _all_field_types.update(_extract_field_types(source))
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