sourcecode 1.31.9__py3-none-any.whl → 1.31.11__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.31.9"
3
+ __version__ = "1.31.11"
sourcecode/classifier.py CHANGED
@@ -20,6 +20,7 @@ _API_FRAMEWORKS = {
20
20
  "Rocket",
21
21
  "Spring Boot",
22
22
  "Quarkus",
23
+ "Jakarta EE", # JAX-RS / Jakarta REST — pure JAX-RS projects must not fall to "unknown"
23
24
  "Laravel",
24
25
  "Symfony",
25
26
  "Ktor",
sourcecode/cli.py CHANGED
@@ -858,7 +858,10 @@ def main(
858
858
  # to avoid polluting sub-project directories used in tests.
859
859
  if _git_sha and (target / ".git").exists():
860
860
  # Include every output-affecting flag so different flag combos never collide
861
+ # Include version so cache is invalidated on sourcecode upgrades
862
+ from sourcecode import __version__ as _sc_version
861
863
  _flags_str = (
864
+ f"v={_sc_version},"
862
865
  f"c={compact},ag={agent},fmt={format},full={full},"
863
866
  f"co={changed_only},dep={dependencies},gm={graph_modules},"
864
867
  f"docs={docs},fm={full_metrics},sem={semantics},"
@@ -2475,9 +2478,9 @@ def endpoints_cmd(
2475
2478
  """Extract REST API endpoint surface from Java source files.
2476
2479
 
2477
2480
  \b
2478
- Scans all @GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping
2479
- and @RequestMapping annotations. Extracts HTTP method, path, controller class,
2480
- handler method, and @M3FiltroSeguridad permission resource name.
2481
+ Scans Spring MVC (@GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping/@RequestMapping)
2482
+ and JAX-RS (@GET/@POST/@PUT/@DELETE/@PATCH with @Path) annotations.
2483
+ Extracts HTTP method, path, controller class, and handler method.
2481
2484
 
2482
2485
  \b
2483
2486
  Examples:
@@ -222,11 +222,18 @@ class ConfidenceAnalyzer:
222
222
  ))
223
223
 
224
224
  # Spring profile documentation (comments in application-{profile}.yml)
225
+ # Only relevant for Spring Boot projects — Quarkus/Jakarta use similar file names
226
+ # but they're Quarkus profiles, not Spring profiles.
227
+ _is_spring_boot = any(
228
+ f.name in ("Spring Boot", "Spring")
229
+ for stack in sm.stacks
230
+ for f in getattr(stack, "frameworks", [])
231
+ )
225
232
  _profile_ymls = [
226
233
  p for p in sm.file_paths
227
234
  if "application-" in p.rsplit("/", 1)[-1] and p.endswith((".yml", ".yaml", ".properties"))
228
235
  ]
229
- if _profile_ymls:
236
+ if _is_spring_boot and _profile_ymls:
230
237
  # Check for at least one comment line (# ...) across profile files
231
238
  _root = Path(sm.metadata.analyzed_path) if sm.metadata.analyzed_path else None
232
239
  _has_profile_docs = False
@@ -128,8 +128,13 @@ def _infer_role(name: str, ecosystem: str, scope: str) -> str:
128
128
  return "runtime"
129
129
 
130
130
  if ecosystem == "java":
131
+ # Scope is authoritative: provided and dev scopes are not runtime, regardless of
132
+ # artifact name. Checking artifact patterns first would mis-classify spring-boot-test
133
+ # (scope=test) as "runtime", inflating false-positive fan-in signals.
131
134
  if scope == "provided":
132
135
  return "provided"
136
+ if is_dev:
137
+ return "devtool"
133
138
  artifact = n.split(":")[-1] if ":" in n else n
134
139
  if any(x in artifact for x in ("spring-boot", "spring-security")):
135
140
  return "runtime"
@@ -143,7 +148,7 @@ def _infer_role(name: str, ecosystem: str, scope: str) -> str:
143
148
  return "parsing"
144
149
  if any(x in artifact for x in ("jjwt", "nimbus-jose")):
145
150
  return "runtime"
146
- return "devtool" if is_dev else "runtime"
151
+ return "runtime"
147
152
 
148
153
  return "devtool" if is_dev else "runtime"
149
154
 
@@ -1142,7 +1147,7 @@ class DependencyAnalyzer:
1142
1147
  records: list[DependencyRecord] = []
1143
1148
  deps_elem = root_elem.find(f"{ns}dependencies")
1144
1149
  if deps_elem is None:
1145
- return [], ["java: pom.xml sin bloque <dependencies>"]
1150
+ return [], ["java: pom.xml has no <dependencies> block"]
1146
1151
 
1147
1152
  for dep in deps_elem.findall(f"{ns}dependency"):
1148
1153
  group_id = (dep.findtext(f"{ns}groupId") or "").strip()
@@ -1190,7 +1195,7 @@ class DependencyAnalyzer:
1190
1195
 
1191
1196
  limitations: list[str] = []
1192
1197
  if not records:
1193
- limitations.append("java: pom.xml sin dependencias parseables (puede usar BOM o propiedades)")
1198
+ limitations.append("java: pom.xml has no parseable dependencies (may use BOM or properties)")
1194
1199
 
1195
1200
  # Warn when Spring Boot BOM manages transitive deps — they can't be resolved statically.
1196
1201
  parent_artifact_local = (
@@ -34,13 +34,16 @@ _HTTP_PATH_RE = re.compile(
34
34
  _REQUEST_METHOD_VERB_RE = re.compile(
35
35
  r'method\s*=\s*RequestMethod\.([A-Z]+)'
36
36
  )
37
- # @M3FiltroSeguridad custom security annotation
38
- _M3_FILTRO_RE = re.compile(r'@M3FiltroSeguridad\b')
39
- _M3_FILTRO_PARAMS_RE = re.compile(
40
- r'@M3FiltroSeguridad\s*\(\s*'
41
- r'(?:nombreRecurso\s*=\s*(?:"([^"]*)"|([\w.]+)))?' # group 1: string literal, group 2: constant ref
42
- r'(?:[^)]*nivelRequerido\s*=\s*(\d+))?' # group 3: nivel
43
- )
37
+ # Custom security annotation registry — extend here for project-specific annotations.
38
+ # Each entry: annotation_simple_name → compiled params regex.
39
+ # Groups: (1) resource string literal, (2) resource constant ref, (3) level integer.
40
+ _CUSTOM_SECURITY_ANNOTATIONS: dict[str, re.Pattern] = {
41
+ "M3FiltroSeguridad": re.compile(
42
+ r'@M3FiltroSeguridad\s*\(\s*'
43
+ r'(?:nombreRecurso\s*=\s*(?:"([^"]*)"|([\w.]+)))?'
44
+ r'(?:[^)]*nivelRequerido\s*=\s*(\d+))?'
45
+ ),
46
+ }
44
47
 
45
48
  # Security config detection
46
49
  _WEB_SECURITY_CONFIGURER_RE = re.compile(r'WebSecurityConfigurerAdapter\b')
@@ -436,7 +439,7 @@ class JavaDetector(AbstractDetector):
436
439
  ))
437
440
  if (not has_controller and not has_filter and not has_security
438
441
  and "ControllerAdvice" not in content
439
- and "M3FiltroSeguridad" not in content
442
+ and not any(ann in content for ann in _CUSTOM_SECURITY_ANNOTATIONS)
440
443
  and not has_jax_rs and not has_cdi and not has_spi):
441
444
  return []
442
445
 
@@ -449,11 +452,13 @@ class JavaDetector(AbstractDetector):
449
452
  elif verb_match:
450
453
  http_path = f"[{verb_match.group(1)}]"
451
454
  security_evidence = None
452
- m3_match = _M3_FILTRO_PARAMS_RE.search(content)
453
- if m3_match:
454
- nombre = m3_match.group(1) or m3_match.group(2) or ""
455
- nivel = m3_match.group(3) or ""
456
- security_evidence = f"@M3FiltroSeguridad(nombreRecurso={nombre!r}, nivelRequerido={nivel})"
455
+ for _ann_name, _ann_re in _CUSTOM_SECURITY_ANNOTATIONS.items():
456
+ _m = _ann_re.search(content)
457
+ if _m:
458
+ nombre = _m.group(1) or _m.group(2) or ""
459
+ nivel = _m.group(3) or ""
460
+ security_evidence = f"@{_ann_name}(nombreRecurso={nombre!r}, nivelRequerido={nivel})"
461
+ break
457
462
  return [EntryPoint(
458
463
  path=rel_path, stack="java", kind="rest_controller",
459
464
  source="annotation", confidence="high",
@@ -474,11 +479,13 @@ class JavaDetector(AbstractDetector):
474
479
  elif verb_match:
475
480
  http_path = f"[{verb_match.group(1)}]"
476
481
  security_evidence = None
477
- m3_match = _M3_FILTRO_PARAMS_RE.search(content)
478
- if m3_match:
479
- nombre = m3_match.group(1) or m3_match.group(2) or ""
480
- nivel = m3_match.group(3) or ""
481
- security_evidence = f"@M3FiltroSeguridad(nombreRecurso={nombre!r}, nivelRequerido={nivel})"
482
+ for _ann_name, _ann_re in _CUSTOM_SECURITY_ANNOTATIONS.items():
483
+ _m = _ann_re.search(content)
484
+ if _m:
485
+ nombre = _m.group(1) or _m.group(2) or ""
486
+ nivel = _m.group(3) or ""
487
+ security_evidence = f"@{_ann_name}(nombreRecurso={nombre!r}, nivelRequerido={nivel})"
488
+ break
482
489
  return [EntryPoint(
483
490
  path=rel_path, stack="java", kind="mvc_controller",
484
491
  source="annotation", confidence="medium",
@@ -514,7 +521,10 @@ class JavaDetector(AbstractDetector):
514
521
  )]
515
522
 
516
523
  # --- JAX-RS resource class ---
517
- if has_jax_rs and _JAX_RS_PATH_RE.search(content) and _JAX_RS_VERB_RE.search(content):
524
+ # Guard uses annotation PRESENCE ("@Path" in content), not _JAX_RS_PATH_RE, because
525
+ # the value-parsing regex fails for @Path(CONSTANT) and @Path(PREFIX + "/sub").
526
+ # _JAX_RS_PATH_RE is still used to extract the http_path display string when parseable.
527
+ if has_jax_rs and "@Path" in content and _JAX_RS_VERB_RE.search(content):
518
528
  path_m = _JAX_RS_PATH_RE.search(content)
519
529
  verb_m = _JAX_RS_VERB_RE.search(content)
520
530
  http_path: str | None = None
@@ -273,10 +273,11 @@ class DocAnalyzer:
273
273
  name = class_m.group(1)
274
274
  doc_text = self._clean_javadoc(jd_match.group(1))
275
275
  records.append(DocRecord(
276
+ symbol=name,
277
+ kind="class",
278
+ language="java",
276
279
  path=path,
277
280
  workspace=workspace,
278
- kind="class",
279
- name=name,
280
281
  doc_text=doc_text,
281
282
  importance=self._infer_importance(path, "class", entry_points),
282
283
  ))
@@ -291,10 +292,11 @@ class DocAnalyzer:
291
292
  continue
292
293
  doc_text = self._clean_javadoc(jd_match.group(1))
293
294
  records.append(DocRecord(
295
+ symbol=name,
296
+ kind="function",
297
+ language="java",
294
298
  path=path,
295
299
  workspace=workspace,
296
- kind="function",
297
- name=name,
298
300
  doc_text=doc_text,
299
301
  importance=self._infer_importance(path, "function", entry_points),
300
302
  ))
sourcecode/mcp/server.py CHANGED
@@ -78,8 +78,9 @@ def get_endpoints(repo_path: str = ".") -> dict:
78
78
  """REST API endpoint surface extraction from Java source files.
79
79
 
80
80
  Maps to: sourcecode endpoints <repo_path>
81
- Returns: endpoints list with method, path, controller, handler, required_permission;
82
- total count and undocumented count.
81
+ Returns: endpoints list with method, path, controller, handler fields;
82
+ total (int) and undocumented (int) counts.
83
+ Supports Spring MVC (@GetMapping etc.) and JAX-RS (@GET/@POST etc.).
83
84
  repo_path: absolute path to the repository (default: current working directory).
84
85
  """
85
86
  if not isinstance(repo_path, str):
@@ -179,6 +180,49 @@ def onboard_context(repo_path: str = ".") -> dict:
179
180
  return _execute(["prepare-context", "onboard", repo_path])
180
181
 
181
182
 
183
+ @mcp.tool()
184
+ def explain_context(repo_path: str = ".") -> dict:
185
+ """Architecture and entry-point explanation for a repository.
186
+
187
+ Maps to: sourcecode prepare-context explain <repo_path>
188
+ Returns: project summary, architecture, entry points, key dependencies.
189
+ repo_path: absolute path to the repository (default: current working directory).
190
+ """
191
+ if not isinstance(repo_path, str):
192
+ return _err("repo_path must be a string", "INVALID_ARGUMENT")
193
+ return _execute(["prepare-context", "explain", repo_path])
194
+
195
+
196
+ @mcp.tool()
197
+ def refactor_context(repo_path: str = ".") -> dict:
198
+ """Structural issues and refactor opportunities for a repository.
199
+
200
+ Maps to: sourcecode prepare-context refactor <repo_path>
201
+ Returns: structural issues, coupling hotspots, improvement opportunities.
202
+ repo_path: absolute path to the repository (default: current working directory).
203
+ """
204
+ if not isinstance(repo_path, str):
205
+ return _err("repo_path must be a string", "INVALID_ARGUMENT")
206
+ return _execute(["prepare-context", "refactor", repo_path])
207
+
208
+
209
+ @mcp.tool()
210
+ def generate_tests_context(repo_path: str = ".", include_all: bool = False) -> dict:
211
+ """Untested source files and test gap analysis for a repository.
212
+
213
+ Maps to: sourcecode prepare-context generate-tests <repo_path> [--all]
214
+ Returns: test_gaps list of untested files ranked by risk.
215
+ repo_path: absolute path to the repository (default: current working directory).
216
+ include_all: return full test_gaps list without truncating to top 20.
217
+ """
218
+ if not isinstance(repo_path, str):
219
+ return _err("repo_path must be a string", "INVALID_ARGUMENT")
220
+ args = ["prepare-context", "generate-tests", repo_path]
221
+ if include_all:
222
+ args.append("--all")
223
+ return _execute(args)
224
+
225
+
182
226
  _TELEMETRY_ACTIONS = frozenset({"status", "enable", "disable"})
183
227
 
184
228
 
@@ -573,17 +573,17 @@ def _read_code_signal_evidence(root: Path, file_path: str, artifact_type: str) -
573
573
 
574
574
  _ARTIFACT_CHANGE_EFFECT: dict[str, str] = {
575
575
  "entrypoint": "application entrypoint (framework bootstrap / CLI handler)",
576
- "controller": "HTTP routing layer (request-to-handler mapping)",
577
- "service": "business logic layer (@Service component)",
578
- "repository": "data access layer (persistence queries / ORM)",
576
+ "controller": "HTTP handler layer (Spring @RestController / JAX-RS @Path resource)",
577
+ "service": "business logic layer (Spring @Service / CDI @ApplicationScoped bean)",
578
+ "repository": "data access layer (persistence queries / ORM / CDI store)",
579
579
  "mapper": "SQL-object mapping layer (MyBatis mapper / query template)",
580
580
  "security": "security component (authentication / access control configuration)",
581
- "spring_config": "Spring @Configuration class (bean definitions / datasource wiring)",
582
- "spring_profile": "Spring profile override (environment-specific configuration)",
581
+ "spring_config": "framework configuration class (Spring @Configuration / Quarkus @QuarkusApplication)",
582
+ "spring_profile": "environment-specific configuration override (Spring profile / Quarkus profile)",
583
583
  "config": "configuration file (application properties / environment values)",
584
584
  "build_manifest": "build manifest (dependency and plugin configuration)",
585
585
  "db_migration": "database schema migration (DDL change pending execution)",
586
- "domain_model": "domain entity (@Entity / value object)",
586
+ "domain_model": "domain entity (@Entity / CDI model / value object)",
587
587
  "dto": "data transfer object (serialization contract)",
588
588
  "test": "test file (no production code modified)",
589
589
  "documentation": "documentation file (no runtime impact)",
@@ -1810,32 +1810,44 @@ class TaskContextBuilder:
1810
1810
  )[:16000]
1811
1811
  _pub_count = _content.count("public ")
1812
1812
  _ann_count = (
1813
+ # Spring MVC / Spring Boot
1813
1814
  _content.count("@Transactional")
1814
1815
  + _content.count("@RequestMapping")
1815
1816
  + _content.count("@GetMapping")
1816
1817
  + _content.count("@PostMapping")
1817
1818
  + _content.count("@PutMapping")
1818
1819
  + _content.count("@DeleteMapping")
1820
+ # JAX-RS
1821
+ + _content.count("@Path")
1822
+ + _content.count("@GET")
1823
+ + _content.count("@POST")
1824
+ + _content.count("@PUT")
1825
+ + _content.count("@DELETE")
1826
+ + _content.count("@PATCH")
1827
+ # CDI / Jakarta EE
1828
+ + _content.count("@ApplicationScoped")
1829
+ + _content.count("@RequestScoped")
1830
+ + _content.count("@Singleton")
1819
1831
  )
1820
1832
  except OSError:
1821
1833
  pass
1822
1834
  _java_candidates.append({
1823
1835
  "path": _p,
1824
1836
  "public_method_count": _pub_count,
1825
- "has_spring_annotations": _ann_count > 0,
1837
+ "has_framework_annotations": _ann_count > 0,
1826
1838
  "_rank": _pub_count + _ann_count * 2,
1827
1839
  })
1828
1840
 
1829
1841
  _java_candidates.sort(
1830
- key=lambda x: -(x["public_method_count"] * (1.5 if x["has_spring_annotations"] else 1.0))
1842
+ key=lambda x: -(x["public_method_count"] * (1.5 if x["has_framework_annotations"] else 1.0))
1831
1843
  )
1832
1844
  _top = _java_candidates if all_gaps else _java_candidates[:20]
1833
1845
  test_gaps = [
1834
1846
  {
1835
1847
  "path": c["path"],
1836
1848
  "public_method_count": c["public_method_count"],
1837
- "has_spring_annotations": c["has_spring_annotations"],
1838
- "rank_score": round(c["public_method_count"] * (1.5 if c["has_spring_annotations"] else 1.0), 1),
1849
+ "has_framework_annotations": c["has_framework_annotations"],
1850
+ "rank_score": round(c["public_method_count"] * (1.5 if c["has_framework_annotations"] else 1.0), 1),
1839
1851
  }
1840
1852
  for c in _top
1841
1853
  ]
@@ -1893,15 +1905,20 @@ class TaskContextBuilder:
1893
1905
 
1894
1906
  conf_summary, analysis_gaps = ConfidenceAnalyzer().analyze(sm_for_conf)
1895
1907
  confidence = conf_summary.overall
1908
+ _has_mybatis = any(
1909
+ f.name == "MyBatis"
1910
+ for s in stacks
1911
+ for f in getattr(s, "frameworks", [])
1912
+ )
1896
1913
  if task_name in ("delta", "review-pr"):
1897
1914
  # Use delta-specific gaps; ConfidenceAnalyzer gaps are about full-repo
1898
1915
  # detection quality and are not meaningful for an incremental diff.
1899
1916
  gaps = _delta_analysis_gaps
1900
- if _mybatis_warning:
1917
+ if _mybatis_warning and _has_mybatis:
1901
1918
  gaps.append(_mybatis_warning["reason"])
1902
1919
  else:
1903
1920
  gaps = [g.reason for g in analysis_gaps]
1904
- if _mybatis_warning:
1921
+ if _mybatis_warning and _has_mybatis:
1905
1922
  gaps.append(_mybatis_warning["reason"])
1906
1923
 
1907
1924
  # ── 9. why_these_files ────────────────────────────────────────────────
@@ -2103,8 +2120,15 @@ class TaskContextBuilder:
2103
2120
  _uncommitted = uncommitted_files or set()
2104
2121
  _max_churn = max(_hotspots.values(), default=1)
2105
2122
 
2123
+ # Per-file note counts — feeds code_note_count into RankingEngine for all tasks.
2124
+ # RankingEngine is the sole ranking source; no ad-hoc annotation boost outside it.
2125
+ _note_counts: dict[str, int] = {}
2126
+ for _n in (code_notes or []):
2127
+ _np = getattr(_n, "path", "")
2128
+ if _np:
2129
+ _note_counts[_np] = _note_counts.get(_np, 0) + 1
2130
+
2106
2131
  # Pre-compute fix-bug signals (used only when task_name == "fix-bug")
2107
- _annotated_files: set[str] = set()
2108
2132
  _dominant_stack = ""
2109
2133
  _recently_changed_stacks: set[str] = set()
2110
2134
  # Query-aware signals extracted from symptom (class names, exception types, tokens)
@@ -2113,10 +2137,6 @@ class TaskContextBuilder:
2113
2137
  _symptom_tokens: set[str] = set() # all lowercase tokens
2114
2138
 
2115
2139
  if task_name == "fix-bug":
2116
- _bug_kinds = {"FIXME", "BUG", "HACK", "XXX"}
2117
- for _n in (code_notes or []):
2118
- if getattr(_n, "kind", "").upper() in _bug_kinds:
2119
- _annotated_files.add(getattr(_n, "path", ""))
2120
2140
 
2121
2141
  def _file_stack(p: str) -> str:
2122
2142
  ext = Path(p).suffix.lower()
@@ -2167,13 +2187,15 @@ class TaskContextBuilder:
2167
2187
  if is_test and task_name != "generate-tests":
2168
2188
  continue
2169
2189
 
2170
- # Structural + git signals from unified engine (task-weighted)
2190
+ # Structural + git signals from unified engine (task-weighted).
2191
+ # code_note_count routes annotation density through RankingEngine — single source.
2171
2192
  fs = engine.score(
2172
2193
  path,
2173
2194
  is_entrypoint=(path in runtime_entry_set),
2174
2195
  git_churn=_hotspots.get(path, 0),
2175
2196
  max_churn=_max_churn,
2176
2197
  is_changed=(path in _uncommitted),
2198
+ code_note_count=_note_counts.get(path, 0),
2177
2199
  task=task_name,
2178
2200
  )
2179
2201
 
@@ -2257,6 +2279,9 @@ class TaskContextBuilder:
2257
2279
  # Single-token match: no boost — avoids OR explosion
2258
2280
 
2259
2281
  # ── Git / annotation signals ──
2282
+ _note_ct = _note_counts.get(path, 0)
2283
+ if _note_ct > 0:
2284
+ _why_parts.append(f"annotation density ({_note_ct} FIXME/BUG/HACK notes)")
2260
2285
  if path in _uncommitted:
2261
2286
  content_boost += 0.40
2262
2287
  _why_parts.append("uncommitted change (+0.40)")
@@ -2264,9 +2289,6 @@ class TaskContextBuilder:
2264
2289
  if _recency > 0:
2265
2290
  content_boost += _recency
2266
2291
  _why_parts.append(f"recent commits (+{_recency:.2f})")
2267
- if path in _annotated_files:
2268
- content_boost += 0.20
2269
- _why_parts.append("FIXME/BUG annotation (+0.20)")
2270
2292
  _file_stk = _file_stack(path)
2271
2293
  if _dominant_stack and _file_stk == _dominant_stack:
2272
2294
  content_boost += 0.10
@@ -2357,12 +2379,22 @@ class TaskContextBuilder:
2357
2379
  if task_name == "onboard":
2358
2380
  def _arch_layer(p: str) -> str:
2359
2381
  n = Path(p).name.lower()
2382
+ is_java = n.endswith(".java")
2383
+ # HTTP handler layer: Spring MVC controllers AND JAX-RS resources/endpoints
2384
+ # Restrict "resource"/"endpoint" to .java files to avoid matching
2385
+ # Maven's src/main/resources/ directory or XML resource files.
2360
2386
  if "controller" in n:
2361
2387
  return "controllers"
2362
- if "repository" in n or "mapper" in n or "dao" in n:
2388
+ if is_java and ("resource" in n or "endpoint" in n or "restadapter" in n):
2389
+ return "controllers"
2390
+ # Data access layer: Spring repos, DAOs, JDBC, JPA stores
2391
+ if "repository" in n or "mapper" in n or "dao" in n or "store" in n:
2363
2392
  return "repositories"
2393
+ # Business logic: Spring @Service, CDI providers, factories
2364
2394
  if "service" in n:
2365
2395
  return "services"
2396
+ if is_java and ("provider" in n or "factory" in n or "manager" in n):
2397
+ return "services"
2366
2398
  pn = p.replace("\\", "/")
2367
2399
  if "entity" in n or "/entity/" in pn or "/domain/" in pn or "/model/" in pn:
2368
2400
  return "domain"