sourcecode 1.31.32__py3-none-any.whl → 1.32.1__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.32"
3
+ __version__ = "1.32.1"
@@ -152,14 +152,15 @@ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
152
152
 
153
153
  # Higher value = wins when score ties
154
154
  _PATTERN_PRIORITY: dict[str, int] = {
155
- "cqrs": 8,
156
- "clean": 7,
157
- "onion": 6,
158
- "hexagonal": 5,
159
- "monorepo": 4,
160
- "mvc": 3,
161
- "layered": 2,
162
- "fullstack": 1,
155
+ "cqrs": 8,
156
+ "clean": 7,
157
+ "onion": 6,
158
+ "hexagonal": 5,
159
+ "monorepo": 4,
160
+ "spring_mvc_layered": 3,
161
+ "mvc": 3,
162
+ "layered": 2,
163
+ "fullstack": 1,
163
164
  }
164
165
 
165
166
 
@@ -248,9 +249,9 @@ class ArchitectureAnalyzer:
248
249
  pattern, layers = self._detect_layers(filtered)
249
250
  if pattern in (None, "flat", "unknown"):
250
251
  if pattern == "flat":
251
- limitations.append("Patron de capas no detectado: proyecto con estructura plana")
252
+ limitations.append("Layer pattern not detected: project has a flat directory structure")
252
253
  elif pattern == "unknown":
253
- limitations.append("Patron de capas no reconocido: estructura de directorios sin senales claras")
254
+ limitations.append("Unrecognized layer pattern: directory structure has no clear architectural signals")
254
255
 
255
256
  # Step 3b: monorepo override — workspace config is hard evidence.
256
257
  # Overrides all weak inferred patterns; only truly specialised patterns
@@ -269,7 +270,7 @@ class ArchitectureAnalyzer:
269
270
  pattern = "monorepo"
270
271
  layers = mono_layers
271
272
  limitations.append(
272
- "Workspace config detectadoarquitectura refleja topologia de paquetes"
273
+ "Workspace config detectedarchitecture reflects package topology"
273
274
  )
274
275
  ws_files = [p for p in sm.file_paths if p.split("/")[-1] in _WORKSPACE_CONFIG_FILES]
275
276
  evidence.append({
@@ -570,28 +571,76 @@ class ArchitectureAnalyzer:
570
571
  ))
571
572
  return best_pattern, layers
572
573
 
573
- # 2. Microservices structural detection (before file-naming heuristics)
574
+ # 2. Spring domain-module detection (petclinic-style: deep common prefix + feature dirs)
575
+ spring_result = self._detect_spring_domain_modules(source_paths)
576
+ if spring_result is not None:
577
+ return spring_result
578
+
579
+ # 3. Microservices structural detection (before file-naming heuristics)
574
580
  microservices_result = self._detect_microservices(source_paths)
575
581
  if microservices_result is not None:
576
582
  return microservices_result
577
583
 
578
- # 3. Functional file-naming heuristic: *_analyzer.py, cli.py, schema.py, …
584
+ # 5. Functional file-naming heuristic: *_analyzer.py, cli.py, schema.py, …
579
585
  func_result = self._detect_layered_functional(source_paths)
580
586
  if func_result is not None:
581
587
  return func_result
582
588
 
583
- # 4. Modular sub-package heuristic: ≥2 distinct named sub-packages
589
+ # 6. Modular sub-package heuristic: ≥2 distinct named sub-packages
584
590
  modular_result = self._detect_modular(source_paths)
585
591
  if modular_result is not None:
586
592
  return modular_result
587
593
 
588
- # 5. Fallback: flat (shallow) vs truly unknown (deep but unrecognised)
594
+ # 7. Fallback: flat (shallow) vs truly unknown (deep but unrecognised)
589
595
  max_depth = max(
590
596
  (len(p.replace("\\", "/").split("/")) - 1 for p in source_paths),
591
597
  default=0,
592
598
  )
593
599
  return ("flat" if max_depth <= 2 else "unknown"), []
594
600
 
601
+ def _detect_spring_domain_modules(
602
+ self, paths: list[str]
603
+ ) -> Optional[tuple[str, list[ArchitectureLayer]]]:
604
+ """Detect Spring Boot domain-organized packages (petclinic-style).
605
+
606
+ When all source paths share a deep common package prefix
607
+ (e.g. src/main/java/org/springframework/samples/petclinic/),
608
+ strips that prefix and detects feature/domain modules in the remainder.
609
+ Requires ≥3 distinct domain directories to avoid false positives.
610
+ """
611
+ if len(paths) < 6:
612
+ return None
613
+
614
+ parts_list = [p.replace("\\", "/").split("/") for p in paths]
615
+ min_depth = min(len(p) for p in parts_list)
616
+ common_depth = 0
617
+ for i in range(min_depth - 1):
618
+ seg = parts_list[0][i]
619
+ if all(pl[i] == seg for pl in parts_list):
620
+ common_depth = i + 1
621
+ else:
622
+ break
623
+
624
+ if common_depth < 3:
625
+ return None
626
+
627
+ module_files: dict[str, list[str]] = {}
628
+ for orig, pl in zip(paths, parts_list):
629
+ remaining = pl[common_depth:]
630
+ if remaining and remaining[0] not in _GENERIC_NAMES:
631
+ module_files.setdefault(remaining[0], []).append(orig)
632
+
633
+ meaningful = {k: v for k, v in module_files.items() if len(v) >= 2}
634
+ if len(meaningful) < 3:
635
+ return None
636
+
637
+ return "spring_mvc_layered", [
638
+ ArchitectureLayer(
639
+ name=k, pattern="spring_mvc_layered", files=v, confidence="medium"
640
+ )
641
+ for k, v in meaningful.items()
642
+ ]
643
+
595
644
  def _detect_microservices(
596
645
  self, paths: list[str]
597
646
  ) -> Optional[tuple[str, list[ArchitectureLayer]]]:
sourcecode/classifier.py CHANGED
@@ -118,6 +118,10 @@ class TypeClassifier:
118
118
  ):
119
119
  return "webapp"
120
120
 
121
+ _SERVERSIDE_TEMPLATE_FRAMEWORKS = frozenset({"Thymeleaf", "FreeMarker"})
122
+ if framework_names & _SERVERSIDE_TEMPLATE_FRAMEWORKS:
123
+ return "web_mvc"
124
+
121
125
  if framework_names & _API_FRAMEWORKS:
122
126
  return "api"
123
127
 
sourcecode/cli.py CHANGED
@@ -781,6 +781,11 @@ def main(
781
781
  err=True,
782
782
  )
783
783
 
784
+ # Pro gate for --full: removing truncation limits is enterprise-scale functionality.
785
+ if full:
786
+ from sourcecode.license import require_feature as _req_full
787
+ _req_full("--full")
788
+
784
789
  # P0-2 FIX: --compact and --full are mutually exclusive.
785
790
  # compact is designed to be a bounded summary; --full removes truncation limits,
786
791
  # which contradicts compact's purpose. Use --agent --full for expanded output.
@@ -2672,6 +2677,32 @@ def prepare_context_cmd(
2672
2677
  _pc_budget = _pc_budgets.get(task, BUDGET_EXPLAIN)
2673
2678
  out = _pc_trim(out, _pc_budget, label=task)
2674
2679
 
2680
+ # Free-tier limits: fix-bug (top-5 files) and review-pr (lightweight).
2681
+ # Pro users get the full analysis; free users get enough to see the value.
2682
+ if task in ("fix-bug", "review-pr"):
2683
+ from sourcecode.license import can_use as _tier_can_use
2684
+ if not _tier_can_use(task):
2685
+ _FREE_FILE_LIMIT = 5
2686
+ if task == "fix-bug":
2687
+ _rf = out.get("relevant_files")
2688
+ if isinstance(_rf, list) and len(_rf) > _FREE_FILE_LIMIT:
2689
+ out["relevant_files"] = _rf[:_FREE_FILE_LIMIT]
2690
+ out["tier"] = "free"
2691
+ out["tier_note"] = (
2692
+ f"Showing top {_FREE_FILE_LIMIT} files. "
2693
+ "Upgrade to Pro for complete risk-ranked analysis across all files."
2694
+ )
2695
+ else: # review-pr
2696
+ for _cap_field in ("runtime_changes", "execution_paths", "review_hotspots", "suggested_review_order"):
2697
+ _fval = out.get(_cap_field)
2698
+ if isinstance(_fval, list) and len(_fval) > _FREE_FILE_LIMIT:
2699
+ out[_cap_field] = _fval[:_FREE_FILE_LIMIT]
2700
+ out["tier"] = "free"
2701
+ out["tier_note"] = (
2702
+ "Lightweight review. Upgrade to Pro for full blast-radius analysis, "
2703
+ "complete execution paths, and CI-grade risk scoring."
2704
+ )
2705
+
2675
2706
  if format == "github-comment" and task == "review-pr":
2676
2707
  from sourcecode.pr_comment_renderer import render_github_comment
2677
2708
  _pc_content = render_github_comment(out)
@@ -3373,13 +3404,11 @@ def modernize_cmd(
3373
3404
  sourcecode onboard . — Architecture overview first
3374
3405
  sourcecode impact <target> — Verify impact before touching a hotspot
3375
3406
  """
3376
- from sourcecode.license import require_pro as _require_pro
3377
- _require_pro("modernize")
3378
-
3379
3407
  import json as _json
3380
3408
  import sys as _sys
3381
3409
  from sourcecode.repository_ir import build_repo_ir, find_java_files, apply_ir_size_limits
3382
3410
  from sourcecode.output_budget import trim_to_budget, BUDGET_ONBOARD
3411
+ from sourcecode.license import can_use as _mod_can_use
3383
3412
 
3384
3413
  root = path.resolve()
3385
3414
  if not root.is_dir():
@@ -3481,52 +3510,73 @@ def modernize_cmd(
3481
3510
  key=lambda s: -len(s.get("members") or []),
3482
3511
  )[:10]
3483
3512
 
3484
- result = {
3485
- "workflow": "modernize",
3486
- "path": str(root),
3487
- "summary": {
3488
- "total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
3489
- "total_subsystems": len(subsystems),
3490
- "high_coupling_nodes": len(coupling_nodes),
3491
- "dead_zone_candidates": len(dead_zones),
3492
- },
3493
- "hotspot_candidates": hotspots,
3494
- "high_coupling_nodes": [
3495
- {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
3496
- for n in coupling_nodes
3497
- ],
3498
- "dead_zone_candidates": [
3499
- {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
3500
- for n in dead_zones
3501
- ],
3502
- "subsystem_summary": [
3503
- {
3504
- "label": s.get("label") or s.get("name") or "",
3505
- "package_prefix": s.get("package_prefix") or s.get("pkg") or "",
3506
- "member_count": len(s.get("members") or []),
3507
- }
3508
- for s in subsystems[:15]
3509
- ],
3510
- "cross_module_tangles": [
3511
- {
3512
- "label": s.get("label") or s.get("name") or "",
3513
- "member_count": len(s.get("members") or []),
3514
- }
3515
- for s in tangle_modules
3516
- ],
3517
- # BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
3518
- # hotspots filters by role=service/repository/controller; annotation types and
3519
- # value objects end up in high_coupling_nodes instead.
3520
- "recommendation": (
3521
- (
3522
- "Start with hotspot_candidates (high fan-in = highest blast radius). "
3523
- if hotspots else
3524
- "high_coupling_nodes shows the most-referenced classes — start there. "
3525
- )
3526
- + "Dead zones are safe to remove or refactor. "
3527
- + "Cross-module tangles indicate coupling worth decomposing."
3528
- ),
3513
+ _summary = {
3514
+ "total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
3515
+ "total_subsystems": len(subsystems),
3516
+ "high_coupling_nodes": len(coupling_nodes),
3517
+ "dead_zone_candidates": len(dead_zones),
3529
3518
  }
3519
+ _subsystem_summary = [
3520
+ {
3521
+ "label": s.get("label") or s.get("name") or "",
3522
+ "package_prefix": s.get("package_prefix") or s.get("pkg") or "",
3523
+ "member_count": len(s.get("members") or []),
3524
+ }
3525
+ for s in subsystems[:15]
3526
+ ]
3527
+
3528
+ if not _mod_can_use("modernize"):
3529
+ # Free tier: structural discovery only — no dead zones, tangles, or full refactor list.
3530
+ result = {
3531
+ "workflow": "modernize",
3532
+ "path": str(root),
3533
+ "tier": "free",
3534
+ "tier_note": (
3535
+ "Upgrade to Pro for full analysis: dead zones, dependency tangles, "
3536
+ "refactor candidates ranked by git churn, and complete coupling graphs."
3537
+ ),
3538
+ "summary": _summary,
3539
+ "subsystem_summary": _subsystem_summary,
3540
+ "hotspot_candidates": hotspots[:3],
3541
+ "high_coupling_nodes": [
3542
+ {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
3543
+ for n in coupling_nodes[:3]
3544
+ ],
3545
+ }
3546
+ else:
3547
+ # Pro tier: full analysis.
3548
+ result = {
3549
+ "workflow": "modernize",
3550
+ "path": str(root),
3551
+ "summary": _summary,
3552
+ "hotspot_candidates": hotspots,
3553
+ "high_coupling_nodes": [
3554
+ {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
3555
+ for n in coupling_nodes
3556
+ ],
3557
+ "dead_zone_candidates": [
3558
+ {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
3559
+ for n in dead_zones
3560
+ ],
3561
+ "subsystem_summary": _subsystem_summary,
3562
+ "cross_module_tangles": [
3563
+ {
3564
+ "label": s.get("label") or s.get("name") or "",
3565
+ "member_count": len(s.get("members") or []),
3566
+ }
3567
+ for s in tangle_modules
3568
+ ],
3569
+ # BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
3570
+ "recommendation": (
3571
+ (
3572
+ "Start with hotspot_candidates (high fan-in = highest blast radius). "
3573
+ if hotspots else
3574
+ "high_coupling_nodes shows the most-referenced classes — start there. "
3575
+ )
3576
+ + "Dead zones are safe to remove or refactor. "
3577
+ + "Cross-module tangles indicate coupling worth decomposing."
3578
+ ),
3579
+ }
3530
3580
 
3531
3581
  result = trim_to_budget(result, BUDGET_ONBOARD, label="modernize")
3532
3582
  output = _json.dumps(result, indent=2, ensure_ascii=False)
@@ -3619,9 +3669,6 @@ def mcp_serve() -> None:
3619
3669
  }
3620
3670
  }
3621
3671
  """
3622
- from sourcecode.license import require_pro as _require_pro
3623
- _require_pro("mcp serve")
3624
-
3625
3672
  import logging
3626
3673
  import sys as _sys
3627
3674
 
@@ -3777,7 +3824,7 @@ def mcp_init(
3777
3824
  raise typer.Exit(code=1)
3778
3825
 
3779
3826
  typer.echo("MCP integration active.")
3780
- typer.echo(" Note: repo_path debe usar forward slashes: C:/Users/... o /ruta/unix")
3827
+ typer.echo(" Note: repo_path must use forward slashes: C:/Users/... or /unix/path")
3781
3828
  typer.echo("")
3782
3829
 
3783
3830
  # Post-write: validate config and warn if client not running
@@ -3929,7 +3976,7 @@ def mcp_status() -> None:
3929
3976
  typer.echo(sep)
3930
3977
  typer.echo(" Note: 'configured' and 'running' are checked independently.")
3931
3978
  typer.echo(" A running app still needs restart after first-time config.")
3932
- typer.echo(" Path: repo_path debe usar forward slashes: C:/Users/... o /ruta/unix")
3979
+ typer.echo(" Path: repo_path must use forward slashes: C:/Users/... or /unix/path")
3933
3980
  typer.echo(" Setup: sourcecode mcp init")
3934
3981
  typer.echo(" Remove: sourcecode mcp remove")
3935
3982
 
@@ -28,6 +28,7 @@ _STACK_LABELS: dict[str, str] = {
28
28
 
29
29
  _TYPE_LABELS: dict[str, str] = {
30
30
  "api": "REST API",
31
+ "web_mvc": "Spring MVC web app",
31
32
  "webapp": "Web app",
32
33
  "fullstack": "Full-stack app",
33
34
  "cli": "CLI tool",
@@ -1118,7 +1118,7 @@ class DependencyAnalyzer:
1118
1118
  try:
1119
1119
  tree = ET.parse(pom)
1120
1120
  except (ET.ParseError, OSError):
1121
- return [], ["java: error al parsear pom.xml"]
1121
+ return [], ["java: error parsing pom.xml"]
1122
1122
 
1123
1123
  root_elem = tree.getroot()
1124
1124
  ns_match = re.match(r"\{[^}]+\}", root_elem.tag)
@@ -1217,10 +1217,10 @@ class DependencyAnalyzer:
1217
1217
  try:
1218
1218
  content = gradle_file.read_text(encoding="utf-8", errors="replace")
1219
1219
  except OSError:
1220
- return [], [f"gradle: error al leer {filename}"]
1220
+ return [], [f"gradle: error reading {filename}"]
1221
1221
  props = self._parse_gradle_properties(root, content)
1222
1222
  records = self._parse_gradle_dependencies(content, props, filename)
1223
- return records, ["gradle: sin lockfile compatible; dependencias transitivas no disponibles"]
1223
+ return records, ["gradle: no compatible lockfile found; transitive dependencies unavailable"]
1224
1224
  return [], []
1225
1225
 
1226
1226
  def _parse_gradle_properties(self, root: Path, content: str) -> dict[str, str]:
@@ -23,6 +23,7 @@ _MAX_ANNOTATION_ENTRY_POINTS = 1000
23
23
  _REST_CONTROLLER_RE = re.compile(r'@RestController\b')
24
24
  _MVC_CONTROLLER_RE = re.compile(r'@Controller\b')
25
25
  _REQUEST_MAPPING_RE = re.compile(r'@RequestMapping\b')
26
+ _ANY_MAPPING_RE = re.compile(r'@(?:Request|Get|Post|Put|Delete|Patch)Mapping\b')
26
27
  _CONTROLLER_ADVICE_RE = re.compile(r'@ControllerAdvice\b')
27
28
  _WEB_FILTER_RE = re.compile(r'@WebFilter\b')
28
29
  _FILTER_BEAN_RE = re.compile(r'FilterRegistrationBean\b')
@@ -298,6 +299,10 @@ class JavaDetector(AbstractDetector):
298
299
  frameworks.append(FrameworkDetection(name="Jakarta EE", source=source))
299
300
  if "mybatis" in text:
300
301
  frameworks.append(FrameworkDetection(name="MyBatis", source=source))
302
+ if "thymeleaf" in text:
303
+ frameworks.append(FrameworkDetection(name="Thymeleaf", source=source))
304
+ if "freemarker" in text:
305
+ frameworks.append(FrameworkDetection(name="FreeMarker", source=source))
301
306
  if "spring-boot-starter-security" in text or "spring-security-core" in text:
302
307
  frameworks.append(FrameworkDetection(name="Spring Security", source=source))
303
308
  if "spring-boot-starter-data-jpa" in text or "spring-data-jpa" in text:
@@ -470,7 +475,7 @@ class JavaDetector(AbstractDetector):
470
475
  path=rel_path, stack="java", kind="exception_handler",
471
476
  source="annotation", confidence="medium",
472
477
  )]
473
- if _MVC_CONTROLLER_RE.search(content) and _REQUEST_MAPPING_RE.search(content):
478
+ if _MVC_CONTROLLER_RE.search(content) and _ANY_MAPPING_RE.search(content):
474
479
  http_path_match = _HTTP_PATH_RE.search(content)
475
480
  http_path = http_path_match.group(1) if http_path_match else None
476
481
  verb_match = _REQUEST_METHOD_VERB_RE.search(content)
sourcecode/license.py CHANGED
@@ -3,7 +3,7 @@
3
3
  Flow:
4
4
  1. Module imported → _init() loads ~/.sourcecode/license.json (if present)
5
5
  2. is_pro set globally (True when plan == "pro")
6
- 3. Pro commands call require_pro(feature_name) at entry — exits 1 if not Pro
6
+ 3. Pro commands call require_feature(feature_name) at entry — exits 1 if not Pro
7
7
  4. `sourcecode activate <key>` calls activate_license(key) — validates via
8
8
  Edge Function, writes ~/.sourcecode/license.json, exits 0 on success
9
9
  5. Cached license is re-validated every 24 h (online); network errors keep
@@ -38,6 +38,53 @@ _LICENSE_DIR: Path = Path.home() / ".sourcecode"
38
38
  _LICENSE_FILE: Path = _LICENSE_DIR / "license.json"
39
39
  _CACHE_TTL_SECONDS: int = 86400 # 24 hours
40
40
 
41
+ # ---------------------------------------------------------------------------
42
+ # Per-feature descriptions for upgrade UX
43
+ # ---------------------------------------------------------------------------
44
+ _FEATURE_INFO: dict[str, dict[str, str]] = {
45
+ "impact": {
46
+ "display": "impact",
47
+ "description": (
48
+ "Shows blast radius, callers, affected endpoints, and persistence paths in one call."
49
+ ),
50
+ "value": "Answers: what breaks if I touch this? The core risk signal before any change.",
51
+ },
52
+ "modernize": {
53
+ "display": "modernize (full)",
54
+ "description": (
55
+ "Full analysis: dead zones, refactor candidates, dependency tangles, and coupling ranked by git churn."
56
+ ),
57
+ "value": "Prioritizes where to refactor and what is safe to touch.",
58
+ },
59
+ "fix-bug": {
60
+ "display": "fix-bug (full)",
61
+ "description": "Complete risk-ranked file list with all annotation and structural signals.",
62
+ "value": "More results means less time scanning the codebase manually.",
63
+ },
64
+ "review-pr": {
65
+ "display": "review-pr (expanded)",
66
+ "description": "Full PR review: blast radius, all execution paths, security and transaction impact.",
67
+ "value": "CI-grade review — the complete picture before merging.",
68
+ },
69
+ "delta": {
70
+ "display": "prepare-context delta",
71
+ "description": "Incremental context: git-changed files with impact propagation.",
72
+ "value": "Designed for CI/CD pipelines — runs on every PR, flags risk automatically.",
73
+ },
74
+ "generate-tests": {
75
+ "display": "prepare-context generate-tests",
76
+ "description": "Test gap analysis: finds untested files with coverage recommendations.",
77
+ "value": "Reduces test debt systematically across the entire codebase.",
78
+ },
79
+ "--full": {
80
+ "display": "--full flag",
81
+ "description": (
82
+ "Removes truncation limits on transactional boundaries, DTO mappers, and large result sets."
83
+ ),
84
+ "value": "Essential for complete analysis of enterprise-scale codebases.",
85
+ },
86
+ }
87
+
41
88
  # ---------------------------------------------------------------------------
42
89
  # Global license state — loaded once at import time
43
90
  # ---------------------------------------------------------------------------
@@ -147,30 +194,74 @@ _init()
147
194
 
148
195
 
149
196
  # ---------------------------------------------------------------------------
150
- # Enforcement
197
+ # Entitlement helpers
151
198
  # ---------------------------------------------------------------------------
152
199
 
153
- def require_pro(feature_name: str) -> None:
154
- """Exit with structured JSON error when not Pro.
200
+ def can_use(feature_name: str) -> bool:
201
+ """Return True if the current plan has access to feature_name.
202
+
203
+ Does not trigger revalidation — use require_feature() at command entry
204
+ points where you want revalidation + gating in one call.
205
+ """
206
+ return is_pro
207
+
208
+
209
+ def require_feature(feature_name: str) -> None:
210
+ """Exit with a clean upgrade prompt when feature_name requires Pro.
155
211
 
156
212
  Re-validates stale cached license before gating (once per 24 h, online).
157
213
 
214
+ Writes human-readable context to stderr (terminal UX) and a JSON error
215
+ to stdout (backward-compatible machine-readable format).
216
+
217
+ Example:
218
+ from sourcecode.license import require_feature
219
+ require_feature("impact")
220
+ """
221
+ _maybe_revalidate()
222
+
223
+ if is_pro:
224
+ return
225
+
226
+ info = _FEATURE_INFO.get(feature_name, {})
227
+ display = info.get("display", feature_name)
228
+ description = info.get("description", "")
229
+ value = info.get("value", "")
230
+
231
+ # Human-readable upgrade prompt on stderr
232
+ lines = [f"\n '{display}' is a Pro feature."]
233
+ if description:
234
+ lines.append(f" {description}")
235
+ if value:
236
+ lines.append(f" {value}")
237
+ lines.append("")
238
+ lines.append(" Upgrade: sourcecode activate <license_key>")
239
+ lines.append("")
240
+ sys.stderr.write("\n".join(lines) + "\n")
241
+ sys.stderr.flush()
242
+
243
+ # JSON on stdout — backward-compatible for CI / MCP consumers
244
+ payload = {
245
+ "error": "pro_required",
246
+ "feature": feature_name,
247
+ "message": (
248
+ f"'{display}' requires a Pro license. "
249
+ "Run: sourcecode activate <license_key>"
250
+ ),
251
+ }
252
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
253
+ sys.stdout.flush()
254
+ sys.exit(1)
255
+
256
+
257
+ def require_pro(feature_name: str) -> None:
258
+ """Backward-compatible alias for require_feature.
259
+
158
260
  Example:
159
261
  from sourcecode.license import require_pro
160
262
  require_pro("impact")
161
263
  """
162
- if is_pro:
163
- _maybe_revalidate()
164
-
165
- if not is_pro:
166
- payload = {
167
- "error": "pro_required",
168
- "feature": feature_name,
169
- "message": "Run: sourcecode activate <license_key>",
170
- }
171
- sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
172
- sys.stdout.flush()
173
- sys.exit(1)
264
+ require_feature(feature_name)
174
265
 
175
266
 
176
267
  # ---------------------------------------------------------------------------
@@ -981,12 +981,18 @@ class TaskContextBuilder:
981
981
 
982
982
  dep_records, dep_summary = DependencyAnalyzer().analyze(self.root)
983
983
  primary_eco = stacks[0].stack if stacks else ""
984
- direct = [
984
+ _direct_raw = [
985
985
  d for d in dep_records
986
986
  if d.scope != "transitive" and d.source in {"manifest", "lockfile"}
987
987
  and (d.role or "unknown") in {"runtime", "parsing", "serialization", "observability", "infra"}
988
988
  and d.scope not in {"dev"}
989
989
  ]
990
+ _seen_dep: set[str] = set()
991
+ direct = []
992
+ for _d in _direct_raw:
993
+ if _d.name not in _seen_dep:
994
+ _seen_dep.add(_d.name)
995
+ direct.append(_d)
990
996
  # Rank by framework centrality: core infra (ORM, Spring) > serialization > other.
991
997
  # Penalise vendored tooling (closure-compiler, shaded utilities) so that
992
998
  # Hibernate/JPA/Solr appear before minor build-time dependencies.
@@ -1697,6 +1697,9 @@ def _canonical_subsystem_pkg(fqn: str) -> str:
1697
1697
  (even if uppercase) to force at least 2-segment grouping.
1698
1698
  """
1699
1699
  _TOP_LEVEL = {"com", "org", "net", "io", "java", "javax"}
1700
+ # Well-known framework namespaces that are not application boundaries;
1701
+ # go one level deeper (depth 5) so callers get the actual app module.
1702
+ _FRAMEWORK_NS = {"springframework", "apache", "eclipse", "google", "jetbrains"}
1700
1703
  parts: list[str] = []
1701
1704
  for segment in fqn.split("."):
1702
1705
  if "#" in segment or (segment and segment[0].isupper()):
@@ -1705,6 +1708,8 @@ def _canonical_subsystem_pkg(fqn: str) -> str:
1705
1708
  if not parts:
1706
1709
  return fqn.rsplit(".", 1)[0] if "." in fqn else fqn
1707
1710
  if parts[0] in _TOP_LEVEL and len(parts) >= 3:
1711
+ if len(parts) >= 5 and len(parts) > 3 and parts[1] in _FRAMEWORK_NS:
1712
+ return ".".join(parts[:5])
1708
1713
  return ".".join(parts[:3])
1709
1714
  # Prevent bare TLD collapse: "org" or "com" alone as subsystem key is meaningless
1710
1715
  # and groups ALL classes under that TLD into a single giant component.
@@ -3338,7 +3343,7 @@ def compute_blast_radius(
3338
3343
  _seen_mapper_fqns.add(fqn)
3339
3344
  _mapper_entry: dict = {
3340
3345
  "fqn": fqn,
3341
- "role": role or "mapper",
3346
+ "role": role or ("mapper" if symbol_kind == "mapper_interface" else "repository"),
3342
3347
  "source_file": node_dict.get("source_file") or "",
3343
3348
  }
3344
3349
  if canonical != fqn:
sourcecode/summarizer.py CHANGED
@@ -279,6 +279,7 @@ class ProjectSummarizer:
279
279
  _TYPE_LABELS: dict[str, str] = {
280
280
  "cli": "CLI",
281
281
  "api": "API",
282
+ "web_mvc": "Spring MVC web app",
282
283
  "webapp": "Web application",
283
284
  "library": "Library",
284
285
  "monorepo": "Monorepo",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.31.32
3
+ Version: 1.32.1
4
4
  Summary: Deterministic codebase context for AI coding agents
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -225,7 +225,7 @@ Description-Content-Type: text/markdown
225
225
 
226
226
  **AI-ready change intelligence for Java/Spring enterprise monoliths.**
227
227
 
228
- ![Version](https://img.shields.io/badge/version-1.31.32-blue)
228
+ ![Version](https://img.shields.io/badge/version-1.32.0-blue)
229
229
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
230
230
 
231
231
  ---
@@ -263,7 +263,7 @@ pipx install sourcecode
263
263
 
264
264
  ```bash
265
265
  sourcecode version
266
- # sourcecode 1.31.32
266
+ # sourcecode 1.32.0
267
267
  ```
268
268
 
269
269
  ---
@@ -1,20 +1,20 @@
1
- sourcecode/__init__.py,sha256=FAgcPCebMe_1K921d8TjlfM7T_GMcbLwHvBZvp_uat8,104
1
+ sourcecode/__init__.py,sha256=fI8Cp1YcT6ueuKNKvHveO2gFDtDkSHrDE6DpgADO1i8,103
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
- sourcecode/architecture_analyzer.py,sha256=Ry3aYT9dc7XuLmWLT5IZ93RkCf_P14Qtew0nGPvUl_8,42184
3
+ sourcecode/architecture_analyzer.py,sha256=qh749a7ykPtGmQI1MR9y6j8TtL_jBdVYFx9YRsLqOMw,44121
4
4
  sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
5
5
  sourcecode/ast_extractor.py,sha256=_btmeOJIe3t-NicF94D5ZAesa2YIJ0_QNExGnbHxGFE,50578
6
6
  sourcecode/cache.py,sha256=pBrPdpPrOgpXHHQO670U3aUfVf5N3A3obsTKgiZtN4I,23030
7
7
  sourcecode/canonical_ir.py,sha256=_HM3AUmKSdna9u4dCoU6rpgSA6HdF8gzOKZykIUCNGY,23277
8
- sourcecode/classifier.py,sha256=yWeq6agTjkFa3zuNa-gdVIHtjoBoPoVlJnX-b7tdVJs,7851
9
- sourcecode/cli.py,sha256=7tkUpZEnXjU_swX1wadkMnfYS0A0p0NvSRZEBIOLLi0,161521
8
+ sourcecode/classifier.py,sha256=2lYoSH3vOTkXZYPU7Go2WIet1-IuNzTWVhc-ULnXtgw,8024
9
+ sourcecode/cli.py,sha256=xV3CKxTAzc1tcEJ6-lfGCuYlBWdLQgxv1-thMzQ3cpk,163863
10
10
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
11
11
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
12
12
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
13
- sourcecode/context_summarizer.py,sha256=CiQrfBEzun949bWvmLabWoj2HhPn6Lw62ofqnsy0FlQ,6503
13
+ sourcecode/context_summarizer.py,sha256=zlbm8ytdvJToohU108-dwBmEl52xl0gXpf6PZBOW_2A,6540
14
14
  sourcecode/contract_model.py,sha256=nRxJKPMs1VHwFTa8AVXhGmaLjti3Lr2sjHDpWgv1bfE,3917
15
15
  sourcecode/contract_pipeline.py,sha256=gvTdDniedm_mjq4vaHqnBY2UkQ0s00gtXqzTLILNXHc,28719
16
16
  sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo,19702
17
- sourcecode/dependency_analyzer.py,sha256=Po7GKJnClCkXty0np1B4F1zo_bPeKAtgbehazhXuaBM,56493
17
+ sourcecode/dependency_analyzer.py,sha256=wE7IgXOepfagGdRJX4-u5mhps_kfR2njz2RFgPikFFY,56491
18
18
  sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24411
19
19
  sourcecode/entrypoint_classifier.py,sha256=MTa7yqbeuJ9XPbGCPuvtR9IqY-SN3hoXXyVtb3iXDhs,4316
20
20
  sourcecode/env_analyzer.py,sha256=9_q_U_Q1tjiY5FSaeEImSDgToP2uHGrjBAFF7ihJn2I,22204
@@ -22,25 +22,25 @@ sourcecode/file_classifier.py,sha256=QrYm7MlG29HQdAR1WOfpnIIBysAz62c5coz9eQ76meo
22
22
  sourcecode/flow_analyzer.py,sha256=dSiuY4w49k29jW_EPXUOND9B5uVbuCA7kjnuHi-pIWA,28781
23
23
  sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
24
24
  sourcecode/graph_analyzer.py,sha256=iUK-7pSV-cvGqqD2hENdYmhnm0wcXFEyK-xnu5ul8OU,62515
25
- sourcecode/license.py,sha256=oiAxBXHf1mz42rrDKQCPX28W2yaAHvWqaVZuijJGSF8,7902
25
+ sourcecode/license.py,sha256=m5n4PKZ7ZZZ17bpLjIsKwd94PXaIg5iS_2kFNjCV2Og,11378
26
26
  sourcecode/mcp_nudge.py,sha256=5ELU_ixzh6uA83NXLOZT8h00OhL53okfQdji3jyKOjg,2917
27
27
  sourcecode/metrics_analyzer.py,sha256=m0ENgtqKeBL17kUIK3fmGkgo7UfXBNHxCMj0H_Y5K7c,22750
28
28
  sourcecode/output_budget.py,sha256=43307mJEyUPU3MI-QEQoVxrcAvNyUzdzF_SAPgisBQE,6603
29
29
  sourcecode/path_filters.py,sha256=ROFRQ8eSLBEMiixK9f45-RO7um4VEEcjoD5AA4I427I,3739
30
30
  sourcecode/pr_comment_renderer.py,sha256=smHslxiG14lrytCkq5nFrFu-qTHgA-t-LFYfdrfjz2o,14423
31
- sourcecode/prepare_context.py,sha256=RM7ka0rduJy8kwGHzLU9if6q7D9ST7tGjOf5LnsdTuw,201451
31
+ sourcecode/prepare_context.py,sha256=TFg8GDJ8kotJtR4rOTAvwQGVNSj0x-Usamy-mtjxeqs,201681
32
32
  sourcecode/progress.py,sha256=qn30sWaHOkjTgXsSBmiPkz7Rsbwc5oSlIe6JNEMYp_k,3149
33
33
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
34
34
  sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
35
35
  sourcecode/relevance_scorer.py,sha256=MYF4FFkveAQps9SmTeTlh6ODiBz2F--_hWNeHMLtUHQ,8405
36
36
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
37
- sourcecode/repository_ir.py,sha256=rga0I9L0K-3kSMTF8R8slBH4Fkwwrh-ut4Y3h6GxLvY,152757
37
+ sourcecode/repository_ir.py,sha256=-NjBQUT7zyya4ng8Hq0-ChoiHZkUif9lr-Q878gmj8M,153163
38
38
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
39
39
  sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
40
40
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
41
41
  sourcecode/semantic_analyzer.py,sha256=TDuC3wzZR2DPm1mgrAg1YSLk2QzJoueS3TZAmyGGpCU,89417
42
42
  sourcecode/serializer.py,sha256=3JBvsMDj5pt1RXpi1zJpk5DGWUKbeb2Jl622-kmYWD4,123312
43
- sourcecode/summarizer.py,sha256=BMHJA0Do4rBnabc1_BxHoETTNb5ew0VqCX_eY3_PdCg,20706
43
+ sourcecode/summarizer.py,sha256=YspHEVeYJVmltq0FMtGZF8kIP3qiR2KLcanGL6Y7uTI,20747
44
44
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
45
45
  sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
46
46
  sourcecode/detectors/__init__.py,sha256=A0AACJFF6HWf_RgatNtWu3PUzstcKtIGM9f1PoFcJug,1987
@@ -52,7 +52,7 @@ sourcecode/detectors/elixir.py,sha256=jCpvt5Yi6jvplc80ovRtWh17q-11ZGo9qX7o8b57TJ
52
52
  sourcecode/detectors/go.py,sha256=2r66uRQfeTWsqxr4HDhT6vExZErby0t46QXLHVBRv9w,2782
53
53
  sourcecode/detectors/heuristic.py,sha256=7cRxrip4yIaggYzZJB6ef8yHKh-gHgiH_pXMFcjlyFU,3723
54
54
  sourcecode/detectors/hybrid.py,sha256=IGFRUVsAZ1ooRlFdznCeJAV6vy1yVDx-VyghvLtddXc,9101
55
- sourcecode/detectors/java.py,sha256=YmmbRChVt_E4yTLfz3jzdNtbCtyQGS4RD7Xt95_rtdg,28979
55
+ sourcecode/detectors/java.py,sha256=BMdDLBe6vzMa8dqNc8cZ6An0OpQmW5oWcJ02ULzHC_Q,29288
56
56
  sourcecode/detectors/jvm_ext.py,sha256=EgHJ5W8EE-ZTN9V607mVzohyKgZE8Mc2jCi-DF8RAZU,2616
57
57
  sourcecode/detectors/nodejs.py,sha256=Hg3Gmr7yIMJFiLoDwOTk2wtu00wxIs6kZf-oQujTFUA,13187
58
58
  sourcecode/detectors/parsers.py,sha256=ugPg8yNUf0Ai1gA7Fnn6wAkYGFjTxRodSP3IeViYJJ4,2290
@@ -78,8 +78,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
78
78
  sourcecode/telemetry/events.py,sha256=oEvvulfsv5GIDWG2174gSS6tNB95w38AIYiYeifGKlE,2294
79
79
  sourcecode/telemetry/filters.py,sha256=Asa71oRl7q3Wt_FMwuufIZJFzSYdgRNKS8LHCIyFeYE,4805
80
80
  sourcecode/telemetry/transport.py,sha256=KJeIPCPWMdmbCP3ySGs2iUlia34U6vWne2dZsUezesw,1560
81
- sourcecode-1.31.32.dist-info/METADATA,sha256=69Ai2xiH7kn4Zmsm-KhGAfTTXtUbmyyy9EgLSSQ9GU4,31103
82
- sourcecode-1.31.32.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
83
- sourcecode-1.31.32.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
84
- sourcecode-1.31.32.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
85
- sourcecode-1.31.32.dist-info/RECORD,,
81
+ sourcecode-1.32.1.dist-info/METADATA,sha256=llWp2sPfqGuDQXERl94vF19Sxpktl_zPUyDNDwT4ORM,31100
82
+ sourcecode-1.32.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
83
+ sourcecode-1.32.1.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
84
+ sourcecode-1.32.1.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
85
+ sourcecode-1.32.1.dist-info/RECORD,,