sourcecode 1.68.0__py3-none-any.whl → 1.70.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.68.0"
3
+ __version__ = "1.70.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:
@@ -185,9 +194,39 @@ class ArchitectureSummarizer:
185
194
 
186
195
  fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
187
196
  if runtime:
197
+ # BUG #4: never assert "rest api" in the headline unless the endpoints
198
+ # command actually backs it (Java/Kotlin only — that is where we have an
199
+ # authoritative extractor). Degrade to a qualified, consistent phrasing.
200
+ if sm.project_type == "api" and primary.stack in {"java", "kotlin"}:
201
+ total, high = self._endpoint_support()
202
+ if high < _MIN_REST_ENDPOINTS_FOR_LABEL:
203
+ plural = "s" if total != 1 else ""
204
+ return (
205
+ f"{stack_label} application{fw_str} "
206
+ f"(HTTP framework present; only {total} endpoint{plural} "
207
+ f"detected — see `endpoints`)."
208
+ )
188
209
  return f"{stack_label} {runtime.lower()}{fw_str}."
189
210
  return f"{stack_label} project{fw_str}."
190
211
 
212
+ def _endpoint_support(self) -> tuple[int, int]:
213
+ """Return (total, high_confidence) endpoint counts from the canonical
214
+ Java endpoint extractor — the same source the `endpoints` command uses,
215
+ so the summary cannot diverge from it. Cached; failure degrades to (0, 0)."""
216
+ if self._endpoint_support_cache is not None:
217
+ return self._endpoint_support_cache
218
+ total, high = 0, 0
219
+ try:
220
+ from sourcecode.repository_ir import extract_java_endpoints
221
+ data = extract_java_endpoints(self.root)
222
+ eps = data.get("endpoints", [])
223
+ total = data.get("total", len(eps))
224
+ high = sum(1 for e in eps if (e.get("confidence") or "high") == "high")
225
+ except Exception:
226
+ total, high = 0, 0
227
+ self._endpoint_support_cache = (total, high)
228
+ return self._endpoint_support_cache
229
+
191
230
  def _describe_arch_pattern(self, arch: Any) -> str:
192
231
  pattern_labels = {
193
232
  "clean": "Clean Architecture",
sourcecode/classifier.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from collections.abc import Iterable, Sequence
4
5
  from dataclasses import replace
5
6
  from typing import Any, Literal
@@ -104,6 +105,21 @@ class TypeClassifier:
104
105
  stack_names = {stack.stack for stack in stacks}
105
106
  framework_names = {framework.name for stack in stacks for framework in stack.frameworks}
106
107
 
108
+ # BUG #4 (JobRunr field test): a framework present only in a small optional
109
+ # integration/adapter submodule must NOT label the whole repo as that
110
+ # framework's app type. JobRunr is a framework-agnostic background-job
111
+ # LIBRARY whose `core` module holds ~85% of the code; Quarkus/Micronaut/Spring
112
+ # appear only in tiny per-framework adapter modules — yet presence-based
113
+ # classification returned project_type="api"+Quarkus. We weight by code
114
+ # locality: if the DOMINANT source module (the one with the most source files)
115
+ # contains no evidence of the app-defining framework, the framework is an
116
+ # optional adapter and the repo is a library. A monolithic Spring app is
117
+ # unaffected — its dominant module *does* use the framework. Drop such
118
+ # localized frameworks from the set that drives the app-type decision below.
119
+ _app_frameworks = framework_names & (_WEB_FRAMEWORKS | _API_FRAMEWORKS)
120
+ _localized = self._localized_adapter_frameworks(file_tree, stacks, _app_frameworks)
121
+ framework_names = framework_names - _localized
122
+
107
123
  if len(stack_names) >= 2 and self._is_fullstack(stacks):
108
124
  return "fullstack"
109
125
 
@@ -129,9 +145,16 @@ class TypeClassifier:
129
145
  if framework_names & _API_FRAMEWORKS:
130
146
  return "api"
131
147
 
148
+ # All app-defining frameworks were localized to optional adapter submodules
149
+ # (multi-module library with per-framework integrations) — report library,
150
+ # never "unknown", when there is clearly source code present.
151
+ if _localized and not (framework_names & (_WEB_FRAMEWORKS | _API_FRAMEWORKS)):
152
+ return "library"
153
+
154
+ # Strong CLI signals: a CLI framework or an explicit cli entry point.
132
155
  if framework_names & _CLI_FRAMEWORKS or any(
133
156
  entry.kind == "cli" for entry in entry_points
134
- ) or any(path.startswith("bin/") for path in flat_paths):
157
+ ):
135
158
  return "cli"
136
159
 
137
160
  if stack_names:
@@ -141,8 +164,105 @@ class TypeClassifier:
141
164
  if single in {"cpp", "dotnet"} and any(entry.kind == "cli" for entry in entry_points):
142
165
  return "cli"
143
166
 
167
+ # BUG #4 (JobRunr field test): a multi-module JVM repo with no app-defining
168
+ # web/API framework is a library/toolkit, not an "unknown" — never let the
169
+ # first command of an audit emit a vacuous classification for a clearly
170
+ # structured codebase (e.g. JobRunr: core + per-framework adapter modules).
171
+ # This is checked BEFORE the weak `bin/`-directory CLI heuristic so a build
172
+ # output / wrapper `bin/` dir does not mislabel a library as a CLI.
173
+ if stack_names & {"java", "kotlin", "scala"} and self._is_multi_module(file_tree):
174
+ return "library"
175
+
176
+ # Weak CLI heuristic: a top-level bin/ directory (only when nothing stronger).
177
+ if any(path.startswith("bin/") for path in flat_paths):
178
+ return "cli"
179
+
144
180
  return "unknown" if stacks else None
145
181
 
182
+ def _is_multi_module(self, file_tree: dict[str, Any]) -> bool:
183
+ """True when the repo has >1 source module (distinct `*/src/...` roots)."""
184
+ _CODE_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
185
+ modules = {
186
+ self._module_of(p)
187
+ for p in flatten_file_tree(file_tree)
188
+ if p.endswith(_CODE_EXTS)
189
+ }
190
+ modules.discard("")
191
+ return len(modules) >= 2
192
+
193
+ @staticmethod
194
+ def _module_of(path: str) -> str:
195
+ """Group a source path into its module root.
196
+
197
+ For Maven/Gradle layouts the module is everything before `/src/`
198
+ (e.g. `framework-support/jobrunr-quarkus/src/main/java/...` →
199
+ `framework-support/jobrunr-quarkus`). Otherwise the top-level directory.
200
+ """
201
+ norm = path.replace("\\", "/")
202
+ idx = norm.find("/src/")
203
+ if idx > 0:
204
+ return norm[:idx]
205
+ head, _, tail = norm.partition("/")
206
+ return head if tail else ""
207
+
208
+ _EVIDENCE_PATH_RE = re.compile(r"\(([^()]+)\)\s*$")
209
+
210
+ def _localized_adapter_frameworks(
211
+ self,
212
+ file_tree: dict[str, Any],
213
+ stacks: Sequence[StackDetection],
214
+ candidate_frameworks: set[str],
215
+ ) -> set[str]:
216
+ """Frameworks confined to a minority module while a framework-agnostic
217
+ module dominates the codebase (library + per-framework adapters).
218
+
219
+ Returns the subset of ``candidate_frameworks`` that should NOT drive the
220
+ project-type decision. A framework qualifies only when (a) the repo is
221
+ multi-module, (b) the framework's evidence files are all outside the
222
+ dominant source module, and (c) the framework has located evidence files
223
+ (a manifest-only/root detection applies repo-wide and never localizes).
224
+ """
225
+ if not candidate_frameworks:
226
+ return set()
227
+
228
+ _CODE_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
229
+ module_file_counts: dict[str, int] = {}
230
+ for p in flatten_file_tree(file_tree):
231
+ if not p.endswith(_CODE_EXTS):
232
+ continue
233
+ mod = self._module_of(p)
234
+ module_file_counts[mod] = module_file_counts.get(mod, 0) + 1
235
+
236
+ # Need a genuine multi-module repo to reason about locality.
237
+ if len(module_file_counts) < 2:
238
+ return set()
239
+ dominant_module = max(module_file_counts, key=lambda m: module_file_counts[m])
240
+
241
+ # Collect evidence file paths per framework from detected_via.
242
+ evidence: dict[str, set[str]] = {}
243
+ for stack in stacks:
244
+ for fw in stack.frameworks:
245
+ if fw.name not in candidate_frameworks:
246
+ continue
247
+ paths = evidence.setdefault(fw.name, set())
248
+ for ev in fw.detected_via:
249
+ if ev.startswith("manifest:"):
250
+ continue
251
+ m = self._EVIDENCE_PATH_RE.search(ev)
252
+ if m:
253
+ paths.add(m.group(1).strip())
254
+
255
+ localized: set[str] = set()
256
+ for fw_name in candidate_frameworks:
257
+ files = evidence.get(fw_name) or set()
258
+ if not files:
259
+ # No locatable evidence (manifest-only) → applies repo-wide.
260
+ continue
261
+ modules = {self._module_of(f) for f in files}
262
+ if dominant_module not in modules:
263
+ localized.add(fw_name)
264
+ return localized
265
+
146
266
  def _is_fullstack(self, stacks: Sequence[StackDetection]) -> bool:
147
267
  has_web = False
148
268
  has_api = False
@@ -90,6 +90,19 @@ _GRADLE_JAVA_VERSION_RE = re.compile(
90
90
  _GRADLE_JAVA_ENUM_RE = re.compile(
91
91
  r"""(?:sourceCompatibility|targetCompatibility)\s*=\s*JavaVersion\.VERSION_(\d+)"""
92
92
  )
93
+ # BUG #4: a gradle line that genuinely declares a dependency or plugin. Matches a
94
+ # quoted Maven coordinate (group:artifact, requires the colon), a plugins-block
95
+ # `id '...'`, an `apply plugin:`, or a `classpath`/`platform(`/`enforcedPlatform(`
96
+ # declaration. Deliberately does NOT match `it.name.contains('quarkus')`-style
97
+ # subproject filters (a bare quoted name with no colon and no plugin/dep keyword).
98
+ _GRADLE_DEP_LINE_RE = re.compile(
99
+ r"""['"][\w.\-]+:[\w.\-]+""" # maven coordinate "group:artifact..."
100
+ r"""|\bclasspath\b"""
101
+ r"""|\b(?:enforced)?platform\s*\("""
102
+ r"""|\bapply\s+plugin\s*:"""
103
+ r"""|\bid\s*[('"]""",
104
+ re.IGNORECASE,
105
+ )
93
106
 
94
107
 
95
108
  class JavaDetector(AbstractDetector):
@@ -279,7 +292,29 @@ class JavaDetector(AbstractDetector):
279
292
  original = "\n".join(read_text_lines(path))
280
293
  content = original.lower()
281
294
  sb_version = self._extract_gradle_sb_version(original)
282
- return self._detect_jvm_frameworks(content, "build.gradle", sb_version=sb_version)
295
+ # BUG #4 (JobRunr field test): framework tokens must be matched only inside
296
+ # genuine dependency / plugin declarations, never anywhere in the file. A
297
+ # multi-module root build.gradle that EXCLUDES a subproject by name —
298
+ # `configure(subprojects.findAll { !it.name.contains('quarkus') })` — must
299
+ # not be read as "this project uses Quarkus". Restrict the scan to lines
300
+ # carrying a Maven coordinate / plugin id; the exclusion filter has neither.
301
+ dep_text = self._gradle_dependency_text(content)
302
+ return self._detect_jvm_frameworks(dep_text, "build.gradle", sb_version=sb_version)
303
+
304
+ @staticmethod
305
+ def _gradle_dependency_text(content: str) -> str:
306
+ """Keep only gradle lines that declare a dependency or plugin.
307
+
308
+ A real dependency carries a Maven coordinate (`group:artifact`...) and a
309
+ plugin carries an `id`/`plugin`/`classpath` token. Subproject-name filters
310
+ and arbitrary prose are dropped, so a framework substring there cannot
311
+ manufacture a phantom framework detection.
312
+ """
313
+ kept: list[str] = []
314
+ for line in content.splitlines():
315
+ if _GRADLE_DEP_LINE_RE.search(line):
316
+ kept.append(line)
317
+ return "\n".join(kept)
283
318
 
284
319
  def _extract_gradle_sb_version(self, content: str) -> str | None:
285
320
  m = _GRADLE_SB_PLUGIN_RE.search(content)
sourcecode/explain.py CHANGED
@@ -223,7 +223,89 @@ def _build_purpose(
223
223
  if role_anns:
224
224
  desc = f"{role_anns[0]} bean"
225
225
 
226
- return desc or "No stereotype detected — may be a plain class or utility."
226
+ if desc:
227
+ return desc
228
+
229
+ # BUG #4 (JobRunr field test): with no recognized framework annotation, the old
230
+ # fallback returned "No stereotype detected — may be a plain class or utility",
231
+ # which is actively misleading for a central domain class. Libraries and clean/
232
+ # hexagonal architectures model rich roles WITHOUT DI annotations. Infer a
233
+ # low-confidence structural role from signals the tool already computes:
234
+ # in-degree (coupling), lifecycle methods, and naming convention.
235
+ structural = _structural_purpose(class_fqn, raw_nodes, cir)
236
+ if structural:
237
+ return structural
238
+ return "No stereotype detected — may be a plain class or utility."
239
+
240
+
241
+ # Lifecycle method names that signal an orchestrator/component managing state.
242
+ _LIFECYCLE_METHODS = frozenset({
243
+ "start", "stop", "init", "initialize", "shutdown", "close",
244
+ "pause", "resume", "run", "destroy", "open", "restart",
245
+ })
246
+
247
+ # Class-name suffix → inferred role (no annotation required).
248
+ _NAME_ROLE_SUFFIXES: tuple[tuple[str, str], ...] = (
249
+ ("Server", "server/orchestrator"),
250
+ ("Manager", "manager/coordinator"),
251
+ ("Controller", "controller"),
252
+ ("Service", "service"),
253
+ ("Repository", "repository/data access"),
254
+ ("Factory", "factory"),
255
+ ("Builder", "builder"),
256
+ ("Handler", "handler"),
257
+ ("Listener", "listener"),
258
+ ("Provider", "provider"),
259
+ ("Registry", "registry"),
260
+ ("Scheduler", "scheduler"),
261
+ ("Dispatcher", "dispatcher"),
262
+ ("Processor", "processor"),
263
+ ("Filter", "filter"),
264
+ ("Interceptor", "interceptor"),
265
+ )
266
+
267
+
268
+ def _structural_purpose(
269
+ class_fqn: str,
270
+ raw_nodes: list[dict],
271
+ cir: "CanonicalRepositoryIR",
272
+ ) -> str:
273
+ """Infer a low-confidence stereotype from structural signals (no annotations)."""
274
+ try:
275
+ in_degree = len(_build_callers(class_fqn, cir))
276
+ except Exception:
277
+ in_degree = 0
278
+
279
+ try:
280
+ method_names = {m.split("(")[0].lower() for m in _build_public_methods(class_fqn, raw_nodes)}
281
+ except Exception:
282
+ method_names = set()
283
+ lifecycle = sorted(method_names & _LIFECYCLE_METHODS)
284
+
285
+ simple = _simple(class_fqn)
286
+ role = ""
287
+ for suffix, label in _NAME_ROLE_SUFFIXES:
288
+ if simple.endswith(suffix):
289
+ role = label
290
+ break
291
+ if not role and lifecycle:
292
+ role = "orchestrator/lifecycle component"
293
+
294
+ # Require at least one real signal — otherwise stay honestly silent.
295
+ if not role and in_degree < 3:
296
+ return ""
297
+
298
+ head = f"Likely {role} (no DI annotations found)" if role else \
299
+ "Likely a structurally significant class (no DI annotations found)"
300
+ signals: list[str] = []
301
+ if in_degree >= 3:
302
+ signals.append(f"high in-degree ({in_degree})")
303
+ elif in_degree:
304
+ signals.append(f"in-degree {in_degree}")
305
+ if lifecycle:
306
+ signals.append(f"lifecycle methods detected ({'/'.join(lifecycle)})")
307
+ suffix = f" — {', '.join(signals)}" if signals else ""
308
+ return f"{head}{suffix} — inferred from structure, not annotations (low confidence)."
227
309
 
228
310
 
229
311
  def _build_public_methods(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
@@ -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,
@@ -200,6 +200,27 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
200
200
  has_mail_import = bool(_MAIL_IMPORT_RE.search(text))
201
201
  naming_factory = _classify_naming_factory(text)
202
202
 
203
+ # BUG #3 (v1.70.0): "HttpClient" is a simple name that collides with
204
+ # user-defined classes (e.g. org.openmrs.util.HttpClient, a thin wrapper over
205
+ # java.net.HttpURLConnection — a completely different API from the JDK 11+
206
+ # java.net.http.HttpClient). Resolve the JDK client by its FULLY-QUALIFIED
207
+ # import, never by the bare class name. When the file imports/declares a
208
+ # different HttpClient (or none can be resolved), degrade to a low-confidence
209
+ # "custom-http-wrapper" rather than asserting a JDK client that isn't there.
210
+ import_fqns = set(
211
+ re.findall(r"^\s*import\s+(?:static\s+)?([\w.]+)\s*;", text, re.MULTILINE)
212
+ )
213
+ http_jdk_imported = (
214
+ "java.net.http.HttpClient" in import_fqns or "java.net.http.*" in import_fqns
215
+ )
216
+ declares_own_httpclient = bool(
217
+ re.search(r"\b(?:class|interface|enum)\s+HttpClient\b", text)
218
+ )
219
+ http_other_import = any(
220
+ fqn.endswith(".HttpClient") and not fqn.startswith("java.net.http.")
221
+ for fqn in import_fqns
222
+ )
223
+
203
224
  # Token clients — per line, skipping imports/package/comment noise.
204
225
  # First pass records the declaration site and any variable name bound to
205
226
  # the client, so a later call site (where the URL literal usually lives)
@@ -241,6 +262,27 @@ def detect_integrations(file_paths: "list[str]", root: Path) -> dict:
241
262
  kind, client, confidence = (
242
263
  "naming-directory-unknown", "jndi-dircontext", "low",
243
264
  )
265
+ # BUG #3: resolve the ambiguous bare "HttpClient" by its import, and
266
+ # suppress pure type-declaration sites (field / parameter / return
267
+ # type) — only a construction or static call is a real network site.
268
+ if client == "jdk-httpclient":
269
+ if not (http_jdk_imported and not declares_own_httpclient):
270
+ # Not the JDK client (own class, third-party, or unresolvable).
271
+ client, confidence = "custom-http-wrapper", "low"
272
+ if declares_own_httpclient or http_other_import:
273
+ confidence = "low"
274
+ is_construction = bool(
275
+ re.search(r"\bnew\s+HttpClient\b", line)
276
+ ) or bool(re.search(r"\bHttpClient\s*\.", line))
277
+ if not is_construction:
278
+ # Type-declaration only (e.g. `HttpClient field;`,
279
+ # `void setX(HttpClient c)`): track the var for the URL
280
+ # second pass but do NOT emit it as an invocation site.
281
+ tok = m.group(0)
282
+ decl = re.search(re.escape(tok) + r"\s+(\w+)\b", line)
283
+ if decl:
284
+ var_to_client[decl.group(1)] = (kind, client)
285
+ continue
244
286
  _add(kind, client, _extract_target(line), rel, lineno, confidence)
245
287
  tok = m.group(0)
246
288
  # `Type name` (field/local decl) and `name = new Type(` forms.
@@ -0,0 +1,26 @@
1
+ """GENERATED FILE — do not edit by hand.
2
+
3
+ Allowlist of sun.* / com.sun.* packages exported UNCONDITIONALLY by the
4
+ JDK (no `--add-exports` / `--add-opens` required on classpath or module
5
+ path). Consumed by migrate-check MIG-011 to suppress false positives.
6
+
7
+ Regenerate with: python scripts/generate_jdk_exports.py > src/sourcecode/jdk_exports.py
8
+ Generated from: java version "21.0.10" 2026-01-20 LTS
9
+ """
10
+ from __future__ import annotations
11
+
12
+
13
+ JDK_UNCONDITIONAL_EXPORTS: frozenset[str] = frozenset(
14
+ {
15
+ "com.sun.java.accessibility.util",
16
+ "com.sun.management",
17
+ "com.sun.net.httpserver",
18
+ "com.sun.net.httpserver.spi",
19
+ "com.sun.nio.sctp",
20
+ "com.sun.security.auth",
21
+ "com.sun.security.auth.callback",
22
+ "com.sun.security.auth.login",
23
+ "com.sun.security.auth.module",
24
+ "com.sun.security.jgss",
25
+ }
26
+ )
@@ -23,6 +23,7 @@ from pathlib import Path
23
23
  from typing import Optional, TYPE_CHECKING
24
24
 
25
25
  from sourcecode.path_filters import is_test_or_fixture_path
26
+ from sourcecode.jdk_exports import JDK_UNCONDITIONAL_EXPORTS
26
27
 
27
28
  if TYPE_CHECKING:
28
29
  from sourcecode.hibernate_strat import HibernateStratification
@@ -381,15 +382,22 @@ _JAVA_9_RULES: list[_Rule] = [
381
382
  severity="high",
382
383
  title="JDK internal API imports (sun.* / com.sun.net.*) — strong encapsulation since Java 9",
383
384
  explanation=(
384
- "Imports from sun.* and com.sun.net.* reference JDK-internal APIs that are "
385
- "not part of the public specification. Since Java 9 (JPMS), these packages are "
386
- "strongly encapsulated and require --add-exports / --add-opens JVM flags, "
387
- "which are cumbersome and may be removed in future Java releases."
385
+ "Imports from sun.* and com.sun.* (tools/jdi/source internals) reference "
386
+ "JDK-internal APIs that are not part of the public specification. Since Java 9 "
387
+ "(JPMS), these packages are strongly encapsulated and require --add-exports / "
388
+ "--add-opens JVM flags, which are cumbersome and may be removed in future Java "
389
+ "releases. Packages the JDK exports UNCONDITIONALLY (e.g. com.sun.net.httpserver "
390
+ "in jdk.httpserver, com.sun.management in jdk.management) are NOT flagged: they "
391
+ "need no JVM flags on any classpath or module path."
388
392
  ),
389
393
  fix_hint=(
390
394
  "Replace internal API usage with public equivalents. "
391
- "For com.sun.net.httpserver, migrate to java.net.http.HttpServer or a framework. "
392
- "Add '--add-exports java.base/sun.misc=ALL-UNNAMED' only as a last resort."
395
+ "For sun.misc.Unsafe migrate to java.lang.invoke.VarHandle; for com.sun.tools.* "
396
+ "use the public javax.tools / java.compiler API. "
397
+ "Add '--add-exports java.base/sun.misc=ALL-UNNAMED' only as a last resort. "
398
+ "Note: unconditionally-exported packages (com.sun.net.httpserver, "
399
+ "com.sun.management, com.sun.security.auth, ...) are auto-excluded — they are "
400
+ "public, stable, and require no migration."
393
401
  ),
394
402
  migration_target="java_9_plus",
395
403
  openrewrite_recipe=None,
@@ -585,6 +593,45 @@ def _is_no_migrate_javax(fqn: str) -> bool:
585
593
  return any(fqn.startswith(p) for p in _JAKARTA_NO_MIGRATE_PREFIXES)
586
594
 
587
595
 
596
+ # BUG #1 (JobRunr field test): MIG-011 flags `sun.*` / `com.sun.*` imports as
597
+ # strongly-encapsulated JDK internals on a pure PREFIX heuristic. That is wrong
598
+ # for packages the JDK exports UNCONDITIONALLY (no `to` clause) — e.g.
599
+ # `com.sun.net.httpserver` (module jdk.httpserver, public since Java 6, the basis
600
+ # of JEP 408) or `com.sun.management` (jdk.management, JMX/diagnostics). These
601
+ # need NO `--add-exports` / `--add-opens` on any classpath or module path, so they
602
+ # must not be flagged `high` / `manual_migration`. The allowlist is generated from
603
+ # the running JDK by scripts/generate_jdk_exports.py (see sourcecode/jdk_exports.py),
604
+ # never hand-maintained. Genuinely-internal packages (sun.misc.Unsafe, com.sun.tools.*,
605
+ # com.sun.jdi.*, com.sun.source.*) are NOT in the allowlist and keep `high` severity.
606
+
607
+
608
+ def _import_package(fqn: str) -> str:
609
+ """Extract the Java package of an import FQN.
610
+
611
+ Packages are lowercase by convention and types are Capitalized, so the
612
+ package is the maximal prefix of non-type segments. Handles wildcard
613
+ (`a.b.*`) and `static` imports. Conservative: an unrecognized shape yields
614
+ the leading lowercase run, never a broader prefix — so a sub-package like
615
+ `com.sun.management.internal` is never confused with `com.sun.management`.
616
+ """
617
+ fqn = fqn.strip().rstrip(";").strip()
618
+ if fqn.startswith("static "):
619
+ fqn = fqn[len("static "):].strip()
620
+ if fqn.endswith(".*"):
621
+ return fqn[:-2]
622
+ pkg_parts: list[str] = []
623
+ for seg in fqn.split("."):
624
+ if seg[:1].isupper(): # first type segment — package ends here
625
+ break
626
+ pkg_parts.append(seg)
627
+ return ".".join(pkg_parts)
628
+
629
+
630
+ def _is_jdk_unconditional_export(fqn: str) -> bool:
631
+ """True if an import targets a package the JDK exports unconditionally."""
632
+ return _import_package(fqn) in JDK_UNCONDITIONAL_EXPORTS
633
+
634
+
588
635
  # BUG #8: autogenerated source markers — path fragments and the JSR-250 marker.
589
636
  _GENERATED_PATH_FRAGMENTS: tuple[str, ...] = (
590
637
  "/generated-sources/", "/generated/", "/target/generated",
@@ -1240,6 +1287,7 @@ class MigrationReport:
1240
1287
  readiness_aggregate: dict = field(default_factory=dict)
1241
1288
  blocking_count: int = 0
1242
1289
  estimated_effort_days: float = 0.0
1290
+ effort_breakdown: dict = field(default_factory=dict)
1243
1291
  # Tri-state: True = Boot 2 confirmed, False = Boot 3+ confirmed,
1244
1292
  # None = could not determine. Absence of evidence is never reported as True.
1245
1293
  spring_boot_2_detected: Optional[bool] = None
@@ -1452,13 +1500,32 @@ class MigrationReport:
1452
1500
 
1453
1501
  # BUG #5: effort over MAIN findings only — N/A axes (Hibernate-6 phantom,
1454
1502
  # test fixtures) no longer pad the estimate.
1455
- self.estimated_effort_days = round(
1503
+ _file_effort = (
1456
1504
  len(critical_files) * 0.5
1457
1505
  + len(high_files) * 0.25
1458
1506
  + len(medium_files) * 0.1
1459
- + len(low_files) * 0.05,
1460
- 1,
1507
+ + len(low_files) * 0.05
1461
1508
  )
1509
+ # BUG #1 (v1.70.0): when the Hibernate 5→6 axis APPLIES, fold its measured
1510
+ # rewrite effort (risk_matrix → total_effort_range_days) into the headline
1511
+ # estimate. Previously a Hibernate-5 project whose ${hibernateVersion} was
1512
+ # misread as 6 set migration_applicable=False, which silently DROPPED this
1513
+ # 28.9–95.6 person-day range from estimated_effort_days, under-reporting the
1514
+ # real cost 1.5–2.6×. We add the range midpoint and expose the breakdown.
1515
+ _hib_effort = 0.0
1516
+ if _hibernate_applies and hib is not None:
1517
+ _r = hib.total_effort_range_days or {}
1518
+ _lo, _hi = _r.get("low"), _r.get("high")
1519
+ if isinstance(_lo, (int, float)) and isinstance(_hi, (int, float)):
1520
+ _hib_effort = (float(_lo) + float(_hi)) / 2.0
1521
+ self.estimated_effort_days = round(_file_effort + _hib_effort, 1)
1522
+ self.effort_breakdown = {
1523
+ "findings_effort_days": round(_file_effort, 1),
1524
+ "hibernate_rewrite_effort_days": round(_hib_effort, 1),
1525
+ "hibernate_rewrite_range": (
1526
+ hib.total_effort_range_days if (_hibernate_applies and hib is not None) else None
1527
+ ),
1528
+ }
1462
1529
 
1463
1530
  # BUG #6 / #8: hygiene + non-blocking buckets, surfaced separately.
1464
1531
  self.hygiene_findings = sum(
@@ -1514,6 +1581,7 @@ class MigrationReport:
1514
1581
  "headline_blocker": self.headline_blocker,
1515
1582
  "blocking_count": self.blocking_count,
1516
1583
  "estimated_effort_days": self.estimated_effort_days,
1584
+ "effort_breakdown": self.effort_breakdown,
1517
1585
  "hygiene_findings": self.hygiene_findings,
1518
1586
  "non_blocking": self.non_blocking,
1519
1587
  "spring_present": self.spring_present,
@@ -1698,6 +1766,16 @@ def _scan_file(
1698
1766
  # javax.annotation.processing.*, ...). These do NOT migrate to jakarta.
1699
1767
  if matches and rule.migration_target == "jakarta":
1700
1768
  matches = [m for m in matches if not _is_no_migrate_javax(m.group(1).strip())]
1769
+ # BUG #1: MIG-011 prefix heuristic must not flag sun.*/com.sun.* packages
1770
+ # the JDK exports unconditionally (no --add-exports/--add-opens needed).
1771
+ # Drop those imports; if none remain, the file produces no MIG-011 finding
1772
+ # (so it never inflates blocking_count / effort). Genuinely-internal
1773
+ # packages are absent from the allowlist and survive as `high`.
1774
+ if matches and rule.id == "MIG-011":
1775
+ matches = [
1776
+ m for m in matches
1777
+ if not _is_jdk_unconditional_export(m.group(1).strip())
1778
+ ]
1701
1779
  if matches:
1702
1780
  import_first_line = source[: matches[0].start()].count("\n") + 1
1703
1781
  matched_imports = [m.group(1) for m in matches]
@@ -3366,9 +3366,20 @@ def build_repo_ir(
3366
3366
  and not _INHERIT_PRESCAN_RE.search(source):
3367
3367
  pkg_m = _PKG_RE.search(source)
3368
3368
  _pkg = pkg_m.group(1) if pkg_m else ""
3369
- # Minimal class-name symbols for same-package map (no methods/fields)
3369
+ # Minimal class-name symbols for same-package map (no methods/fields).
3370
+ # BUG #2 (JobRunr field test): this fast-path regex previously ran over RAW
3371
+ # source, so prose inside Javadoc/comments and string literals (e.g.
3372
+ # "This class provides the entry point", "...the interface is Serializable
3373
+ # ...instead.") was tokenized into phantom type symbols like
3374
+ # `org.jobrunr.configuration.provides`. Those leaked into the symbol graph
3375
+ # and every consumer of it (modernize statically_unreferenced /
3376
+ # framework_dispatched, impact, export --c4). Strip comments AND string
3377
+ # literals before scanning so only real declarations are captured. The name
3378
+ # must also start uppercase ([A-Z]) — Java type convention, matching the
3379
+ # precision of the full _CLASS_DECL_RE used on annotated files.
3380
+ _decl_source = _STRING_LITERAL_RE.sub('', _strip_java_comments(source))
3370
3381
  _min_syms: list[SymbolRecord] = []
3371
- for _cm in re.finditer(r'(?:class|interface|enum)\s+(\w+)', source):
3382
+ for _cm in re.finditer(r'\b(?:class|interface|enum)\s+([A-Z]\w*)', _decl_source):
3372
3383
  _cls_name = _cm.group(1)
3373
3384
  _fqn = f"{_pkg}.{_cls_name}" if _pkg else _cls_name
3374
3385
  _min_syms.append(SymbolRecord(
@@ -4023,6 +4034,27 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
4023
4034
  _SERVLET_RE = _re.compile(r"\bextends\s+\w*HttpServlet\b")
4024
4035
  _nonspring: dict[str, int] = {"webscripts": 0, "jax_rs": 0, "servlets": 0}
4025
4036
 
4037
+ # BUG #3 (JobRunr field test): imperative router-DSL routes. Lightweight HTTP
4038
+ # frameworks that deliberately avoid Spring/JAX-RS (JobRunr's own dashboard
4039
+ # handler, Javalin, Spark Java, hand-rolled routers) register routes as method
4040
+ # calls `get("/path", handler)` / `post(...)` instead of annotations, so the
4041
+ # annotation surface above never sees them and `endpoints` returns 0 — a silent
4042
+ # total false negative that also disables the `validation` command downstream.
4043
+ # Detection is by SYNTACTIC SHAPE (no framework knowledge): an HTTP-verb method
4044
+ # name, a first argument that is a string literal looking like a path (starts
4045
+ # with "/", may contain :param / {param}), AND a second argument (the trailing
4046
+ # comma) — the comma is what distinguishes a route registration from a 1-arg
4047
+ # getter such as Map.get("/k"). Matches both bare `get(...)` (static-import /
4048
+ # Spark style) and `app.get(...)` (Javalin style); the lookbehind only rejects
4049
+ # an identifier char so `forget(` is not mistaken for `get(`. Reported at
4050
+ # confidence "medium" — an occasional flagged false positive beats a total
4051
+ # silent false negative.
4052
+ _DSL_ROUTE_RE = _re.compile(
4053
+ r'(?<![A-Za-z0-9_])(get|post|put|delete|patch|head|options)\s*\(\s*'
4054
+ r'"(/[^"\s]*)"\s*,'
4055
+ )
4056
+ _dsl_routes: list[dict] = []
4057
+
4026
4058
  for jf in java_files:
4027
4059
  try:
4028
4060
  source = jf.read_text(encoding="utf-8", errors="replace")
@@ -4051,6 +4083,27 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
4051
4083
  if m:
4052
4084
  extends_map[sym.symbol] = m.group(1)
4053
4085
 
4086
+ # BUG #3: scan for imperative router-DSL route registrations. Strip comments
4087
+ # first so commented-out / example routes don't leak in. The enclosing class
4088
+ # is the first class/interface symbol in the file (route DSLs live in one
4089
+ # handler class); fall back to the file stem when none was extracted.
4090
+ _dsl_src = _strip_java_comments(source)
4091
+ _dsl_matches = list(_DSL_ROUTE_RE.finditer(_dsl_src))
4092
+ if _dsl_matches:
4093
+ _cls_fqn = next(
4094
+ (s.symbol for s in symbols if s.type in ("class", "interface")), None
4095
+ )
4096
+ _cls_simple = (
4097
+ _cls_fqn.split(".")[-1] if _cls_fqn else Path(rel).stem
4098
+ )
4099
+ for _m in _dsl_matches:
4100
+ _dsl_routes.append({
4101
+ "method": _m.group(1).upper(),
4102
+ "path": _m.group(2),
4103
+ "controller": _cls_simple,
4104
+ "effective_class": _cls_fqn or _cls_simple,
4105
+ })
4106
+
4054
4107
  routes = _build_route_surface(
4055
4108
  all_symbols, route_diffs=None, extends_map=extends_map,
4056
4109
  custom_security=_custom_sec_tuple,
@@ -4226,6 +4279,32 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
4226
4279
  if e.get("security", {}).get("policy") == "none_detected"
4227
4280
  )
4228
4281
 
4282
+ # BUG #3: merge imperative router-DSL endpoints. Dedup against the annotation
4283
+ # surface (method+path) in case a handler is both annotated and DSL-registered.
4284
+ # These carry confidence "medium" (no unambiguous annotation) and explicit
4285
+ # provenance so a consumer can tell shape-detected routes from annotated ones.
4286
+ if _dsl_routes:
4287
+ _seen_mp = {(e.get("method"), e.get("path")) for e in endpoints}
4288
+ for _r in _dsl_routes:
4289
+ _key = (_r["method"], _r["path"])
4290
+ if _key in _seen_mp:
4291
+ continue
4292
+ _seen_mp.add(_key)
4293
+ endpoints.append({
4294
+ "method": _r["method"],
4295
+ "path": _r["path"],
4296
+ "controller": _r["controller"],
4297
+ "handler": _r["controller"],
4298
+ "return_type": "unknown",
4299
+ "security": {"policy": "none_detected"},
4300
+ "confidence": "medium",
4301
+ "source": "router_dsl",
4302
+ })
4303
+ no_security_signal = sum(
4304
+ 1 for e in endpoints
4305
+ if e.get("security", {}).get("policy") == "none_detected"
4306
+ )
4307
+
4229
4308
  # Append spec-recovered endpoints AFTER the security-model heuristics (which
4230
4309
  # are about annotation/filter/XML coverage of scanned source) so spec-sourced
4231
4310
  # entries don't skew those signals. They carry their own source provenance.
@@ -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.68.0
3
+ Version: 1.70.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
@@ -1,12 +1,12 @@
1
- sourcecode/__init__.py,sha256=gp3XLYiUZaatdehs90NAezSd-3vDxmo0g-OIVUqEBmg,103
1
+ sourcecode/__init__.py,sha256=Carzg_e30MI_EAuPeCeJOnrSKumFIJRAPHiKk6kpPak,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=UbfVpFRk7dqtX_o-B5VFXlzcx9cr1JmMl-cAm3tmHYw,22650
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
8
8
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
9
- sourcecode/classifier.py,sha256=hKzg-nQ47htqqIUzSGvYxv15cXrA3KgICTwJmdqal0o,8095
9
+ sourcecode/classifier.py,sha256=YTTCoRdcLEFRVcql9Ow1dE7eYQj0jq2rgx32bRDnb1k,13852
10
10
  sourcecode/cli.py,sha256=VqAiGRauTO-s8yBJh2JMldha3VKx0jTXC0tOvRpWQkI,283297
11
11
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
12
12
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
@@ -20,7 +20,7 @@ sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24
20
20
  sourcecode/entrypoint_classifier.py,sha256=jhTYlyqDJH2AtdEcLVaRU3lYRTJuF8DkxVzl4-W3zWE,5322
21
21
  sourcecode/env_analyzer.py,sha256=aNTyYgQk5noJDfJU6FmasmESOHfiomyJw5EvZqjy6qc,22213
22
22
  sourcecode/error_schema.py,sha256=uwosfNaSujtYm11_732Hu92z5ITV040fQDaIyefSvR4,1683
23
- sourcecode/explain.py,sha256=dVG35YBlpRmbtOXSmspEhoIwDMVApPmLISBy3iigUSc,16913
23
+ sourcecode/explain.py,sha256=GbcruAyzlmseV3o2rjeyxGQxToCfSYHJjKG3N05NVbQ,19897
24
24
  sourcecode/file_chunker.py,sha256=3vkM3mDQ5eE_yTPvUgjyjpGFBIjkW6_mrBmIbrylnA8,16444
25
25
  sourcecode/file_classifier.py,sha256=A0fEABqtfVu1MfoaxnPAvGpZgneGgVXlJDhT74NYXxE,15314
26
26
  sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,28781
@@ -28,12 +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=PibFXxwFHRNQ3twJFVkqzHTfNLRtEf94DK9fPDnAtfQ,15499
33
+ sourcecode/jdk_exports.py,sha256=fCrlwNAXUT9gge_joq6kMnY3zJxYB2pxqy-0w3o3MJI,874
33
34
  sourcecode/license.py,sha256=wckiLuiwaE35KMCStUf1gYzleJuFe6qsSyxUQJvit3s,23500
34
35
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
35
36
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
36
- sourcecode/migrate_check.py,sha256=Z0AZhWuZWfGuG3dsQugJn92FRpId5-SFAoyMc1RBZHk,91908
37
+ sourcecode/migrate_check.py,sha256=6Bp57IhQNqGhgL2hfqFasq3PHzsEWFa7abAQDv9t5MI,96348
37
38
  sourcecode/openapi_surface.py,sha256=BTt0K-woZbkbWTN77IkqeBm_Okag9owR0848fmot8sk,16207
38
39
  sourcecode/output_budget.py,sha256=Js9yUlfQtPhqBl9R6wn_9UHVjjJc3GtLcqyfjf5t50Q,9869
39
40
  sourcecode/path_filters.py,sha256=VnaD9jxZVNzluackNSTCdIddwzAqIseuSCs_A-gpCDM,6898
@@ -46,7 +47,7 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
46
47
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
47
48
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
48
49
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
49
- sourcecode/repository_ir.py,sha256=Blo4rTCkY7EppwunxqlvI-1b9wU3EPLuHlZik3Ksz7c,224908
50
+ sourcecode/repository_ir.py,sha256=tP9L8v9K2xg-xb93N1aELJP-pHHxww8537GZ7Sf0_94,229324
50
51
  sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
51
52
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
52
53
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
@@ -56,7 +57,7 @@ sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIx
56
57
  sourcecode/serializer.py,sha256=MSxYZ-_UYDPKMvg-hVk-MnKN-TfrmxXL202siCssa9U,129110
57
58
  sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
58
59
  sourcecode/spring_findings.py,sha256=G7Or2lKBUQbcTDqudLvSs9XvNg_YoAa-_lBOG_ULs8E,5457
59
- sourcecode/spring_impact.py,sha256=qLwLfItX_o9LU-k_qjhD2hFpTX3PpEQ85TsYTAArzvg,56016
60
+ sourcecode/spring_impact.py,sha256=1Eu1vkdwTVsw92iBEJDe_i_FaSf4uGH9Rb0eSgIAAZc,57542
60
61
  sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
61
62
  sourcecode/spring_security_audit.py,sha256=XtPJ1SXlZJ8k6VYmaWuAp7Bbir4UmreAL7doIGQ5I7o,20595
62
63
  sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM,13307
@@ -75,7 +76,7 @@ sourcecode/detectors/elixir.py,sha256=jCpvt5Yi6jvplc80ovRtWh17q-11ZGo9qX7o8b57TJ
75
76
  sourcecode/detectors/go.py,sha256=2r66uRQfeTWsqxr4HDhT6vExZErby0t46QXLHVBRv9w,2782
76
77
  sourcecode/detectors/heuristic.py,sha256=7cRxrip4yIaggYzZJB6ef8yHKh-gHgiH_pXMFcjlyFU,3723
77
78
  sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
78
- sourcecode/detectors/java.py,sha256=HZcK94A3FzhTMXveicKSSDJBvRbgeqnflEP_mPgaOpo,30438
79
+ sourcecode/detectors/java.py,sha256=V9Eb2EMqEdonIhoegvFN2-z95R4Nu9q2y9G54baWShc,32298
79
80
  sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
80
81
  sourcecode/detectors/nodejs.py,sha256=Hg3Gmr7yIMJFiLoDwOTk2wtu00wxIs6kZf-oQujTFUA,13187
81
82
  sourcecode/detectors/parsers.py,sha256=ugPg8yNUf0Ai1gA7Fnn6wAkYGFjTxRodSP3IeViYJJ4,2290
@@ -103,8 +104,8 @@ sourcecode/telemetry/consent.py,sha256=H5z2Wu63pZqbaKucRPoJQJ0zCo4cke9ZlBrJC-MaW
103
104
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
104
105
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
105
106
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
106
- sourcecode-1.68.0.dist-info/METADATA,sha256=_4nRHRSnFFBpmv9OJqSFf30pGcWo7XpOC0gyAJgoT4c,47341
107
- sourcecode-1.68.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
108
- sourcecode-1.68.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
109
- sourcecode-1.68.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
110
- sourcecode-1.68.0.dist-info/RECORD,,
107
+ sourcecode-1.70.0.dist-info/METADATA,sha256=JvldKhPtPefiKgs9lS0IJcWer4MmSHnXBL7NKW_PQS4,47341
108
+ sourcecode-1.70.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
109
+ sourcecode-1.70.0.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
110
+ sourcecode-1.70.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
111
+ sourcecode-1.70.0.dist-info/RECORD,,