sourcecode 1.35.13__tar.gz → 1.35.14__tar.gz

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.
Files changed (105) hide show
  1. {sourcecode-1.35.13 → sourcecode-1.35.14}/PKG-INFO +3 -3
  2. {sourcecode-1.35.13 → sourcecode-1.35.14}/README.md +2 -2
  3. {sourcecode-1.35.13 → sourcecode-1.35.14}/pyproject.toml +1 -1
  4. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/cli.py +117 -0
  6. sourcecode-1.35.14/src/sourcecode/explain.py +483 -0
  7. {sourcecode-1.35.13 → sourcecode-1.35.14}/.github/workflows/build-windows.yml +0 -0
  8. {sourcecode-1.35.13 → sourcecode-1.35.14}/.gitignore +0 -0
  9. {sourcecode-1.35.13 → sourcecode-1.35.14}/.ruff.toml +0 -0
  10. {sourcecode-1.35.13 → sourcecode-1.35.14}/CHANGELOG.md +0 -0
  11. {sourcecode-1.35.13 → sourcecode-1.35.14}/CONTRIBUTING.md +0 -0
  12. {sourcecode-1.35.13 → sourcecode-1.35.14}/LICENSE +0 -0
  13. {sourcecode-1.35.13 → sourcecode-1.35.14}/SECURITY.md +0 -0
  14. {sourcecode-1.35.13 → sourcecode-1.35.14}/raw +0 -0
  15. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/adaptive_scanner.py +0 -0
  16. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/architecture_analyzer.py +0 -0
  17. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/architecture_summary.py +0 -0
  18. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/ast_extractor.py +0 -0
  19. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/cache.py +0 -0
  20. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/canonical_ir.py +0 -0
  21. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/cir_graphs.py +0 -0
  22. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/classifier.py +0 -0
  23. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/code_notes_analyzer.py +0 -0
  24. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/confidence_analyzer.py +0 -0
  25. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/context_scorer.py +0 -0
  26. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/context_summarizer.py +0 -0
  27. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/contract_model.py +0 -0
  28. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/contract_pipeline.py +0 -0
  29. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/coverage_parser.py +0 -0
  30. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/dependency_analyzer.py +0 -0
  31. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/__init__.py +0 -0
  32. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/base.py +0 -0
  33. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/csproj_parser.py +0 -0
  34. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/dart.py +0 -0
  35. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/dotnet.py +0 -0
  36. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/elixir.py +0 -0
  37. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/go.py +0 -0
  38. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/heuristic.py +0 -0
  39. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/hybrid.py +0 -0
  40. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/java.py +0 -0
  41. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/jvm_ext.py +0 -0
  42. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/nodejs.py +0 -0
  43. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/parsers.py +0 -0
  44. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/php.py +0 -0
  45. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/project.py +0 -0
  46. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/python.py +0 -0
  47. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/ruby.py +0 -0
  48. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/rust.py +0 -0
  49. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/systems.py +0 -0
  50. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/terraform.py +0 -0
  51. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/detectors/tooling.py +0 -0
  52. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/doc_analyzer.py +0 -0
  53. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/entrypoint_classifier.py +0 -0
  54. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/env_analyzer.py +0 -0
  55. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/error_schema.py +0 -0
  56. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/file_classifier.py +0 -0
  57. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/flow_analyzer.py +0 -0
  58. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/git_analyzer.py +0 -0
  59. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/graph_analyzer.py +0 -0
  60. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/license.py +0 -0
  61. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/__init__.py +0 -0
  62. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  63. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  64. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  65. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  66. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  67. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/orchestrator.py +0 -0
  68. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/registry.py +0 -0
  69. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/runner.py +0 -0
  70. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp/server.py +0 -0
  71. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/mcp_nudge.py +0 -0
  72. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/metrics_analyzer.py +0 -0
  73. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/output_budget.py +0 -0
  74. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/path_filters.py +0 -0
  75. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/pr_comment_renderer.py +0 -0
  76. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/pr_impact.py +0 -0
  77. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/prepare_context.py +0 -0
  78. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/progress.py +0 -0
  79. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/ranking_engine.py +0 -0
  80. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/redactor.py +0 -0
  81. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/relevance_scorer.py +0 -0
  82. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/repo_classifier.py +0 -0
  83. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/repository_ir.py +0 -0
  84. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/ris.py +0 -0
  85. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/runtime_classifier.py +0 -0
  86. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/scanner.py +0 -0
  87. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/schema.py +0 -0
  88. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/semantic_analyzer.py +0 -0
  89. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/serializer.py +0 -0
  90. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_event_topology.py +0 -0
  91. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_findings.py +0 -0
  92. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_impact.py +0 -0
  93. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_model.py +0 -0
  94. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_security_audit.py +0 -0
  95. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_semantic.py +0 -0
  96. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/spring_tx_analyzer.py +0 -0
  97. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/summarizer.py +0 -0
  98. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/telemetry/__init__.py +0 -0
  99. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/telemetry/config.py +0 -0
  100. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/telemetry/consent.py +0 -0
  101. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/telemetry/events.py +0 -0
  102. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/telemetry/filters.py +0 -0
  103. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/telemetry/transport.py +0 -0
  104. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/tree_utils.py +0 -0
  105. {sourcecode-1.35.13 → sourcecode-1.35.14}/src/sourcecode/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.35.13
3
+ Version: 1.35.14
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
@@ -39,7 +39,7 @@ Description-Content-Type: text/markdown
39
39
 
40
40
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
41
41
 
42
- ![Version](https://img.shields.io/badge/version-1.35.13-blue)
42
+ ![Version](https://img.shields.io/badge/version-1.35.14-blue)
43
43
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
44
44
 
45
45
  ---
@@ -113,7 +113,7 @@ pipx install sourcecode
113
113
 
114
114
  ```bash
115
115
  sourcecode version
116
- # sourcecode 1.35.13
116
+ # sourcecode 1.35.14
117
117
  ```
118
118
 
119
119
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
4
4
 
5
- ![Version](https://img.shields.io/badge/version-1.35.13-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.35.14-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
7
7
 
8
8
  ---
@@ -76,7 +76,7 @@ pipx install sourcecode
76
76
 
77
77
  ```bash
78
78
  sourcecode version
79
- # sourcecode 1.35.13
79
+ # sourcecode 1.35.14
80
80
  ```
81
81
 
82
82
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.35.13"
7
+ version = "1.35.14"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.13"
3
+ __version__ = "1.35.14"
@@ -229,6 +229,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
229
229
  "impact-chain",
230
230
  # PR blast-radius report
231
231
  "pr-impact",
232
+ # Class architectural summary
233
+ "explain",
232
234
  }
233
235
  )
234
236
 
@@ -4179,6 +4181,121 @@ def pr_impact_cmd(
4179
4181
  typer.echo("✓ copied to clipboard", err=True)
4180
4182
 
4181
4183
 
4184
+ # ── Explain Command ───────────────────────────────────────────────────────────
4185
+
4186
+ @app.command("explain")
4187
+ def explain_cmd(
4188
+ class_name: str = typer.Argument(
4189
+ ...,
4190
+ help="Simple class name to explain (e.g. UserService, OrderController).",
4191
+ ),
4192
+ path: Path = typer.Argument(
4193
+ Path("."),
4194
+ help="Repository root (default: current directory)",
4195
+ ),
4196
+ format: str = typer.Option(
4197
+ "text", "--format", "-f",
4198
+ help="Output format: text (default) or json.",
4199
+ show_default=True,
4200
+ ),
4201
+ output_path: Optional[Path] = typer.Option(
4202
+ None, "--output", "-o",
4203
+ help="Write output to a file instead of stdout.",
4204
+ ),
4205
+ copy: bool = typer.Option(
4206
+ False, "--copy", "-c",
4207
+ help="Copy output to clipboard after a successful run.",
4208
+ ),
4209
+ ) -> None:
4210
+ """Human-readable architectural summary for a class.
4211
+
4212
+ \b
4213
+ Generates a structured explanation derived entirely from static analysis:
4214
+ - Purpose and Spring stereotype
4215
+ - Public methods
4216
+ - Incoming callers (who uses this class)
4217
+ - Outgoing dependencies (what this class calls)
4218
+ - Events published and consumed
4219
+ - @Transactional boundaries
4220
+ - Security constraints (@PreAuthorize, @Secured, etc.)
4221
+ - REST endpoints related
4222
+
4223
+ \b
4224
+ JAVA/SPRING ONLY. Reads from existing CIR — no new parsers.
4225
+
4226
+ \b
4227
+ Examples:
4228
+ sourcecode explain UserService
4229
+ sourcecode explain OrderController /path/to/repo
4230
+ sourcecode explain UserService --format json
4231
+ """
4232
+ import json as _json
4233
+
4234
+ from sourcecode.repository_ir import find_java_files
4235
+ from sourcecode.canonical_ir import build_canonical_ir
4236
+ from sourcecode.spring_model import SpringSemanticModel
4237
+ from sourcecode.explain import explain_class
4238
+
4239
+ target = path.resolve()
4240
+ if not target.exists() or not target.is_dir():
4241
+ _emit_error_json(
4242
+ INVALID_INPUT_CODE,
4243
+ f"'{target}' is not a valid directory.",
4244
+ path=str(target),
4245
+ hint="Pass an existing repository directory.",
4246
+ expected="A directory path.",
4247
+ )
4248
+ raise typer.Exit(code=1)
4249
+
4250
+ if format not in ("text", "json"):
4251
+ _emit_error_json(
4252
+ INVALID_INPUT_CODE,
4253
+ f"Invalid format '{format}'.",
4254
+ hint="format must be: text or json.",
4255
+ expected="text | json",
4256
+ )
4257
+ raise typer.Exit(code=1)
4258
+
4259
+ file_list = find_java_files(target)
4260
+ if not file_list:
4261
+ msg = f"No Java files found in '{target}'. sourcecode explain requires a Java/Spring repository."
4262
+ if format == "json":
4263
+ output = _json.dumps({
4264
+ "error": "no_java_files",
4265
+ "message": msg,
4266
+ "class_name": class_name,
4267
+ }, indent=2)
4268
+ else:
4269
+ output = msg
4270
+ if output_path is not None:
4271
+ output_path.write_text(output, encoding="utf-8")
4272
+ else:
4273
+ sys.stdout.buffer.write(output.encode("utf-8"))
4274
+ sys.stdout.buffer.write(b"\n")
4275
+ sys.stdout.buffer.flush()
4276
+ raise typer.Exit(code=1)
4277
+
4278
+ cir = build_canonical_ir(file_list, target)
4279
+ model = SpringSemanticModel.build(cir)
4280
+ explanation = explain_class(class_name, cir, model)
4281
+
4282
+ if format == "json":
4283
+ output = _json.dumps(explanation.to_dict(), indent=2, ensure_ascii=False)
4284
+ else:
4285
+ output = explanation.render_text()
4286
+
4287
+ if output_path is not None:
4288
+ output_path.write_text(output, encoding="utf-8")
4289
+ typer.echo(f"Explanation written to {output_path}", err=True)
4290
+ else:
4291
+ sys.stdout.buffer.write(output.encode("utf-8"))
4292
+ sys.stdout.buffer.write(b"\n")
4293
+ sys.stdout.buffer.flush()
4294
+
4295
+ if copy and _copy_to_clipboard(output):
4296
+ typer.echo("✓ copied to clipboard", err=True)
4297
+
4298
+
4182
4299
  # ── Enterprise Workflow Commands ──────────────────────────────────────────────
4183
4300
  #
4184
4301
  # These are the five canonical enterprise workflows. Each is a thin wrapper
@@ -0,0 +1,483 @@
1
+ """explain.py — Human-readable architectural summary for a Java class.
2
+
3
+ Usage:
4
+ cir = build_canonical_ir(find_java_files(root), root)
5
+ model = SpringSemanticModel.build(cir)
6
+ result = explain_class("UserService", cir, model)
7
+ print(result.render_text())
8
+
9
+ Derives all data from existing CIR + SpringSemanticModel — no new parsers.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Optional
15
+
16
+ if TYPE_CHECKING:
17
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
18
+ from sourcecode.spring_model import SpringSemanticModel
19
+
20
+
21
+ _STEREOTYPE_DESC: dict[str, str] = {
22
+ "service": "Spring @Service — business logic layer",
23
+ "repository": "Spring @Repository — data access layer",
24
+ "controller": "Spring @Controller — MVC request handler",
25
+ "restcontroller": "Spring @RestController — REST request handler",
26
+ "component": "Spring @Component — general-purpose bean",
27
+ "configuration": "Spring @Configuration — bean factory / config",
28
+ "bean": "Spring @Bean — managed component",
29
+ }
30
+
31
+ _SECURITY_ANNOTATION_PREFIXES = (
32
+ "@PreAuthorize", "@PostAuthorize", "@Secured", "@RolesAllowed",
33
+ "@PermitAll", "@DenyAll",
34
+ )
35
+
36
+ _DEFAULT_PROPAGATION = "REQUIRED"
37
+
38
+
39
+ def _simple(fqn: str) -> str:
40
+ """pkg.Foo#bar → Foo#bar; pkg.Foo → Foo."""
41
+ if "." not in fqn and "#" not in fqn:
42
+ return fqn
43
+ # Strip package prefix before simple class name
44
+ if "#" in fqn:
45
+ cls_part, method = fqn.rsplit("#", 1)
46
+ return f"{cls_part.rsplit('.', 1)[-1]}#{method}"
47
+ return fqn.rsplit(".", 1)[-1]
48
+
49
+
50
+ def _method_name(fqn: str) -> str:
51
+ """pkg.Foo#bar → bar."""
52
+ return fqn.rsplit("#", 1)[-1] if "#" in fqn else fqn
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Output model
57
+ # ---------------------------------------------------------------------------
58
+
59
+ @dataclass
60
+ class ClassExplanation:
61
+ """Structured architectural summary for a single class."""
62
+
63
+ class_name: str
64
+ class_fqn: str
65
+ stereotype: str
66
+ purpose: str
67
+ public_methods: list[str] = field(default_factory=list)
68
+ incoming_callers: list[str] = field(default_factory=list)
69
+ outgoing_deps: list[str] = field(default_factory=list)
70
+ events_published: list[str] = field(default_factory=list)
71
+ events_consumed: list[str] = field(default_factory=list)
72
+ transactions: list[str] = field(default_factory=list)
73
+ security_constraints: list[str] = field(default_factory=list)
74
+ rest_endpoints: list[str] = field(default_factory=list)
75
+ warnings: list[str] = field(default_factory=list)
76
+
77
+ def render_text(self) -> str:
78
+ lines: list[str] = [f"## {self.class_name}", ""]
79
+
80
+ if self.class_fqn != self.class_name:
81
+ lines += [f"**FQN:** `{self.class_fqn}`", ""]
82
+
83
+ lines += ["**Purpose:**", self.purpose, ""]
84
+
85
+ def _section(title: str, items: list[str]) -> None:
86
+ if not items:
87
+ return
88
+ lines.append(f"**{title}:**")
89
+ for item in items:
90
+ lines.append(f"* {item}")
91
+ lines.append("")
92
+
93
+ _section("Public Methods", self.public_methods)
94
+ _section("Used By", self.incoming_callers)
95
+ _section("Calls", self.outgoing_deps)
96
+ _section("Publishes", self.events_published)
97
+ _section("Consumes", self.events_consumed)
98
+ _section("Transactions", self.transactions)
99
+ _section("Security", self.security_constraints)
100
+ _section("REST Endpoints", self.rest_endpoints)
101
+ _section("Warnings", self.warnings)
102
+
103
+ return "\n".join(lines).rstrip()
104
+
105
+ def to_dict(self) -> dict:
106
+ return {
107
+ "class_name": self.class_name,
108
+ "class_fqn": self.class_fqn,
109
+ "stereotype": self.stereotype,
110
+ "purpose": self.purpose,
111
+ "public_methods": self.public_methods,
112
+ "incoming_callers": self.incoming_callers,
113
+ "outgoing_deps": self.outgoing_deps,
114
+ "events_published": self.events_published,
115
+ "events_consumed": self.events_consumed,
116
+ "transactions": self.transactions,
117
+ "security_constraints": self.security_constraints,
118
+ "rest_endpoints": self.rest_endpoints,
119
+ "warnings": self.warnings,
120
+ }
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # FQN resolution
125
+ # ---------------------------------------------------------------------------
126
+
127
+ def _resolve_fqn(class_name: str, cir: "CanonicalRepositoryIR") -> tuple[str, list[str]]:
128
+ """Find all class FQNs matching simple class_name in cir.symbols.
129
+
130
+ Returns (best_fqn, all_matches).
131
+ best_fqn is empty string when no match found.
132
+ """
133
+ suffix_dot = f".{class_name}"
134
+ suffix_hash = f"{class_name}#"
135
+ matches: list[str] = []
136
+
137
+ for sym in (getattr(cir, "symbols", None) or []):
138
+ if not isinstance(sym, str):
139
+ continue
140
+ if "#" in sym:
141
+ continue # method symbol — skip
142
+ # Exact match or package-qualified match
143
+ if sym == class_name or sym.endswith(suffix_dot):
144
+ matches.append(sym)
145
+
146
+ # Also scan raw_ir graph nodes for class symbols (more reliable for kind)
147
+ raw_nodes = _get_raw_nodes(cir)
148
+ node_fqns: set[str] = set()
149
+ for node in raw_nodes:
150
+ fqn = node.get("fqn") or ""
151
+ if "#" in fqn:
152
+ continue
153
+ kind = node.get("symbol_kind") or node.get("type") or ""
154
+ if kind not in ("class", "interface", "enum", "annotation", ""):
155
+ continue
156
+ if fqn == class_name or fqn.endswith(suffix_dot):
157
+ node_fqns.add(fqn)
158
+
159
+ # Merge — prefer node_fqns when available
160
+ all_fqns = list(dict.fromkeys(list(node_fqns) + matches))
161
+
162
+ if not all_fqns:
163
+ return "", []
164
+ return all_fqns[0], all_fqns
165
+
166
+
167
+ def _get_raw_nodes(cir: "CanonicalRepositoryIR") -> list[dict]:
168
+ raw_ir = getattr(cir, "_raw_ir", None) or {}
169
+ return (raw_ir.get("graph") or {}).get("nodes") or []
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Section builders
174
+ # ---------------------------------------------------------------------------
175
+
176
+ def _build_purpose(
177
+ class_fqn: str,
178
+ raw_nodes: list[dict],
179
+ stereotype: str,
180
+ cir: "CanonicalRepositoryIR",
181
+ model: "SpringSemanticModel",
182
+ ) -> str:
183
+ # Collect class-level annotations from raw_ir node
184
+ class_anns: list[str] = []
185
+ parent_sig = ""
186
+ for node in raw_nodes:
187
+ if node.get("fqn") == class_fqn:
188
+ class_anns = node.get("annotations") or []
189
+ break
190
+
191
+ # Inheritance
192
+ parent_sig = model.inheritance.immediate_parent(class_fqn)
193
+
194
+ # Base description from stereotype
195
+ desc = _STEREOTYPE_DESC.get(stereotype, "")
196
+
197
+ # Enrich with class-level @Transactional
198
+ tx_class = model.tx_index.class_level.get(class_fqn)
199
+ if tx_class:
200
+ desc = f"{desc}. Class-level @Transactional ({tx_class.propagation})" if desc else f"@Transactional ({tx_class.propagation})"
201
+
202
+ # Enrich with parent
203
+ if parent_sig:
204
+ parent_simple = _simple(parent_sig.split("<")[0])
205
+ if desc:
206
+ desc = f"{desc}. Extends {parent_simple}"
207
+ else:
208
+ desc = f"Extends {parent_simple}"
209
+
210
+ # Fallback: derive from annotations present
211
+ if not desc:
212
+ role_anns = [a for a in class_anns if a in (
213
+ "@Service", "@Repository", "@Controller", "@RestController",
214
+ "@Component", "@Configuration",
215
+ )]
216
+ if role_anns:
217
+ desc = f"{role_anns[0]} bean"
218
+
219
+ return desc or "No stereotype detected — may be a plain class or utility."
220
+
221
+
222
+ def _build_public_methods(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
223
+ """Return public method names for class_fqn from raw_ir nodes."""
224
+ prefix = class_fqn + "#"
225
+ methods: list[str] = []
226
+ for node in raw_nodes:
227
+ fqn = node.get("fqn") or ""
228
+ if not fqn.startswith(prefix):
229
+ continue
230
+ kind = node.get("symbol_kind") or node.get("type") or ""
231
+ if kind not in ("method", "endpoint", "constructor", ""):
232
+ continue
233
+ modifiers: list[str] = node.get("modifiers") or []
234
+ if "public" not in modifiers and "protected" not in modifiers:
235
+ continue
236
+ method_name = fqn[len(prefix):]
237
+ if method_name and not method_name.startswith("<"):
238
+ methods.append(method_name)
239
+ return sorted(set(methods))
240
+
241
+
242
+ def _build_callers(class_fqn: str, cir: "CanonicalRepositoryIR") -> list[str]:
243
+ """DI dependents + reverse call graph callers, deduplicated, simple names."""
244
+ seen: set[str] = set()
245
+ result: list[str] = []
246
+
247
+ # DI injection dependents
248
+ for fqn in (getattr(cir, "injection_graph", None) and cir.injection_graph.dependents_of(class_fqn) or []):
249
+ s = _simple(fqn)
250
+ if s not in seen:
251
+ seen.add(s)
252
+ result.append(s)
253
+
254
+ # Direct call reverse graph: reverse_graph[target] → {type → [callers]}
255
+ rev: dict = (getattr(cir, "reverse_graph", None) or {}).get(class_fqn) or {}
256
+ for callers in rev.values():
257
+ for caller_fqn in (callers or []):
258
+ # Strip method part to get class
259
+ cls_fqn = caller_fqn.rsplit("#", 1)[0] if "#" in caller_fqn else caller_fqn
260
+ if cls_fqn == class_fqn:
261
+ continue
262
+ s = _simple(cls_fqn)
263
+ if s not in seen:
264
+ seen.add(s)
265
+ result.append(s)
266
+
267
+ return result
268
+
269
+
270
+ def _build_deps(class_fqn: str, cir: "CanonicalRepositoryIR") -> list[str]:
271
+ """DI injected dependencies, simple names."""
272
+ deps = (getattr(cir, "injection_graph", None) and cir.injection_graph.dependencies_of(class_fqn) or [])
273
+ return sorted({_simple(d) for d in deps})
274
+
275
+
276
+ def _build_events_published(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
277
+ """Event types published by this class (any method of the class)."""
278
+ prefix = class_fqn + "#"
279
+ result: list[str] = []
280
+ for event_type, publishers in model.event_graph.publishers.items():
281
+ for pub in publishers:
282
+ if pub == class_fqn or pub.startswith(prefix):
283
+ result.append(_simple(event_type))
284
+ break
285
+ return sorted(set(result))
286
+
287
+
288
+ def _build_events_consumed(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
289
+ """Event types consumed/listened by this class."""
290
+ prefix = class_fqn + "#"
291
+ result: list[str] = []
292
+ for event_type, listeners in model.event_graph.listeners.items():
293
+ for lst in listeners:
294
+ if lst == class_fqn or lst.startswith(prefix):
295
+ result.append(_simple(event_type))
296
+ break
297
+ return sorted(set(result))
298
+
299
+
300
+ def _build_transactions(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
301
+ """Transaction boundaries for this class."""
302
+ result: list[str] = []
303
+
304
+ # Class-level
305
+ cls_tx = model.tx_index.class_level.get(class_fqn)
306
+ if cls_tx:
307
+ label = f"@Transactional (class-level, {cls_tx.propagation})"
308
+ if cls_tx.read_only:
309
+ label += ", readOnly"
310
+ result.append(label)
311
+
312
+ # Method-level
313
+ for boundary in (model.tx_index.by_class.get(class_fqn) or []):
314
+ method = _method_name(boundary.symbol)
315
+ label = method + "()"
316
+ extras: list[str] = []
317
+ if boundary.propagation != _DEFAULT_PROPAGATION:
318
+ extras.append(boundary.propagation)
319
+ if boundary.read_only:
320
+ extras.append("readOnly")
321
+ if extras:
322
+ label += f" [{', '.join(extras)}]"
323
+ result.append(label)
324
+
325
+ return result
326
+
327
+
328
+ def _build_security(
329
+ class_fqn: str,
330
+ raw_nodes: list[dict],
331
+ cir: "CanonicalRepositoryIR",
332
+ ) -> list[str]:
333
+ """Security annotations from method-level nodes + cir.security_index."""
334
+ seen: set[str] = set()
335
+ result: list[str] = []
336
+
337
+ prefix = class_fqn + "#"
338
+
339
+ # From cir.security_index (handler_symbol → CanonicalSecurity)
340
+ for handler_sym, sec in (getattr(cir, "security_index", None) or {}).items():
341
+ if not (handler_sym == class_fqn or handler_sym.startswith(prefix)):
342
+ continue
343
+ policy = getattr(sec, "policy", "") or ""
344
+ roles = getattr(sec, "roles", []) or []
345
+ method = _method_name(handler_sym)
346
+ if roles:
347
+ label = f"{method}(): {policy} ({', '.join(roles)})"
348
+ else:
349
+ label = f"{method}(): {policy}"
350
+ if label not in seen:
351
+ seen.add(label)
352
+ result.append(label)
353
+
354
+ # From raw_ir method annotations
355
+ for node in raw_nodes:
356
+ fqn = node.get("fqn") or ""
357
+ if not fqn.startswith(prefix):
358
+ continue
359
+ anns: list[str] = node.get("annotations") or []
360
+ for ann in anns:
361
+ if any(ann.startswith(p) for p in _SECURITY_ANNOTATION_PREFIXES):
362
+ method = _method_name(fqn)
363
+ label = f"{method}(): {ann}"
364
+ if label not in seen:
365
+ seen.add(label)
366
+ result.append(label)
367
+
368
+ return result
369
+
370
+
371
+ def _build_endpoints(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
372
+ """REST endpoints declared on this controller class."""
373
+ endpoints = model.endpoint_index.endpoints_for(class_fqn)
374
+ result: list[str] = []
375
+ for ep in endpoints:
376
+ method = getattr(ep, "method", "") or "?"
377
+ path = getattr(ep, "path", "") or "?"
378
+ result.append(f"{method} {path}")
379
+ return sorted(set(result))
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Main entry point
384
+ # ---------------------------------------------------------------------------
385
+
386
+ def explain_class(
387
+ class_name: str,
388
+ cir: "CanonicalRepositoryIR",
389
+ model: "SpringSemanticModel",
390
+ ) -> ClassExplanation:
391
+ """Build a ClassExplanation for class_name from existing CIR + model.
392
+
393
+ Never raises — wraps all derivation in try/except.
394
+ """
395
+ warnings: list[str] = []
396
+
397
+ try:
398
+ class_fqn, all_matches = _resolve_fqn(class_name, cir)
399
+ except Exception:
400
+ class_fqn, all_matches = "", []
401
+
402
+ if not class_fqn:
403
+ return ClassExplanation(
404
+ class_name=class_name,
405
+ class_fqn=class_name,
406
+ stereotype="unknown",
407
+ purpose="Class not found in repository symbols.",
408
+ warnings=[f"'{class_name}' not found in CIR symbols. Is this a Java/Kotlin repo?"],
409
+ )
410
+
411
+ if len(all_matches) > 1:
412
+ warnings.append(
413
+ f"Ambiguous: {len(all_matches)} classes named '{class_name}'. "
414
+ f"Showing first: {class_fqn}"
415
+ )
416
+
417
+ raw_nodes = _get_raw_nodes(cir)
418
+
419
+ try:
420
+ stereotype = model.bean_graph.get_stereotype(class_fqn) or "unknown"
421
+ except Exception:
422
+ stereotype = "unknown"
423
+
424
+ try:
425
+ purpose = _build_purpose(class_fqn, raw_nodes, stereotype, cir, model)
426
+ except Exception:
427
+ purpose = f"{stereotype} class"
428
+
429
+ try:
430
+ public_methods = _build_public_methods(class_fqn, raw_nodes)
431
+ except Exception:
432
+ public_methods = []
433
+
434
+ try:
435
+ incoming_callers = _build_callers(class_fqn, cir)
436
+ except Exception:
437
+ incoming_callers = []
438
+
439
+ try:
440
+ outgoing_deps = _build_deps(class_fqn, cir)
441
+ except Exception:
442
+ outgoing_deps = []
443
+
444
+ try:
445
+ events_published = _build_events_published(class_fqn, model)
446
+ except Exception:
447
+ events_published = []
448
+
449
+ try:
450
+ events_consumed = _build_events_consumed(class_fqn, model)
451
+ except Exception:
452
+ events_consumed = []
453
+
454
+ try:
455
+ transactions = _build_transactions(class_fqn, model)
456
+ except Exception:
457
+ transactions = []
458
+
459
+ try:
460
+ security_constraints = _build_security(class_fqn, raw_nodes, cir)
461
+ except Exception:
462
+ security_constraints = []
463
+
464
+ try:
465
+ rest_endpoints = _build_endpoints(class_fqn, model)
466
+ except Exception:
467
+ rest_endpoints = []
468
+
469
+ return ClassExplanation(
470
+ class_name=class_name,
471
+ class_fqn=class_fqn,
472
+ stereotype=stereotype,
473
+ purpose=purpose,
474
+ public_methods=public_methods,
475
+ incoming_callers=incoming_callers,
476
+ outgoing_deps=outgoing_deps,
477
+ events_published=events_published,
478
+ events_consumed=events_consumed,
479
+ transactions=transactions,
480
+ security_constraints=security_constraints,
481
+ rest_endpoints=rest_endpoints,
482
+ warnings=warnings,
483
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes