sourcecode 1.69.0__py3-none-any.whl → 1.71.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.69.0"
3
+ __version__ = "1.71.0"
@@ -38,7 +38,15 @@ _CODE_EXTENSIONS = {
38
38
  }
39
39
  _GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
40
40
 
41
- _TEST_DIRS: frozenset[str] = frozenset({"tests", "test", "spec", "specs", "__tests__", "e2e"})
41
+ _TEST_DIRS: frozenset[str] = frozenset({
42
+ "tests", "test", "spec", "specs", "__tests__", "e2e",
43
+ # Gradle/Maven test-fixture source roots are test code, not runtime architecture.
44
+ "testfixtures", "testfixture",
45
+ })
46
+ # Always-vendored asset trees — never a backend code layer at any depth.
47
+ _ASSET_DIRS: frozenset[str] = frozenset({
48
+ "node_modules", "bower_components",
49
+ })
42
50
  _BENCHMARK_DIRS: frozenset[str] = frozenset({
43
51
  "benchmark", "benchmarks", "bench",
44
52
  "example", "examples",
@@ -50,7 +58,7 @@ _BENCHMARK_DIRS: frozenset[str] = frozenset({
50
58
  _DOCS_DIRS: frozenset[str] = frozenset({"docs", "doc", "documentation", "wiki"})
51
59
  _TOOLING_DIRS: frozenset[str] = frozenset({"scripts", "script", "tools", "tool", "ci"})
52
60
  # All dirs that are not part of the runtime source architecture
53
- _NON_SOURCE_DIRS: frozenset[str] = _TEST_DIRS | _BENCHMARK_DIRS | _DOCS_DIRS | _TOOLING_DIRS
61
+ _NON_SOURCE_DIRS: frozenset[str] = _TEST_DIRS | _BENCHMARK_DIRS | _DOCS_DIRS | _TOOLING_DIRS | _ASSET_DIRS
54
62
 
55
63
  # Exact file stems that signal a specific architectural layer
56
64
  _LAYER_STEM_EXACT: dict[str, str] = {
@@ -150,6 +158,15 @@ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
150
158
  },
151
159
  }
152
160
 
161
+ # Layer keys a pattern MUST match to qualify, regardless of score. BUG (JobRunr
162
+ # field test): "mvc" was inferred from a controller-ish dir (`handlers`) plus a
163
+ # `model` dir, with NO view layer — that is not MVC (a library with no templates/
164
+ # pages/components is at most layered). MVC's defining trait is the View, so require
165
+ # it; without it the pattern falls through to layered / a weaker match.
166
+ _PATTERN_REQUIRED_KEYS: dict[str, frozenset[str]] = {
167
+ "mvc": frozenset({"view"}),
168
+ }
169
+
153
170
  # Higher value = wins when score ties
154
171
  _PATTERN_PRIORITY: dict[str, int] = {
155
172
  "cqrs": 8,
@@ -245,8 +262,12 @@ class ArchitectureAnalyzer:
245
262
  # Step 2: domain clustering
246
263
  domains = self._cluster_domains(filtered)
247
264
 
248
- # Step 3: layer detection
249
- pattern, layers = self._detect_layers(filtered)
265
+ # Step 3: layer detection. Feed the FULL path list (not the code-extension
266
+ # `filtered` set): a View layer is template files (.html/.jinja/.ejs…) that
267
+ # `filtered` would drop, hiding the very layer that distinguishes MVC.
268
+ # _detect_layers applies its own non-source/asset/test dir filtering, so
269
+ # bundled frontend trees and test fixtures are still excluded.
270
+ pattern, layers = self._detect_layers(sm.file_paths)
250
271
  if pattern in (None, "flat", "unknown"):
251
272
  if pattern == "flat":
252
273
  limitations.append("Layer pattern not detected: project has a flat directory structure")
@@ -518,10 +539,18 @@ class ArchitectureAnalyzer:
518
539
  return domains
519
540
 
520
541
  def _detect_layers(self, paths: list[str]) -> tuple[str, list[ArchitectureLayer]]:
521
- # Exclude non-source paths (tests, benchmarks, docs, tooling) from layer scoring
542
+ # Exclude non-source paths (tests, benchmarks, docs, tooling, vendored assets)
543
+ # from layer scoring. Also exclude anything under a `resources/` segment: in
544
+ # Maven/Gradle layouts `src/main/resources/**` is bundled config/assets — e.g.
545
+ # JobRunr ships a React dashboard SPA at
546
+ # `core/src/main/resources/org/jobrunr/dashboard/frontend/src/components`, whose
547
+ # `components` dir would otherwise be miscounted as a backend MVC "view" layer.
522
548
  source_paths = [
523
549
  p for p in paths
524
- if not any(part.lower() in _NON_SOURCE_DIRS for part in p.replace("\\", "/").split("/"))
550
+ if not any(
551
+ part.lower() in _NON_SOURCE_DIRS or part.lower() == "resources"
552
+ for part in p.replace("\\", "/").split("/")
553
+ )
525
554
  ]
526
555
  if not source_paths:
527
556
  return "unknown", []
@@ -544,6 +573,10 @@ class ArchitectureAnalyzer:
544
573
  matched_dirs = [d for d in dir_names if d in keywords]
545
574
  if matched_dirs:
546
575
  matched[layer_key] = matched_dirs
576
+ # A pattern with unmet required keys cannot qualify (e.g. mvc needs a view).
577
+ required = _PATTERN_REQUIRED_KEYS.get(pattern_name)
578
+ if required and not required.issubset(matched.keys()):
579
+ continue
547
580
  score = len(matched)
548
581
  priority = _PATTERN_PRIORITY.get(pattern_name, 0)
549
582
  if (score, priority) > (best_score, best_priority):
@@ -23,6 +23,14 @@ _JAVA_EXTENSIONS = {".java", ".kt", ".scala"}
23
23
 
24
24
  _CORE_DETECTION_MODULES = {"scanner", "detectors", "classifier", "workspace"}
25
25
 
26
+ # BUG #4 (v1.70.0): the "REST API" project-type label is driven by HTTP-framework
27
+ # PRESENCE (Spring MVC / JAX-RS on the classpath), not by an actual endpoint count.
28
+ # The `--compact` summary is the first (often only) thing an agent reads, so it must
29
+ # not assert "rest api" when the authoritative `endpoints` command finds almost no
30
+ # high-confidence surface. Below this many high-confidence endpoints we degrade the
31
+ # headline to a qualified, consistent phrasing instead of overclaiming.
32
+ _MIN_REST_ENDPOINTS_FOR_LABEL = 5
33
+
26
34
  _OPTIONAL_LABEL_MAP: dict[str, str] = {
27
35
  "DependencyAnalyzer": "dependencias",
28
36
  "GraphAnalyzer": "grafo de módulos",
@@ -41,6 +49,7 @@ class ArchitectureSummarizer:
41
49
 
42
50
  def __init__(self, root: Path) -> None:
43
51
  self.root = root
52
+ self._endpoint_support_cache: tuple[int, int] | None = None
44
53
 
45
54
  def generate(self, sm: SourceMap) -> str | None:
46
55
  try:
@@ -133,6 +142,7 @@ class ArchitectureSummarizer:
133
142
  # Line 2: architecture pattern + layers
134
143
  if arch and arch.pattern not in (None, "unknown", "flat"):
135
144
  arch_line = self._describe_arch_pattern(arch)
145
+ arch_line = self._qualify_web_pattern(arch, arch_line, sm)
136
146
  if arch_line:
137
147
  lines.append(arch_line)
138
148
 
@@ -185,9 +195,65 @@ class ArchitectureSummarizer:
185
195
 
186
196
  fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
187
197
  if runtime:
198
+ # BUG #4: never assert "rest api" in the headline unless the endpoints
199
+ # command actually backs it (Java/Kotlin only — that is where we have an
200
+ # authoritative extractor). Degrade to a qualified, consistent phrasing.
201
+ if sm.project_type == "api" and primary.stack in {"java", "kotlin"}:
202
+ total, high = self._endpoint_support()
203
+ if high < _MIN_REST_ENDPOINTS_FOR_LABEL:
204
+ plural = "s" if total != 1 else ""
205
+ return (
206
+ f"{stack_label} application{fw_str} "
207
+ f"(HTTP framework present; only {total} endpoint{plural} "
208
+ f"detected — see `endpoints`)."
209
+ )
188
210
  return f"{stack_label} {runtime.lower()}{fw_str}."
189
211
  return f"{stack_label} project{fw_str}."
190
212
 
213
+ def _endpoint_support(self) -> tuple[int, int]:
214
+ """Return (total, high_confidence) endpoint counts from the canonical
215
+ Java endpoint extractor — the same source the `endpoints` command uses,
216
+ so the summary cannot diverge from it. Cached; failure degrades to (0, 0)."""
217
+ if self._endpoint_support_cache is not None:
218
+ return self._endpoint_support_cache
219
+ total, high = 0, 0
220
+ try:
221
+ from sourcecode.repository_ir import extract_java_endpoints
222
+ data = extract_java_endpoints(self.root)
223
+ eps = data.get("endpoints", [])
224
+ total = data.get("total", len(eps))
225
+ high = sum(1 for e in eps if (e.get("confidence") or "high") == "high")
226
+ except Exception:
227
+ total, high = 0, 0
228
+ self._endpoint_support_cache = (total, high)
229
+ return self._endpoint_support_cache
230
+
231
+ def _qualify_web_pattern(self, arch: Any, arch_line: str, sm: SourceMap) -> str:
232
+ """BUG #1 (Jenkins field test): the "mvc" pattern is a directory-name
233
+ heuristic (dirs matching controller/model/view keywords). On a Java/Kotlin
234
+ repo, do not assert "MVC pattern with ... view layers" in prose when the
235
+ canonical endpoint extractor finds no HTTP controllers in the same run —
236
+ Jenkins (Stapler web framework) matches model/view-ish dirs but declares
237
+ zero Spring MVC controllers. Cross-check against `endpoints` and degrade to
238
+ a consistent, non-committal phrasing instead of a categorical MVC claim."""
239
+ if not arch_line or arch.pattern not in ("mvc", "spring_mvc_layered"):
240
+ return arch_line
241
+ primary = next((s for s in sm.stacks if s.primary), sm.stacks[0] if sm.stacks else None)
242
+ if primary is None or primary.stack not in {"java", "kotlin"}:
243
+ return arch_line
244
+ _total, high = self._endpoint_support()
245
+ if high >= _MIN_REST_ENDPOINTS_FOR_LABEL:
246
+ return arch_line
247
+ # No controller backing → the MVC/view claim is unverified. Report the
248
+ # directory-derived layers without the framework label or the "view" claim.
249
+ layer_names = [l.name for l in arch.layers[:4] if l.name != "view"] if arch.layers else []
250
+ if layer_names:
251
+ return (
252
+ f"Layered code organization ({', '.join(layer_names)}; "
253
+ f"directory-based — no HTTP controllers detected)."
254
+ )
255
+ return ""
256
+
191
257
  def _describe_arch_pattern(self, arch: Any) -> str:
192
258
  pattern_labels = {
193
259
  "clean": "Clean Architecture",
@@ -284,10 +284,43 @@ class JavaDetector(AbstractDetector):
284
284
  if parent_artifact == "spring-boot-starter-parent":
285
285
  sb_version = (parent_elem.findtext(f"{ns}version") or "").strip() or None
286
286
 
287
- text = ElementTree.tostring(root_elem, encoding="unicode").lower()
287
+ # BUG #1 (Jenkins field test): framework tokens must be matched only inside
288
+ # genuine dependency / plugin / parent coordinates, never anywhere in the
289
+ # serialized pom. Jenkins' war/pom.xml lists `<exclude>...:spring-web</exclude>`
290
+ # and `<exclude>...:spring-aop</exclude>` in an enforcer-plugin bytecode rule
291
+ # — those are exclusions of transitive artifacts, not declared dependencies.
292
+ # Serializing the whole tree let them read as "uses Spring MVC / Spring AOP".
293
+ # Restrict the scan to real dependency/plugin/parent artifact coordinates;
294
+ # `<exclusion>` and `<exclude>` elements carry neither.
295
+ text = self._pom_coordinate_text(root_elem, ns)
288
296
  frameworks = self._detect_jvm_frameworks(text, "pom.xml", sb_version=sb_version)
289
297
  return frameworks
290
298
 
299
+ def _pom_coordinate_text(self, root_elem: ElementTree.Element, ns: str) -> str:
300
+ """Collect groupId:artifactId coordinates from real dependency, plugin, and
301
+ parent declarations only. Skips `<exclusion>`/`<exclude>` (transitive-artifact
302
+ removals) and comments, which must never be read as usage signal."""
303
+ coords: list[str] = []
304
+
305
+ def _coord(elem: ElementTree.Element) -> None:
306
+ gid = (elem.findtext(f"{ns}groupId") or "").strip()
307
+ aid = (elem.findtext(f"{ns}artifactId") or "").strip()
308
+ if aid:
309
+ coords.append(f"{gid}:{aid}" if gid else aid)
310
+
311
+ # <dependency> nodes anywhere (dependencies, dependencyManagement, profiles).
312
+ # ElementTree never yields <dependency> under <exclusions> (those are
313
+ # <exclusion>), so exclusion coordinates are structurally excluded.
314
+ for dep in root_elem.iter(f"{ns}dependency"):
315
+ _coord(dep)
316
+ for plugin in root_elem.iter(f"{ns}plugin"):
317
+ _coord(plugin)
318
+ parent_elem = root_elem.find(f"{ns}parent")
319
+ if parent_elem is not None:
320
+ _coord(parent_elem)
321
+
322
+ return "\n".join(coords).lower()
323
+
291
324
  def _frameworks_from_gradle(self, path: Path) -> list[FrameworkDetection]:
292
325
  original = "\n".join(read_text_lines(path))
293
326
  content = original.lower()
@@ -361,7 +394,12 @@ class JavaDetector(AbstractDetector):
361
394
  frameworks.append(FrameworkDetection(name="Thymeleaf", source=source))
362
395
  if "freemarker" in text:
363
396
  frameworks.append(FrameworkDetection(name="FreeMarker", source=source))
364
- if "spring-boot-starter-security" in text or "spring-security-core" in text:
397
+ if (
398
+ "spring-boot-starter-security" in text
399
+ or "spring-security-core" in text
400
+ or "spring-security-web" in text
401
+ or "spring-security-config" in text
402
+ ):
365
403
  frameworks.append(FrameworkDetection(name="Spring Security", source=source))
366
404
  if "spring-boot-starter-data-jpa" in text or "spring-data-jpa" in text:
367
405
  frameworks.append(FrameworkDetection(name="Spring Data JPA", source=source))
@@ -262,15 +262,33 @@ def _scan_build_dependency(root: Path) -> tuple[bool, list[str]]:
262
262
  # ≥ 6 the 5→6 axis does NOT apply (the migration is already done). When it cannot
263
263
  # be resolved the axis degrades to a low-confidence hypothesis (no headline).
264
264
  _HIB_VER = r"(\d+\.\d+(?:\.\d+)?(?:[.\-][\w]+)*)" # 6.2.13.Final, 5.6.15, 6.2
265
+ _HIB_VER_ANCHORED = re.compile(r"^" + _HIB_VER + r"$")
265
266
  _HIB_PROP_RE = re.compile(
266
267
  r"<((?:[\w.\-]*hibernate[\w.\-]*?)\.?version)>\s*" + _HIB_VER + r"\s*</\1>",
267
268
  re.IGNORECASE,
268
269
  )
269
- _HIB_COORD_VERSION_RE = re.compile(
270
- r"<artifactId>\s*hibernate-(?:core|orm)\s*</artifactId>\s*"
271
- r"(?:<[^>]+>[^<]*</[^>]+>\s*)*?<version>\s*" + _HIB_VER,
270
+ # A Hibernate property is ONLY a proxy for the ORM version when it is not the
271
+ # version of a sibling Hibernate artifact (Search, Validator, Envers, OGM, …).
272
+ # Those ship on their own version line (e.g. hibernate-search 6.x on a
273
+ # hibernate-core 5.x project) and must never be mistaken for the ORM version.
274
+ # BUG #1 (v1.70.0): openmrs pinned hibernate-core to 5.6.15 via ${hibernateVersion}
275
+ # while ${hibernateSearchVersion}=6.2.4 — the old "newest wins" scan picked the
276
+ # Search version and declared the project already on Hibernate 6.
277
+ _HIB_NON_CORE_PROP_RE = re.compile(
278
+ r"search|validator|envers|ogm|reactive|spatial|jpamodelgen|"
279
+ r"jpa-?model|gradle|tool|commons|metamodel",
280
+ re.IGNORECASE,
281
+ )
282
+ # Anchor: capture the <version> declared inside the hibernate-core/orm
283
+ # <dependency> block (the value may be a literal or a ${property} reference).
284
+ _HIB_CORE_DEP_RE = re.compile(
285
+ r"<dependency>(?P<body>(?:(?!</dependency>).)*?"
286
+ r"<artifactId>\s*hibernate-(?:core|orm)\s*</artifactId>"
287
+ r"(?:(?!</dependency>).)*?)</dependency>",
272
288
  re.IGNORECASE | re.DOTALL,
273
289
  )
290
+ _VERSION_TAG_RE = re.compile(r"<version>\s*([^<]+?)\s*</version>", re.IGNORECASE)
291
+ _PROP_REF_RE = re.compile(r"^\$\{([^}]+)\}$")
274
292
  _HIB_GRADLE_VERSION_RE = re.compile(
275
293
  r"org\.hibernate(?:\.orm)?:hibernate-(?:core|orm):" + _HIB_VER,
276
294
  re.IGNORECASE,
@@ -290,37 +308,88 @@ def _ver_key(full: str) -> tuple[int, int]:
290
308
  return (major, minor)
291
309
 
292
310
 
311
+ def _lookup_maven_property(texts: list[str], name: str) -> Optional[str]:
312
+ """Resolve a single Maven property by exact tag name across build files."""
313
+ pat = re.compile(
314
+ r"<" + re.escape(name) + r">\s*([^<]+?)\s*</" + re.escape(name) + r">",
315
+ re.IGNORECASE,
316
+ )
317
+ for t in texts:
318
+ m = pat.search(t)
319
+ if m:
320
+ return m.group(1).strip()
321
+ return None
322
+
323
+
324
+ def _resolve_version_expr(texts: list[str], expr: str, depth: int = 0) -> Optional[str]:
325
+ """Resolve a <version> value that may be a literal or a ${property} chain."""
326
+ expr = expr.strip()
327
+ ref = _PROP_REF_RE.match(expr)
328
+ if ref:
329
+ if depth > 5:
330
+ return None
331
+ val = _lookup_maven_property(texts, ref.group(1))
332
+ if val is None:
333
+ return None
334
+ return _resolve_version_expr(texts, val, depth + 1)
335
+ return expr if _HIB_VER_ANCHORED.match(expr) else None
336
+
337
+
293
338
  def _resolve_hibernate_version(root: Path) -> tuple[Optional[str], Optional[int], str]:
294
339
  """Return (full_version_string, major, confidence) for the effective Hibernate.
295
340
 
296
- confidence is "high" when a version was resolved from a build descriptor,
297
- "none" when none could be determined (degrade to hypothesis). The newest
298
- declared Hibernate wins in a multi-module build.
341
+ Resolution order (BUG #1 never trust a same-named-but-different artifact):
342
+ 1. The <version> anchored to the hibernate-core / hibernate-orm dependency
343
+ (literal, or a ${property} resolved to that specific property), and the
344
+ Gradle org.hibernate(.orm):hibernate-(core|orm) coordinate. HIGH conf.
345
+ 2. Fallback: a <hibernate*version> property that is NOT a sibling-artifact
346
+ version (Search/Validator/Envers/…). HIGH conf.
347
+ 3. None resolvable → degrade to hypothesis (confidence "none").
348
+ The newest qualifying version wins in a multi-module build.
299
349
  """
300
350
  import os
301
- best_full: Optional[str] = None
351
+ texts: list[str] = []
302
352
  for dirpath, dirnames, filenames in os.walk(root):
303
353
  dirnames[:] = [d for d in dirnames if d not in _SKIP_BUILD_SCAN_DIRS]
304
354
  for fname in filenames:
305
355
  if fname not in _BUILD_FILE_NAMES:
306
356
  continue
307
357
  try:
308
- text = (Path(dirpath) / fname).read_text(encoding="utf-8", errors="replace")
358
+ texts.append(
359
+ (Path(dirpath) / fname).read_text(encoding="utf-8", errors="replace")
360
+ )
309
361
  except OSError:
310
362
  continue
311
- fulls: list[str] = []
312
- for m in _HIB_PROP_RE.finditer(text):
313
- fulls.append(m.group(2))
314
- for m in _HIB_COORD_VERSION_RE.finditer(text):
315
- fulls.append(m.group(1))
316
- for m in _HIB_GRADLE_VERSION_RE.finditer(text):
317
- fulls.append(m.group(1))
318
- for full in fulls:
319
- if best_full is None or _ver_key(full) > _ver_key(best_full):
320
- best_full = full
321
- if best_full is None:
363
+ if not texts:
322
364
  return None, None, "none"
323
- return best_full, _ver_key(best_full)[0], "high"
365
+
366
+ # 1. Anchored to the hibernate-core / hibernate-orm coordinate.
367
+ anchored: list[str] = []
368
+ for text in texts:
369
+ for dep in _HIB_CORE_DEP_RE.finditer(text):
370
+ vm = _VERSION_TAG_RE.search(dep.group("body"))
371
+ if vm:
372
+ resolved = _resolve_version_expr(texts, vm.group(1))
373
+ if resolved:
374
+ anchored.append(resolved)
375
+ for m in _HIB_GRADLE_VERSION_RE.finditer(text):
376
+ anchored.append(m.group(1))
377
+ if anchored:
378
+ best = max(anchored, key=_ver_key)
379
+ return best, _ver_key(best)[0], "high"
380
+
381
+ # 2. Fallback: a Hibernate *property* that is not a sibling-artifact version.
382
+ prop_cands: list[str] = []
383
+ for text in texts:
384
+ for m in _HIB_PROP_RE.finditer(text):
385
+ if _HIB_NON_CORE_PROP_RE.search(m.group(1)):
386
+ continue
387
+ prop_cands.append(m.group(2))
388
+ if prop_cands:
389
+ best = max(prop_cands, key=_ver_key)
390
+ return best, _ver_key(best)[0], "high"
391
+
392
+ return None, None, "none"
324
393
 
325
394
  # Escalation markers — dynamic / reflection-based persistence construction.
326
395
  _ABSTRACTION_CLASS_RE = re.compile(
@@ -495,7 +564,13 @@ class HibernateStratification:
495
564
  "classification": self.classification,
496
565
  "classification_label": self.classification_label,
497
566
  "stratified": True,
498
- "hibernate_readiness": self.readiness,
567
+ # BUG #1 (v1.70.0): renamed from "hibernate_readiness" to avoid a
568
+ # same-name contradiction with the document-level migrate-check field.
569
+ # This is the RAW 5→6 rewrite-zone readiness (independent of whether the
570
+ # axis applies). The authoritative, applicability-gated migration score
571
+ # lives at the document root as "hibernate_readiness"; consult that one
572
+ # for decisions. They diverge by design when the axis is N/A.
573
+ "rewrite_zone_readiness": self.readiness,
499
574
  "risk_matrix": [r.to_dict() for r in self.risk_matrix],
500
575
  "module_exposure_map": self.module_exposure,
501
576
  "incompatible_patterns": self.incompatible_patterns,
@@ -58,6 +58,13 @@ _ATTR_URL_RE = re.compile(r'url\s*=\s*"([^"]*)"')
58
58
  _ATTR_NAME_RE = re.compile(r'(?:name|value)\s*=\s*"([^"]*)"')
59
59
  _FIRST_LITERAL_RE = re.compile(r'^\s*"([^"]*)"')
60
60
 
61
+ # BUG #5 (Jenkins field test): thresholds for the structured coverage_confidence
62
+ # signal. A repo at/above _LARGE_REPO_FILE_THRESHOLD source files with fewer than
63
+ # _LOW_COVERAGE_COUNT recognized integration constructs is flagged "low" coverage —
64
+ # the count almost certainly under-represents custom-protocol/SPI integrations.
65
+ _LARGE_REPO_FILE_THRESHOLD: int = 300
66
+ _LOW_COVERAGE_COUNT: int = 10
67
+
61
68
  # token -> (kind, client). Matched as whole-word usage outside imports/comments.
62
69
  # Covers Spring AND plain-Java/Jakarta stacks (Quarkus, Micronaut, Keycloak SPI):
63
70
  # the detector must not be Spring-centric, or a non-Spring repo with real LDAP /
@@ -200,6 +207,27 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
200
207
  has_mail_import = bool(_MAIL_IMPORT_RE.search(text))
201
208
  naming_factory = _classify_naming_factory(text)
202
209
 
210
+ # BUG #3 (v1.70.0): "HttpClient" is a simple name that collides with
211
+ # user-defined classes (e.g. org.openmrs.util.HttpClient, a thin wrapper over
212
+ # java.net.HttpURLConnection — a completely different API from the JDK 11+
213
+ # java.net.http.HttpClient). Resolve the JDK client by its FULLY-QUALIFIED
214
+ # import, never by the bare class name. When the file imports/declares a
215
+ # different HttpClient (or none can be resolved), degrade to a low-confidence
216
+ # "custom-http-wrapper" rather than asserting a JDK client that isn't there.
217
+ import_fqns = set(
218
+ re.findall(r"^\s*import\s+(?:static\s+)?([\w.]+)\s*;", text, re.MULTILINE)
219
+ )
220
+ http_jdk_imported = (
221
+ "java.net.http.HttpClient" in import_fqns or "java.net.http.*" in import_fqns
222
+ )
223
+ declares_own_httpclient = bool(
224
+ re.search(r"\b(?:class|interface|enum)\s+HttpClient\b", text)
225
+ )
226
+ http_other_import = any(
227
+ fqn.endswith(".HttpClient") and not fqn.startswith("java.net.http.")
228
+ for fqn in import_fqns
229
+ )
230
+
203
231
  # Token clients — per line, skipping imports/package/comment noise.
204
232
  # First pass records the declaration site and any variable name bound to
205
233
  # the client, so a later call site (where the URL literal usually lives)
@@ -241,6 +269,27 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
241
269
  kind, client, confidence = (
242
270
  "naming-directory-unknown", "jndi-dircontext", "low",
243
271
  )
272
+ # BUG #3: resolve the ambiguous bare "HttpClient" by its import, and
273
+ # suppress pure type-declaration sites (field / parameter / return
274
+ # type) — only a construction or static call is a real network site.
275
+ if client == "jdk-httpclient":
276
+ if not (http_jdk_imported and not declares_own_httpclient):
277
+ # Not the JDK client (own class, third-party, or unresolvable).
278
+ client, confidence = "custom-http-wrapper", "low"
279
+ if declares_own_httpclient or http_other_import:
280
+ confidence = "low"
281
+ is_construction = bool(
282
+ re.search(r"\bnew\s+HttpClient\b", line)
283
+ ) or bool(re.search(r"\bHttpClient\s*\.", line))
284
+ if not is_construction:
285
+ # Type-declaration only (e.g. `HttpClient field;`,
286
+ # `void setX(HttpClient c)`): track the var for the URL
287
+ # second pass but do NOT emit it as an invocation site.
288
+ tok = m.group(0)
289
+ decl = re.search(re.escape(tok) + r"\s+(\w+)\b", line)
290
+ if decl:
291
+ var_to_client[decl.group(1)] = (kind, client)
292
+ continue
244
293
  _add(kind, client, _extract_target(line), rel, lineno, confidence)
245
294
  tok = m.group(0)
246
295
  # `Type name` (field/local decl) and `name = new Type(` forms.
@@ -274,11 +323,50 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
274
323
  # clients (DI, config-driven endpoints, JCA connectors) are invisible to static
275
324
  # text matching. Report that explicitly instead of an authoritative "0".
276
325
  confidence = "observed" if records else "not_analyzed"
326
+
327
+ # BUG #5 (Jenkins field test): the coverage caveat lived only inside the prose
328
+ # `coverage_note`. Propagate it as a STRUCTURED signal so an automated consumer
329
+ # does not read a low `count` as "low external coupling". A large repo with few
330
+ # recognized constructs (Jenkins: custom remoting/SCM/Update-Center SPIs, not
331
+ # RestTemplate/WebClient) is under-counted, not loosely coupled.
332
+ _repo_size = len(file_paths)
333
+ _large_repo = _repo_size >= _LARGE_REPO_FILE_THRESHOLD
334
+ if not records:
335
+ coverage_confidence = "low"
336
+ _cov_reason = (
337
+ "no recognized client-library construct found; custom protocol/SPI-based "
338
+ "or DI-wired integrations are not statically detectable by this analyzer. "
339
+ "A count of 0 does not imply the system has no outbound integrations."
340
+ )
341
+ elif _large_repo and len(records) < _LOW_COVERAGE_COUNT:
342
+ coverage_confidence = "low"
343
+ _cov_reason = (
344
+ f"only {len(records)} recognized construct(s) across {_repo_size} source "
345
+ "files; count reflects only recognized client-library patterns "
346
+ "(RestTemplate/WebClient/JDK/Apache/OkHttp/LDAP/JMS/…). Custom protocol/"
347
+ "SPI-based integrations are likely under-counted — do not read a low count "
348
+ "as low external coupling."
349
+ )
350
+ elif len(records) < _LOW_COVERAGE_COUNT:
351
+ coverage_confidence = "partial"
352
+ _cov_reason = (
353
+ "recognized client-library constructs detected; custom protocol/SPI-based "
354
+ "or DI-wired integrations, if any, are not statically visible."
355
+ )
356
+ else:
357
+ coverage_confidence = "high"
358
+ _cov_reason = (
359
+ "recognized client-library constructs cover the observable integration "
360
+ "surface; runtime/DI-wired clients remain out of static scope."
361
+ )
362
+
277
363
  return {
278
364
  "integrations": records,
279
365
  "by_kind": {k: by_kind[k] for k in sorted(by_kind)},
280
366
  "count": len(records),
281
367
  "confidence": confidence,
368
+ "coverage_confidence": coverage_confidence,
369
+ "coverage_confidence_reason": _cov_reason,
282
370
  "coverage_note": (
283
371
  "Detects HTTP (RestTemplate/WebClient/JDK/Apache/OkHttp), LDAP (Spring "
284
372
  "+ JNDI), DNS (JNDI DirContext w/ DnsContextFactory), SMTP (JavaMail, "
@@ -652,6 +652,13 @@ def _classify_code_context(finding: "MigrationFinding") -> str:
652
652
  imp.rsplit(".", 1)[-1] == "Generated" for imp in finding.imports_found
653
653
  ):
654
654
  return "generated"
655
+ # BUG #3 (Jenkins field test): a namespace-migration finding (javax→jakarta,
656
+ # e.g. javax.servlet) on a class-level @Deprecated type is a frozen-legacy
657
+ # compatibility shim — kept for binary compat, with a live replacement elsewhere
658
+ # (Jenkins ChainedServletFilter → ChainedServletFilter2). Not an active blocker:
659
+ # segregate it out of blocking_count like test/generated, with its own label.
660
+ if finding.enclosing_deprecated and finding.migration_target in _JAKARTA_TARGETS:
661
+ return "deprecated_shim"
655
662
  return "main"
656
663
 
657
664
 
@@ -1181,6 +1188,11 @@ class MigrationFinding:
1181
1188
  # harness), or "generated" (autogenerated source). Only "main" counts toward
1182
1189
  # blocking_count / readiness; test+generated are reported in a separate bucket.
1183
1190
  code_context: str = "main"
1191
+ # BUG #3 (Jenkins field test): the enclosing top-level TYPE carries a class-level
1192
+ # @Deprecated annotation. A namespace-migration finding on such a class is a
1193
+ # frozen-legacy compatibility shim (kept for binary compat, replaced elsewhere),
1194
+ # not an active migration blocker — reclassified out of blocking_count.
1195
+ enclosing_deprecated: bool = False
1184
1196
 
1185
1197
  @staticmethod
1186
1198
  def make_id(rule_id: str, source_file: str) -> str:
@@ -1287,12 +1299,19 @@ class MigrationReport:
1287
1299
  readiness_aggregate: dict = field(default_factory=dict)
1288
1300
  blocking_count: int = 0
1289
1301
  estimated_effort_days: float = 0.0
1302
+ effort_breakdown: dict = field(default_factory=dict)
1290
1303
  # Tri-state: True = Boot 2 confirmed, False = Boot 3+ confirmed,
1291
1304
  # None = could not determine. Absence of evidence is never reported as True.
1292
1305
  spring_boot_2_detected: Optional[bool] = None
1293
1306
  spring_boot_version_detected: Optional[str] = None
1294
1307
  # BUG #2: whether Spring is used AT RUNTIME. The Boot3 dimension is N/A without it.
1295
1308
  spring_present: bool = True
1309
+ # BUG #2 (Jenkins field test): whether the repo is a Spring BOOT application
1310
+ # (spring-boot* coordinate / @SpringBootApplication), as opposed to merely having
1311
+ # a Spring library (e.g. spring-security-web) on the classpath. The boot3 (Boot
1312
+ # 2→3) migration dimension is applicable ONLY to Boot repos — a non-Boot Spring
1313
+ # repo has no Boot upgrade axis and must not receive a contaminated readiness.
1314
+ spring_boot_present: bool = False
1296
1315
  # BUG #1/#2: Spring appears ONLY as a test dependency (e.g. spring-test). Reported
1297
1316
  # so a consumer can see WHY boot3 is N/A despite an org.springframework coordinate.
1298
1317
  spring_test_only: bool = False
@@ -1365,7 +1384,19 @@ class MigrationReport:
1365
1384
  self.jakarta_readiness = _dimension_score(main_findings, _JAKARTA_TARGETS)
1366
1385
  self.boot3_readiness = _dimension_score(main_findings, _BOOT3_MIGRATION_TARGETS)
1367
1386
  self.jdk_modernization = _dimension_score(main_findings, None)
1368
- if not self.spring_present:
1387
+ # BUG #2 (Jenkins field test): the boot3 axis applies only with POSITIVE
1388
+ # evidence — a Spring BOOT repo (spring-boot* coordinate / @SpringBootApplication),
1389
+ # OR a concrete Boot-3 / Security-6 migration finding in main code. A repo that
1390
+ # merely carries a Spring library (spring-security-web) with no Boot and no
1391
+ # Boot/Security migration findings (Jenkins) has no boot3 axis — jakarta-shaped
1392
+ # findings must not masquerade as a boot3 readiness deficit. Note: jakarta
1393
+ # findings do NOT make boot3 applicable; only genuine boot3/security-6 targets do.
1394
+ _boot3_migration_findings = any(
1395
+ f.migration_target in (_BOOT3_MIGRATION_TARGETS - _JAKARTA_TARGETS)
1396
+ for f in main_findings
1397
+ )
1398
+ _boot3_applies = self.spring_boot_present or _boot3_migration_findings
1399
+ if not _boot3_applies:
1369
1400
  self.boot3_readiness = 100
1370
1401
 
1371
1402
  # BUG #3/#4: the jakarta dimension is applicable ONLY with POSITIVE evidence the
@@ -1441,14 +1472,23 @@ class MigrationReport:
1441
1472
  else "N/A — no migratable javax.* imports detected"),
1442
1473
  },
1443
1474
  "boot3": {
1444
- "applicable": self.spring_present,
1445
- "score": self.boot3_readiness if self.spring_present else None,
1446
- "reason": ("Spring Boot 2→3 / Security 6 migration"
1447
- if self.spring_present
1448
- else ("N/A — Spring present only as a TEST dependency "
1449
- "(spring-test); no runtime Spring"
1450
- if self.spring_test_only
1451
- else "N/A — no Spring usage detected (non-Spring stack)")),
1475
+ "applicable": _boot3_applies,
1476
+ "score": self.boot3_readiness if _boot3_applies else None,
1477
+ "reason": (
1478
+ "Spring Boot 2→3 / Security 6 migration"
1479
+ if self.spring_boot_present
1480
+ else "Spring Security 6 migration (spring_security_6 / spring_boot_3 "
1481
+ "findings present) — no Spring Boot detected"
1482
+ if _boot3_applies
1483
+ else "N/A — Spring present only as a TEST dependency (spring-test); "
1484
+ "no runtime Spring, no Spring Boot"
1485
+ if self.spring_test_only
1486
+ else "N/A — Spring library on the classpath but no spring-boot "
1487
+ "dependency / @SpringBootApplication and no Boot/Security-6 "
1488
+ "migration finding — not a Spring Boot application"
1489
+ if self.spring_present
1490
+ else "N/A — no Spring usage detected (non-Spring stack)"
1491
+ ),
1452
1492
  },
1453
1493
  "jdk_modernization": {"applicable": True, "score": self.jdk_modernization,
1454
1494
  "reason": "orthogonal JDK modernization debt (excluded from "
@@ -1499,13 +1539,32 @@ class MigrationReport:
1499
1539
 
1500
1540
  # BUG #5: effort over MAIN findings only — N/A axes (Hibernate-6 phantom,
1501
1541
  # test fixtures) no longer pad the estimate.
1502
- self.estimated_effort_days = round(
1542
+ _file_effort = (
1503
1543
  len(critical_files) * 0.5
1504
1544
  + len(high_files) * 0.25
1505
1545
  + len(medium_files) * 0.1
1506
- + len(low_files) * 0.05,
1507
- 1,
1546
+ + len(low_files) * 0.05
1508
1547
  )
1548
+ # BUG #1 (v1.70.0): when the Hibernate 5→6 axis APPLIES, fold its measured
1549
+ # rewrite effort (risk_matrix → total_effort_range_days) into the headline
1550
+ # estimate. Previously a Hibernate-5 project whose ${hibernateVersion} was
1551
+ # misread as 6 set migration_applicable=False, which silently DROPPED this
1552
+ # 28.9–95.6 person-day range from estimated_effort_days, under-reporting the
1553
+ # real cost 1.5–2.6×. We add the range midpoint and expose the breakdown.
1554
+ _hib_effort = 0.0
1555
+ if _hibernate_applies and hib is not None:
1556
+ _r = hib.total_effort_range_days or {}
1557
+ _lo, _hi = _r.get("low"), _r.get("high")
1558
+ if isinstance(_lo, (int, float)) and isinstance(_hi, (int, float)):
1559
+ _hib_effort = (float(_lo) + float(_hi)) / 2.0
1560
+ self.estimated_effort_days = round(_file_effort + _hib_effort, 1)
1561
+ self.effort_breakdown = {
1562
+ "findings_effort_days": round(_file_effort, 1),
1563
+ "hibernate_rewrite_effort_days": round(_hib_effort, 1),
1564
+ "hibernate_rewrite_range": (
1565
+ hib.total_effort_range_days if (_hibernate_applies and hib is not None) else None
1566
+ ),
1567
+ }
1509
1568
 
1510
1569
  # BUG #6 / #8: hygiene + non-blocking buckets, surfaced separately.
1511
1570
  self.hygiene_findings = sum(
@@ -1522,8 +1581,11 @@ class MigrationReport:
1522
1581
  "count": sum(_nb_by_ctx.values()),
1523
1582
  "by_context": _nb_by_ctx,
1524
1583
  "by_rule": _nb_by_rule,
1525
- "note": ("Findings in test/fixture harnesses or autogenerated sources. "
1526
- "Excluded from blocking_count, readiness, and effort."),
1584
+ "note": ("Findings in test/fixture harnesses, autogenerated sources, or "
1585
+ "class-level @Deprecated frozen-legacy shims (code_context="
1586
+ "\"deprecated_shim\" — kept for binary compat with a live "
1587
+ "replacement; verify before removing). Excluded from "
1588
+ "blocking_count, readiness, and effort."),
1527
1589
  }
1528
1590
 
1529
1591
  self.summary = {
@@ -1561,9 +1623,11 @@ class MigrationReport:
1561
1623
  "headline_blocker": self.headline_blocker,
1562
1624
  "blocking_count": self.blocking_count,
1563
1625
  "estimated_effort_days": self.estimated_effort_days,
1626
+ "effort_breakdown": self.effort_breakdown,
1564
1627
  "hygiene_findings": self.hygiene_findings,
1565
1628
  "non_blocking": self.non_blocking,
1566
1629
  "spring_present": self.spring_present,
1630
+ "spring_boot_present": self.spring_boot_present,
1567
1631
  "spring_test_only": self.spring_test_only,
1568
1632
  "spring_boot_2_detected": self.spring_boot_2_detected,
1569
1633
  "spring_boot_version_detected": self.spring_boot_version_detected,
@@ -1725,12 +1789,35 @@ def _neutralize_non_spring_explanation(text: str) -> str:
1725
1789
  return text
1726
1790
 
1727
1791
 
1792
+ # BUG #3 (Jenkins field test): the primary top-level type declaration in a .java
1793
+ # file. Used to test whether a class-level @Deprecated annotation precedes it.
1794
+ _PRIMARY_TYPE_DECL_RE: re.Pattern = re.compile(
1795
+ r"(?m)^[ \t]*(?:public\s+|final\s+|abstract\s+|sealed\s+|non-sealed\s+|strictfp\s+)*"
1796
+ r"(?:class|interface|enum|record)\s+\w"
1797
+ )
1798
+
1799
+
1800
+ def _primary_type_is_deprecated(source: str) -> bool:
1801
+ """True iff the file's primary top-level type carries a class-level @Deprecated
1802
+ annotation. Scoped to the region between the last import and the type keyword so
1803
+ a @Deprecated on a METHOD (below the class declaration) never counts — that is a
1804
+ live class with a deprecated member, not a frozen-legacy shim."""
1805
+ m = _PRIMARY_TYPE_DECL_RE.search(source)
1806
+ if not m:
1807
+ return False
1808
+ head = source[: m.start()]
1809
+ last_import = head.rfind("\nimport ")
1810
+ window = head[last_import:] if last_import != -1 else head
1811
+ return "@Deprecated" in window
1812
+
1813
+
1728
1814
  def _scan_file(
1729
1815
  source: str,
1730
1816
  rel_path: str,
1731
1817
  rules: list[_Rule],
1732
1818
  ) -> list[MigrationFinding]:
1733
1819
  findings: list[MigrationFinding] = []
1820
+ type_deprecated = _primary_type_is_deprecated(source)
1734
1821
 
1735
1822
  for rule in rules:
1736
1823
  matched_imports: list[str] = []
@@ -1799,6 +1886,7 @@ def _scan_file(
1799
1886
  fix_hint=rule.fix_hint,
1800
1887
  migration_target=rule.migration_target,
1801
1888
  openrewrite_recipe=rule.openrewrite_recipe,
1889
+ enclosing_deprecated=type_deprecated,
1802
1890
  )
1803
1891
  )
1804
1892
 
@@ -1920,6 +2008,7 @@ def run_migrate_check(
1920
2008
  spring_present, spring_test_only = _detect_spring_usage(
1921
2009
  root, spring_runtime_import_seen, spring_test_import_seen
1922
2010
  )
2011
+ spring_boot_present = _detect_spring_boot_present(root)
1923
2012
 
1924
2013
  # BUG #8: classify each finding's code context (main / test / generated) so the
1925
2014
  # report can keep test fixtures and autogenerated sources out of blocking_count.
@@ -1944,6 +2033,7 @@ def run_migrate_check(
1944
2033
  spring_boot_2_detected=spring_boot_2,
1945
2034
  spring_boot_version_detected=spring_boot_version,
1946
2035
  spring_present=spring_present,
2036
+ spring_boot_present=spring_boot_present,
1947
2037
  spring_test_only=spring_test_only,
1948
2038
  jakarta_namespace_adopted=jakarta_import_count > 0,
1949
2039
  findings=all_findings,
@@ -2044,6 +2134,46 @@ def _extract_boot_versions(text: str) -> tuple[set[int], Optional[str]]:
2044
2134
  return majors, full
2045
2135
 
2046
2136
 
2137
+ # BUG #2 (Jenkins field test): strip XML comments and exclusion/exclude blocks
2138
+ # before scanning for a spring-boot coordinate, so a `<exclude>...:spring-boot</…>`
2139
+ # or a comment never reads as "this repo uses Spring Boot" — the same exclusion
2140
+ # poison that produced phantom Spring MVC/AOP in framework detection.
2141
+ _XML_COMMENT_RE: re.Pattern = re.compile(r"<!--.*?-->", re.DOTALL)
2142
+ _XML_EXCLUSION_EL_RE: re.Pattern = re.compile(r"<exclusion\b.*?</exclusion\s*>", re.DOTALL | re.IGNORECASE)
2143
+ _XML_EXCLUDE_EL_RE: re.Pattern = re.compile(r"<exclude\b.*?</exclude\s*>", re.DOTALL | re.IGNORECASE)
2144
+ # A spring-boot* Maven/Gradle artifact coordinate (starter, BOM, dependency, plugin).
2145
+ _SPRING_BOOT_ARTIFACT_RE: re.Pattern = re.compile(r"\bspring-boot(?:-[a-z0-9.\-]+)?\b", re.IGNORECASE)
2146
+
2147
+
2148
+ def _strip_xml_exclusions(text: str) -> str:
2149
+ """Remove XML comments and exclusion/exclude elements so their coordinates are
2150
+ not mistaken for declared dependencies."""
2151
+ text = _XML_COMMENT_RE.sub(" ", text)
2152
+ text = _XML_EXCLUSION_EL_RE.sub(" ", text)
2153
+ text = _XML_EXCLUDE_EL_RE.sub(" ", text)
2154
+ return text
2155
+
2156
+
2157
+ def _detect_spring_boot_present(root: Path) -> bool:
2158
+ """True iff there is POSITIVE Spring Boot evidence — a spring-boot* build
2159
+ coordinate (parent / BOM / dependency / plugin) or the Spring Boot Gradle
2160
+ plugin. Distinguishes a Spring Boot application from a repo that merely has a
2161
+ Spring library on the classpath (e.g. Jenkins' spring-security-web). Gates the
2162
+ boot3 (Boot 2→3) migration dimension. @SpringBootApplication is not scanned
2163
+ separately: its annotation class ships in spring-boot-autoconfigure, so a Boot
2164
+ app always carries a spring-boot* coordinate."""
2165
+ for abs_path, _rel in _find_build_files(root):
2166
+ try:
2167
+ text = abs_path.read_text(encoding="utf-8", errors="replace")
2168
+ except OSError:
2169
+ continue
2170
+ if _SPRING_BOOT_PLUGIN_RE.search(text):
2171
+ return True
2172
+ if _SPRING_BOOT_ARTIFACT_RE.search(_strip_xml_exclusions(text)):
2173
+ return True
2174
+ return False
2175
+
2176
+
2047
2177
  def _detect_spring_boot(root: Path, jakarta_import_count: int) -> tuple[Optional[bool], Optional[str]]:
2048
2178
  """Tri-state Spring Boot 2 detection. Returns (spring_boot_2_detected, version).
2049
2179
 
@@ -2872,12 +2872,35 @@ def _assemble(
2872
2872
  else:
2873
2873
  _security_model_asm = "unknown"
2874
2874
 
2875
+ # BUG #4 (Jenkins field test): the bare "unknown" string carried no reason or
2876
+ # sub-structure — inconsistent with the not_detected+reason pattern used
2877
+ # elsewhere (migrate-check hibernate). Emit a structured companion so a consumer
2878
+ # sees WHY, and never reads "unknown" as "unsecured". A repo with a custom
2879
+ # security SPI (Jenkins ACL/SecurityRealm) simply matched no RECOGNIZED pattern.
2880
+ _security_model_detail_asm = {
2881
+ "model": _security_model_asm,
2882
+ "status": "detected" if _security_model_asm != "unknown" else "not_detected",
2883
+ "spring_security_filter_chain_detected": _filter_based_asm,
2884
+ "annotation_policies_detected": _has_ann_sec_asm,
2885
+ "custom_spi_detected": [],
2886
+ "reason": (
2887
+ "recognized security model: " + _security_model_asm
2888
+ if _security_model_asm != "unknown"
2889
+ else "N/A — no Spring Security filter chain, no annotation-based policy, "
2890
+ "and no recognized custom security SPI matched. Absence of a "
2891
+ "recognized pattern is not evidence the repo is unsecured — it may "
2892
+ "use a framework-specific or custom access-control model this "
2893
+ "analyzer does not model."
2894
+ ),
2895
+ }
2896
+
2875
2897
  return {
2876
2898
  **_base,
2877
2899
  "route_surface": _route_surface,
2878
2900
  "spring_events": _spring_events,
2879
2901
  "analysis_gaps": _analysis_gaps,
2880
2902
  "security_model": _security_model_asm,
2903
+ "security_model_detail": _security_model_detail_asm,
2881
2904
  "audit": {
2882
2905
  "dropped_fields": dropped_fields,
2883
2906
  },
@@ -3510,6 +3533,15 @@ def build_repo_ir(
3510
3533
  ir["security_model"] = "xml_or_filter_chain"
3511
3534
  elif _sec_model in ("annotation_based", "mixed"):
3512
3535
  ir["security_model"] = "mixed"
3536
+ # BUG #4: keep the structured detail consistent with the rewritten string.
3537
+ _detail = ir.get("security_model_detail")
3538
+ if isinstance(_detail, dict):
3539
+ _detail["model"] = ir["security_model"]
3540
+ _detail["status"] = "detected"
3541
+ _detail["spring_security_filter_chain_detected"] = True
3542
+ _detail["reason"] = ("XML/filter-chain security configuration detected "
3543
+ "(declarative filter mapping in web.xml or Spring "
3544
+ "security XML)")
3513
3545
  # Retag route_surface entries that have no security (would become none_detected in CIR)
3514
3546
  for _r in ir.get("route_surface") or []:
3515
3547
  _r_sec = _r.get("security_annotations")
@@ -121,6 +121,8 @@ class ImpactChainResult:
121
121
  resolution: str = "not_found" # "exact" | "class_expanded" | "partial" | "not_found"
122
122
  direct_callers: list[str] = field(default_factory=list)
123
123
  indirect_callers: list[str] = field(default_factory=list)
124
+ # BUG #2: count of own-class members dropped from callers (members, not callers).
125
+ self_referential_excluded: int = 0
124
126
  implementations: list[str] = field(default_factory=list) # in-repo subtypes of queried interface/base
125
127
  endpoints_affected: list[AffectedEndpoint] = field(default_factory=list)
126
128
  transaction_boundary: Optional[dict] = None # TransactionBoundary.to_dict() or None
@@ -144,6 +146,7 @@ class ImpactChainResult:
144
146
  "resolution": self.resolution,
145
147
  "direct_callers": self.direct_callers,
146
148
  "indirect_callers": self.indirect_callers,
149
+ "self_referential_excluded": self.self_referential_excluded,
147
150
  "implementations": self.implementations,
148
151
  "endpoints_affected": [ep.to_dict() for ep in self.endpoints_affected],
149
152
  "transaction_boundary": self.transaction_boundary,
@@ -471,6 +474,15 @@ def _bfs_callers(
471
474
  indirect: list[str] = []
472
475
  was_truncated = False
473
476
 
477
+ # BUG #2 (v1.70.0): a class's OWN members are not "callers" of that class — they
478
+ # are members. When the seed is a class node, _edges_for folds in every method
479
+ # key of that class, so internal method→method calls (e.g.
480
+ # ConceptServiceImpl#purgeConcept → ConceptServiceImpl#saveConcept) were leaking
481
+ # into direct_callers and inflating the blast radius ~12×. Exclude any caller
482
+ # whose owning class is one of the seed classes.
483
+ seed_classes: set[str] = {normalize_owner_fqn(s) for s in seed_fqns}
484
+ self_excluded: int = 0
485
+
474
486
  # BUG-004: index class FQN → list of method-level keys in reverse_graph.
475
487
  # Callers of Foo#doWork are stored under reverse_graph["Foo#doWork"], never
476
488
  # under reverse_graph["Foo"]. Without this index, BFS silently terminates
@@ -506,7 +518,9 @@ def _bfs_callers(
506
518
  for seed in seed_fqns:
507
519
  for etype, fqn_list in _edges_for(seed):
508
520
  if etype not in _SKIP_EDGE_TYPES:
509
- unique_direct_callers.update(fqn_list)
521
+ unique_direct_callers.update(
522
+ c for c in fqn_list if normalize_owner_fqn(c) not in seed_classes
523
+ )
510
524
 
511
525
  effective_depth = 1 if len(unique_direct_callers) > _BFS_CALLER_CAP else max_depth
512
526
  if effective_depth < max_depth:
@@ -516,8 +530,14 @@ def _bfs_callers(
516
530
  queue: list[tuple[str, int]] = [(s, 0) for s in seed_fqns]
517
531
 
518
532
  def _add_caller(caller: str, depth: int) -> None:
533
+ nonlocal self_excluded
519
534
  if caller in visited:
520
535
  return
536
+ # BUG #2: a member of a seed class is not an external caller — drop it.
537
+ if normalize_owner_fqn(caller) in seed_classes:
538
+ visited.add(caller)
539
+ self_excluded += 1
540
+ return
521
541
  visited.add(caller)
522
542
  if depth == 0:
523
543
  direct.append(caller)
@@ -542,7 +562,7 @@ def _bfs_callers(
542
562
  else:
543
563
  _add_caller(caller, depth)
544
564
 
545
- return direct, indirect, was_truncated
565
+ return direct, indirect, was_truncated, self_excluded
546
566
 
547
567
 
548
568
  # ---------------------------------------------------------------------------
@@ -888,9 +908,15 @@ class ImpactOrchestrator:
888
908
  )
889
909
 
890
910
  # ── 2. BFS through reverse graph ─────────────────────────────────
891
- direct_callers, indirect_callers, truncated = _bfs_callers(
911
+ direct_callers, indirect_callers, truncated, self_excluded = _bfs_callers(
892
912
  seed_fqns, cir.reverse_graph, depth, impl_graph=impl_graph
893
913
  )
914
+ if self_excluded:
915
+ warnings.append(
916
+ f"Self-referential exclusion (BUG #2): {self_excluded} member(s) of the "
917
+ f"analyzed class were dropped from callers — a class's own methods are "
918
+ f"members, not external callers (they do not count toward blast radius)."
919
+ )
894
920
  if truncated:
895
921
  warnings.append(
896
922
  "Hub-class guard active: symbol has > 500 direct callers — "
@@ -1083,6 +1109,7 @@ class ImpactOrchestrator:
1083
1109
  resolution=resolution,
1084
1110
  direct_callers=direct_callers,
1085
1111
  indirect_callers=indirect_callers,
1112
+ self_referential_excluded=self_excluded,
1086
1113
  implementations=sorted(subtype_classes_added),
1087
1114
  endpoints_affected=endpoints_affected,
1088
1115
  transaction_boundary=tx_boundary,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.69.0
3
+ Version: 1.71.0
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -411,7 +411,7 @@ Emits **structured, tool-agnostic** codebase views as plain JSON/YAML — the ki
411
411
  |------|--------|
412
412
  | `--by-directory` | One group per source directory, each symbol with a `source_file:line` reference. |
413
413
  | `--module-graph` | `{nodes, edges, summary}` — directories as modules, inter-module dependencies rolled up from class-level relation edges with hit counts + edge types. |
414
- | `--integrations` | Outbound integrations across Spring **and** plain-Java/Jakarta stacks: HTTP (`RestTemplate`, `WebClient`, `@FeignClient`, JDK `HttpClient`, Apache, OkHttp), LDAP (`LdapTemplate`, JNDI `InitialDirContext`/`LdapContext`), SMTP (`JavaMailSender`, JavaMail `Transport`/`MimeMessage`), and JMS — with `file:line` evidence and a literal `target` when present. Emits an explicit `confidence` (`observed` / `not_analyzed`) + `coverage_note`: a count of `0` means "no client construct found", **not** "no integrations" (runtime/DI-wired clients are not statically visible). |
414
+ | `--integrations` | Outbound integrations across Spring **and** plain-Java/Jakarta stacks: HTTP (`RestTemplate`, `WebClient`, `@FeignClient`, JDK `HttpClient`, Apache, OkHttp), LDAP (`LdapTemplate`, JNDI `InitialDirContext`/`LdapContext`), SMTP (`JavaMailSender`, JavaMail `Transport`/`MimeMessage`), and JMS — with `file:line` evidence and a literal `target` when present. Emits an explicit `confidence` (`observed` / `not_analyzed`), a structured `coverage_confidence` (`low` / `partial` / `high`) with `coverage_confidence_reason`, and a prose `coverage_note`: a count of `0` or a low count on a large repo (≥300 files, <10 constructs) is flagged `low`, so a low count is never read as low external coupling (custom protocol/SPI clients are not statically visible). |
415
415
  | `--c4` | Unified document: `c4.{context, containers, components, code}` + `api_surface` + a `manifest` with per-directory content hashes for **incremental** consumers (skip directories whose hash is unchanged). `components.module_roots` rolls leaf source dirs up to architectural module roots and classifies each `layered` (DDD: ≥2 of `domain`/`application`/`infrastructure`) vs `flat` (legacy/flat package), with a verifiable `module_count` — so a consumer enumerates real modules instead of inferring boundaries from leaf directories. |
416
416
 
417
417
  The section flags compose (pass several for one multi-section document); `--c4` assembles the full export on its own. URLs assembled at runtime yield `target: null` (honest absence, never a guess); containers are derived from build files (Maven/Gradle) and reported as a limitation when none are found.
@@ -734,9 +734,20 @@ evidence. `migrate-check` enforces:
734
734
  `hibernate_rewrite` headline. An **unresolved** version degrades to a
735
735
  low-confidence hypothesis with no headline blocker. See
736
736
  `hibernate.effective_version` / `version_major` / `version_confidence`.
737
- - **Boot3 auto-disables without Spring.** `boot3.applicable` tracks real Spring
738
- usage; Quarkus / Micronaut / Helidon / Jakarta-pure repos report `boot3` as N/A
739
- (`spring_present: false`), not a contradictory `applicable: true`.
737
+ - **Boot3 needs Spring _Boot_, not just Spring.** `boot3.applicable` requires
738
+ positive Spring Boot evidence a `spring-boot*` coordinate / Gradle plugin (or
739
+ `@SpringBootApplication`), **or** a concrete `spring_boot_3` / `spring_security_6`
740
+ finding. A repo that merely carries a Spring library (e.g. `spring-security-web`
741
+ with no Boot — Jenkins core) reports `boot3` as N/A with a reason and is excluded
742
+ from the aggregate; `spring_present: true` but `spring_boot_present: false`. Quarkus
743
+ / Micronaut / Helidon / Jakarta-pure repos likewise report `boot3` N/A. jakarta
744
+ findings alone never enable boot3.
745
+ - **Frozen-legacy `@Deprecated` shims aren't blockers.** A `javax→jakarta` finding
746
+ on a **class-level `@Deprecated`** type (kept for binary compat, replaced
747
+ elsewhere) is classified `code_context: "deprecated_shim"` and excluded from
748
+ `blocking_count` / readiness / effort — surfaced under `non_blocking` with a
749
+ "verify before removing" note. A `@Deprecated` **method** on a live class still
750
+ blocks.
740
751
  - **Permanent `javax.*` are never jakarta debt.** JDK/JSR namespaces
741
752
  (`javax.cache`, `javax.sql`, `javax.xml` JAXP, `javax.crypto`, `javax.naming`,
742
753
  `javax.management`, `javax.security.auth`, `javax.annotation.processing`, …) are
@@ -1,7 +1,7 @@
1
- sourcecode/__init__.py,sha256=mxqKQ416I6lEnKsI6lwjPPRQY5VpPWrIc3-FB4IRfK8,103
1
+ sourcecode/__init__.py,sha256=y7H6BpC2bXN93kgVpSIJ6MrHneDY6yl0VNPo_qOu4Ts,103
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
- sourcecode/architecture_analyzer.py,sha256=liCwQmLgb5vplohy8arjYxs_HOIv5C9MjLh_gY6bc5Q,44115
4
- sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
3
+ sourcecode/architecture_analyzer.py,sha256=r_xf-SWXwUm3nVQMCSXHJ3M8zKsQP7Ze8Nqf6TVLRq8,45998
4
+ sourcecode/architecture_summary.py,sha256=UaxmUU77oTlWxttMU3c6PxHrU-Q6q5N31TeU_NWWTdU,24297
5
5
  sourcecode/ast_extractor.py,sha256=sa6CmLpn-k5G3_Hzxn8hAlZ5-TS-EVzXDD0Gvxd2jzs,50613
6
6
  sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
7
7
  sourcecode/canonical_ir.py,sha256=DEwucOPJguLsVtg5cV8mWXNi112l5jmBhv73KGGebVk,24849
@@ -28,13 +28,13 @@ sourcecode/format_contract.py,sha256=1cTNqwP8geA2hbQoBHUPgX3_vSh3l8guJT_jmgEnFF8
28
28
  sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
29
29
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
30
30
  sourcecode/graph_analyzer.py,sha256=DHR8fY69oU_Pi4SYaWboX6EoEFrctQKB9dsjpqwGMzw,62403
31
- sourcecode/hibernate_strat.py,sha256=ZyI0sikxh7IemU8uqn1Z73z-tHeFeCJuYhJnyQL8pzU,57765
32
- sourcecode/integration_detector.py,sha256=ygZwXHJXJ4QM6oiqKb5S6WIfElByDYdz9moyuFlZpYI,12997
31
+ sourcecode/hibernate_strat.py,sha256=h0leIhlWvSjYq3F99LxvLIDLrJ-xPYxWAREG4LkqZ-4,61190
32
+ sourcecode/integration_detector.py,sha256=HlVdiVki7i62t3pJ0lRjTQQ7WZqPDcPLTqBGAfjFjVQ,17883
33
33
  sourcecode/jdk_exports.py,sha256=fCrlwNAXUT9gge_joq6kMnY3zJxYB2pxqy-0w3o3MJI,874
34
34
  sourcecode/license.py,sha256=wckiLuiwaE35KMCStUf1gYzleJuFe6qsSyxUQJvit3s,23500
35
35
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
36
36
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
37
- sourcecode/migrate_check.py,sha256=QNyFmrEqYUSqmi0iIQ1P6oI63g8prOluSvYA5fUJ7Ow,95040
37
+ sourcecode/migrate_check.py,sha256=VyA-2n5J1Cqf1XxhQ143pf5Y7IvGwolMM1F4OCLOOaU,102628
38
38
  sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
39
39
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
40
40
  sourcecode/path_filters.py,sha256=VnaD9jxZVNzluackNSTCdIddwzAqIseuSCs_A-gpCDM,6898
@@ -47,7 +47,7 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
47
47
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
48
48
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
49
49
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
50
- sourcecode/repository_ir.py,sha256=tP9L8v9K2xg-xb93N1aELJP-pHHxww8537GZ7Sf0_94,229324
50
+ sourcecode/repository_ir.py,sha256=tj3e_uxkVGTRWfzkf2YoGO-oGakSO4DmWrUQow0dFGk,231201
51
51
  sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
52
52
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
53
53
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
@@ -57,7 +57,7 @@ sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIx
57
57
  sourcecode/serializer.py,sha256=MSxYZ-_UYDPKMvg-hVk-MnKN-TfrmxXL202siCssa9U,129110
58
58
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
59
59
  sourcecode/spring_findings.py,sha256=G7Or2lKBUQbcTDqudLvSs9XvNg_YoAa-_lBOG_ULs8E,5457
60
- sourcecode/spring_impact.py,sha256=qLwLfItX_o9LU-k_qjhD2hFpTX3PpEQ85TsYTAArzvg,56016
60
+ sourcecode/spring_impact.py,sha256=1Eu1vkdwTVsw92iBEJDe_i_FaSf4uGH9Rb0eSgIAAZc,57542
61
61
  sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
62
62
  sourcecode/spring_security_audit.py,sha256=XtPJ1SXlZJ8k6VYmaWuAp7Bbir4UmreAL7doIGQ5I7o,20595
63
63
  sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM,13307
@@ -76,7 +76,7 @@ sourcecode/detectors/elixir.py,sha256=jCpvt5Yi6jvplc80ovRtWh17q-11ZGo9qX7o8b57TJ
76
76
  sourcecode/detectors/go.py,sha256=2r66uRQfeTWsqxr4HDhT6vExZErby0t46QXLHVBRv9w,2782
77
77
  sourcecode/detectors/heuristic.py,sha256=7cRxrip4yIaggYzZJB6ef8yHKh-gHgiH_pXMFcjlyFU,3723
78
78
  sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
79
- sourcecode/detectors/java.py,sha256=V9Eb2EMqEdonIhoegvFN2-z95R4Nu9q2y9G54baWShc,32298
79
+ sourcecode/detectors/java.py,sha256=hyAlTo8_y-tzNVun3zvHmWN8RdG--sdLquCI7fQBAVc,34263
80
80
  sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
81
81
  sourcecode/detectors/nodejs.py,sha256=Hg3Gmr7yIMJFiLoDwOTk2wtu00wxIs6kZf-oQujTFUA,13187
82
82
  sourcecode/detectors/parsers.py,sha256=ugPg8yNUf0Ai1gA7Fnn6wAkYGFjTxRodSP3IeViYJJ4,2290
@@ -104,8 +104,8 @@ sourcecode/telemetry/consent.py,sha256=H5z2Wu63pZqbaKucRPoJQJ0zCo4cke9ZlBrJC-MaW
104
104
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
105
105
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
106
106
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
107
- sourcecode-1.69.0.dist-info/METADATA,sha256=s7Ko18CIs4SrMOMsNrqCkc-nFZLcxTnC0M_r0zxXycU,47341
108
- sourcecode-1.69.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
109
- sourcecode-1.69.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
110
- sourcecode-1.69.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
111
- sourcecode-1.69.0.dist-info/RECORD,,
107
+ sourcecode-1.71.0.dist-info/METADATA,sha256=w75cRYv8E561ZW0S1KTAry6BealPGHv20TwuZgW-NGM,48339
108
+ sourcecode-1.71.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
109
+ sourcecode-1.71.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
110
+ sourcecode-1.71.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
111
+ sourcecode-1.71.0.dist-info/RECORD,,