sourcecode 1.35.13__tar.gz → 1.35.15__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 (106) hide show
  1. {sourcecode-1.35.13 → sourcecode-1.35.15}/PKG-INFO +3 -3
  2. {sourcecode-1.35.13 → sourcecode-1.35.15}/README.md +2 -2
  3. {sourcecode-1.35.13 → sourcecode-1.35.15}/pyproject.toml +1 -1
  4. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/cir_graphs.py +9 -5
  6. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/cli.py +117 -0
  7. sourcecode-1.35.15/src/sourcecode/explain.py +485 -0
  8. sourcecode-1.35.15/src/sourcecode/fqn_utils.py +53 -0
  9. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/pr_impact.py +3 -1
  10. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_impact.py +7 -12
  11. {sourcecode-1.35.13 → sourcecode-1.35.15}/.github/workflows/build-windows.yml +0 -0
  12. {sourcecode-1.35.13 → sourcecode-1.35.15}/.gitignore +0 -0
  13. {sourcecode-1.35.13 → sourcecode-1.35.15}/.ruff.toml +0 -0
  14. {sourcecode-1.35.13 → sourcecode-1.35.15}/CHANGELOG.md +0 -0
  15. {sourcecode-1.35.13 → sourcecode-1.35.15}/CONTRIBUTING.md +0 -0
  16. {sourcecode-1.35.13 → sourcecode-1.35.15}/LICENSE +0 -0
  17. {sourcecode-1.35.13 → sourcecode-1.35.15}/SECURITY.md +0 -0
  18. {sourcecode-1.35.13 → sourcecode-1.35.15}/raw +0 -0
  19. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/adaptive_scanner.py +0 -0
  20. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/architecture_analyzer.py +0 -0
  21. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/architecture_summary.py +0 -0
  22. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/ast_extractor.py +0 -0
  23. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/cache.py +0 -0
  24. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/canonical_ir.py +0 -0
  25. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/classifier.py +0 -0
  26. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/code_notes_analyzer.py +0 -0
  27. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/confidence_analyzer.py +0 -0
  28. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/context_scorer.py +0 -0
  29. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/context_summarizer.py +0 -0
  30. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/contract_model.py +0 -0
  31. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/contract_pipeline.py +0 -0
  32. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/coverage_parser.py +0 -0
  33. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/dependency_analyzer.py +0 -0
  34. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/__init__.py +0 -0
  35. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/base.py +0 -0
  36. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/csproj_parser.py +0 -0
  37. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/dart.py +0 -0
  38. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/dotnet.py +0 -0
  39. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/elixir.py +0 -0
  40. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/go.py +0 -0
  41. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/heuristic.py +0 -0
  42. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/hybrid.py +0 -0
  43. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/java.py +0 -0
  44. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/jvm_ext.py +0 -0
  45. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/nodejs.py +0 -0
  46. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/parsers.py +0 -0
  47. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/php.py +0 -0
  48. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/project.py +0 -0
  49. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/python.py +0 -0
  50. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/ruby.py +0 -0
  51. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/rust.py +0 -0
  52. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/systems.py +0 -0
  53. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/terraform.py +0 -0
  54. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/tooling.py +0 -0
  55. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/doc_analyzer.py +0 -0
  56. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/entrypoint_classifier.py +0 -0
  57. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/env_analyzer.py +0 -0
  58. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/error_schema.py +0 -0
  59. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/file_classifier.py +0 -0
  60. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/flow_analyzer.py +0 -0
  61. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/git_analyzer.py +0 -0
  62. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/graph_analyzer.py +0 -0
  63. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/license.py +0 -0
  64. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/__init__.py +0 -0
  65. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  66. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  67. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  68. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  69. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  70. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/orchestrator.py +0 -0
  71. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/registry.py +0 -0
  72. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/runner.py +0 -0
  73. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/server.py +0 -0
  74. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp_nudge.py +0 -0
  75. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/metrics_analyzer.py +0 -0
  76. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/output_budget.py +0 -0
  77. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/path_filters.py +0 -0
  78. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/pr_comment_renderer.py +0 -0
  79. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/prepare_context.py +0 -0
  80. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/progress.py +0 -0
  81. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/ranking_engine.py +0 -0
  82. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/redactor.py +0 -0
  83. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/relevance_scorer.py +0 -0
  84. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/repo_classifier.py +0 -0
  85. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/repository_ir.py +0 -0
  86. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/ris.py +0 -0
  87. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/runtime_classifier.py +0 -0
  88. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/scanner.py +0 -0
  89. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/schema.py +0 -0
  90. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/semantic_analyzer.py +0 -0
  91. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/serializer.py +0 -0
  92. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_event_topology.py +0 -0
  93. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_findings.py +0 -0
  94. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_model.py +0 -0
  95. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_security_audit.py +0 -0
  96. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_semantic.py +0 -0
  97. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_tx_analyzer.py +0 -0
  98. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/summarizer.py +0 -0
  99. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/__init__.py +0 -0
  100. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/config.py +0 -0
  101. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/consent.py +0 -0
  102. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/events.py +0 -0
  103. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/filters.py +0 -0
  104. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/transport.py +0 -0
  105. {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/tree_utils.py +0 -0
  106. {sourcecode-1.35.13 → sourcecode-1.35.15}/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.15
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.15-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.15
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.15-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.15
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.15"
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.15"
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  from dataclasses import dataclass, field
15
15
 
16
+ from sourcecode.fqn_utils import normalize_owner_fqn
17
+
16
18
  # ---------------------------------------------------------------------------
17
19
  # ImplementationGraph — CH-001
18
20
  # ---------------------------------------------------------------------------
@@ -179,12 +181,14 @@ class InjectionGraph:
179
181
  if not from_fqn or not to_fqn:
180
182
  continue
181
183
 
182
- # Resolve injector to class level
183
- if "#" in from_fqn:
184
- class_fqn = from_fqn.rsplit("#", 1)[0]
184
+ # Resolve injector to class level.
185
+ # Three formats emitted by the CIR parser:
186
+ # Constructor: pkg.Class#<init> → class = pkg.Class
187
+ # Field: pkg.Class.field → class = pkg.Class (normalize_owner_fqn)
188
+ # Lombok: pkg.Class → class = pkg.Class (already class-level)
189
+ class_fqn = normalize_owner_fqn(from_fqn)
190
+ if class_fqn != from_fqn:
185
191
  injector_to_class[from_fqn] = class_fqn
186
- else:
187
- class_fqn = from_fqn
188
192
 
189
193
  # Build class → [dep, ...] and service → [class, ...] indices
190
194
  deps = deps_of.setdefault(class_fqn, [])
@@ -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,485 @@
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
+ from sourcecode.fqn_utils import normalize_owner_fqn
17
+
18
+ if TYPE_CHECKING:
19
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
20
+ from sourcecode.spring_model import SpringSemanticModel
21
+
22
+
23
+ _STEREOTYPE_DESC: dict[str, str] = {
24
+ "service": "Spring @Service — business logic layer",
25
+ "repository": "Spring @Repository — data access layer",
26
+ "controller": "Spring @Controller — MVC request handler",
27
+ "restcontroller": "Spring @RestController — REST request handler",
28
+ "component": "Spring @Component — general-purpose bean",
29
+ "configuration": "Spring @Configuration — bean factory / config",
30
+ "bean": "Spring @Bean — managed component",
31
+ }
32
+
33
+ _SECURITY_ANNOTATION_PREFIXES = (
34
+ "@PreAuthorize", "@PostAuthorize", "@Secured", "@RolesAllowed",
35
+ "@PermitAll", "@DenyAll",
36
+ )
37
+
38
+ _DEFAULT_PROPAGATION = "REQUIRED"
39
+
40
+
41
+ def _simple(fqn: str) -> str:
42
+ """pkg.Foo#bar → Foo#bar; pkg.Foo → Foo."""
43
+ if "." not in fqn and "#" not in fqn:
44
+ return fqn
45
+ # Strip package prefix before simple class name
46
+ if "#" in fqn:
47
+ cls_part, method = fqn.rsplit("#", 1)
48
+ return f"{cls_part.rsplit('.', 1)[-1]}#{method}"
49
+ return fqn.rsplit(".", 1)[-1]
50
+
51
+
52
+ def _method_name(fqn: str) -> str:
53
+ """pkg.Foo#bar → bar."""
54
+ return fqn.rsplit("#", 1)[-1] if "#" in fqn else fqn
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Output model
59
+ # ---------------------------------------------------------------------------
60
+
61
+ @dataclass
62
+ class ClassExplanation:
63
+ """Structured architectural summary for a single class."""
64
+
65
+ class_name: str
66
+ class_fqn: str
67
+ stereotype: str
68
+ purpose: str
69
+ public_methods: list[str] = field(default_factory=list)
70
+ incoming_callers: list[str] = field(default_factory=list)
71
+ outgoing_deps: list[str] = field(default_factory=list)
72
+ events_published: list[str] = field(default_factory=list)
73
+ events_consumed: list[str] = field(default_factory=list)
74
+ transactions: list[str] = field(default_factory=list)
75
+ security_constraints: list[str] = field(default_factory=list)
76
+ rest_endpoints: list[str] = field(default_factory=list)
77
+ warnings: list[str] = field(default_factory=list)
78
+
79
+ def render_text(self) -> str:
80
+ lines: list[str] = [f"## {self.class_name}", ""]
81
+
82
+ if self.class_fqn != self.class_name:
83
+ lines += [f"**FQN:** `{self.class_fqn}`", ""]
84
+
85
+ lines += ["**Purpose:**", self.purpose, ""]
86
+
87
+ def _section(title: str, items: list[str]) -> None:
88
+ if not items:
89
+ return
90
+ lines.append(f"**{title}:**")
91
+ for item in items:
92
+ lines.append(f"* {item}")
93
+ lines.append("")
94
+
95
+ _section("Public Methods", self.public_methods)
96
+ _section("Used By", self.incoming_callers)
97
+ _section("Calls", self.outgoing_deps)
98
+ _section("Publishes", self.events_published)
99
+ _section("Consumes", self.events_consumed)
100
+ _section("Transactions", self.transactions)
101
+ _section("Security", self.security_constraints)
102
+ _section("REST Endpoints", self.rest_endpoints)
103
+ _section("Warnings", self.warnings)
104
+
105
+ return "\n".join(lines).rstrip()
106
+
107
+ def to_dict(self) -> dict:
108
+ return {
109
+ "class_name": self.class_name,
110
+ "class_fqn": self.class_fqn,
111
+ "stereotype": self.stereotype,
112
+ "purpose": self.purpose,
113
+ "public_methods": self.public_methods,
114
+ "incoming_callers": self.incoming_callers,
115
+ "outgoing_deps": self.outgoing_deps,
116
+ "events_published": self.events_published,
117
+ "events_consumed": self.events_consumed,
118
+ "transactions": self.transactions,
119
+ "security_constraints": self.security_constraints,
120
+ "rest_endpoints": self.rest_endpoints,
121
+ "warnings": self.warnings,
122
+ }
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # FQN resolution
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def _resolve_fqn(class_name: str, cir: "CanonicalRepositoryIR") -> tuple[str, list[str]]:
130
+ """Find all class FQNs matching simple class_name in cir.symbols.
131
+
132
+ Returns (best_fqn, all_matches).
133
+ best_fqn is empty string when no match found.
134
+ """
135
+ suffix_dot = f".{class_name}"
136
+ suffix_hash = f"{class_name}#"
137
+ matches: list[str] = []
138
+
139
+ for sym in (getattr(cir, "symbols", None) or []):
140
+ if not isinstance(sym, str):
141
+ continue
142
+ if "#" in sym:
143
+ continue # method symbol — skip
144
+ # Exact match or package-qualified match
145
+ if sym == class_name or sym.endswith(suffix_dot):
146
+ matches.append(sym)
147
+
148
+ # Also scan raw_ir graph nodes for class symbols (more reliable for kind)
149
+ raw_nodes = _get_raw_nodes(cir)
150
+ node_fqns: set[str] = set()
151
+ for node in raw_nodes:
152
+ fqn = node.get("fqn") or ""
153
+ if "#" in fqn:
154
+ continue
155
+ kind = node.get("symbol_kind") or node.get("type") or ""
156
+ if kind not in ("class", "interface", "enum", "annotation", ""):
157
+ continue
158
+ if fqn == class_name or fqn.endswith(suffix_dot):
159
+ node_fqns.add(fqn)
160
+
161
+ # Merge — prefer node_fqns when available
162
+ all_fqns = list(dict.fromkeys(list(node_fqns) + matches))
163
+
164
+ if not all_fqns:
165
+ return "", []
166
+ return all_fqns[0], all_fqns
167
+
168
+
169
+ def _get_raw_nodes(cir: "CanonicalRepositoryIR") -> list[dict]:
170
+ raw_ir = getattr(cir, "_raw_ir", None) or {}
171
+ return (raw_ir.get("graph") or {}).get("nodes") or []
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Section builders
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def _build_purpose(
179
+ class_fqn: str,
180
+ raw_nodes: list[dict],
181
+ stereotype: str,
182
+ cir: "CanonicalRepositoryIR",
183
+ model: "SpringSemanticModel",
184
+ ) -> str:
185
+ # Collect class-level annotations from raw_ir node
186
+ class_anns: list[str] = []
187
+ parent_sig = ""
188
+ for node in raw_nodes:
189
+ if node.get("fqn") == class_fqn:
190
+ class_anns = node.get("annotations") or []
191
+ break
192
+
193
+ # Inheritance
194
+ parent_sig = model.inheritance.immediate_parent(class_fqn)
195
+
196
+ # Base description from stereotype
197
+ desc = _STEREOTYPE_DESC.get(stereotype, "")
198
+
199
+ # Enrich with class-level @Transactional
200
+ tx_class = model.tx_index.class_level.get(class_fqn)
201
+ if tx_class:
202
+ desc = f"{desc}. Class-level @Transactional ({tx_class.propagation})" if desc else f"@Transactional ({tx_class.propagation})"
203
+
204
+ # Enrich with parent
205
+ if parent_sig:
206
+ parent_simple = _simple(parent_sig.split("<")[0])
207
+ if desc:
208
+ desc = f"{desc}. Extends {parent_simple}"
209
+ else:
210
+ desc = f"Extends {parent_simple}"
211
+
212
+ # Fallback: derive from annotations present
213
+ if not desc:
214
+ role_anns = [a for a in class_anns if a in (
215
+ "@Service", "@Repository", "@Controller", "@RestController",
216
+ "@Component", "@Configuration",
217
+ )]
218
+ if role_anns:
219
+ desc = f"{role_anns[0]} bean"
220
+
221
+ return desc or "No stereotype detected — may be a plain class or utility."
222
+
223
+
224
+ def _build_public_methods(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
225
+ """Return public method names for class_fqn from raw_ir nodes."""
226
+ prefix = class_fqn + "#"
227
+ methods: list[str] = []
228
+ for node in raw_nodes:
229
+ fqn = node.get("fqn") or ""
230
+ if not fqn.startswith(prefix):
231
+ continue
232
+ kind = node.get("symbol_kind") or node.get("type") or ""
233
+ if kind not in ("method", "endpoint", "constructor", ""):
234
+ continue
235
+ modifiers: list[str] = node.get("modifiers") or []
236
+ if "public" not in modifiers and "protected" not in modifiers:
237
+ continue
238
+ method_name = fqn[len(prefix):]
239
+ if method_name and not method_name.startswith("<"):
240
+ methods.append(method_name)
241
+ return sorted(set(methods))
242
+
243
+
244
+ def _build_callers(class_fqn: str, cir: "CanonicalRepositoryIR") -> list[str]:
245
+ """DI dependents + reverse call graph callers, deduplicated, simple names."""
246
+ seen: set[str] = set()
247
+ result: list[str] = []
248
+
249
+ # DI injection dependents
250
+ for fqn in (getattr(cir, "injection_graph", None) and cir.injection_graph.dependents_of(class_fqn) or []):
251
+ s = _simple(fqn)
252
+ if s not in seen:
253
+ seen.add(s)
254
+ result.append(s)
255
+
256
+ # Direct call reverse graph: reverse_graph[target] → {type → [callers]}
257
+ rev: dict = (getattr(cir, "reverse_graph", None) or {}).get(class_fqn) or {}
258
+ for callers in rev.values():
259
+ for caller_fqn in (callers or []):
260
+ # Normalize: field (pkg.Class.field) and method (pkg.Class#method) → class FQN
261
+ cls_fqn = normalize_owner_fqn(caller_fqn)
262
+ if cls_fqn == class_fqn:
263
+ continue
264
+ s = _simple(cls_fqn)
265
+ if s not in seen:
266
+ seen.add(s)
267
+ result.append(s)
268
+
269
+ return result
270
+
271
+
272
+ def _build_deps(class_fqn: str, cir: "CanonicalRepositoryIR") -> list[str]:
273
+ """DI injected dependencies, simple names."""
274
+ deps = (getattr(cir, "injection_graph", None) and cir.injection_graph.dependencies_of(class_fqn) or [])
275
+ return sorted({_simple(d) for d in deps})
276
+
277
+
278
+ def _build_events_published(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
279
+ """Event types published by this class (any method of the class)."""
280
+ prefix = class_fqn + "#"
281
+ result: list[str] = []
282
+ for event_type, publishers in model.event_graph.publishers.items():
283
+ for pub in publishers:
284
+ if pub == class_fqn or pub.startswith(prefix):
285
+ result.append(_simple(event_type))
286
+ break
287
+ return sorted(set(result))
288
+
289
+
290
+ def _build_events_consumed(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
291
+ """Event types consumed/listened by this class."""
292
+ prefix = class_fqn + "#"
293
+ result: list[str] = []
294
+ for event_type, listeners in model.event_graph.listeners.items():
295
+ for lst in listeners:
296
+ if lst == class_fqn or lst.startswith(prefix):
297
+ result.append(_simple(event_type))
298
+ break
299
+ return sorted(set(result))
300
+
301
+
302
+ def _build_transactions(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
303
+ """Transaction boundaries for this class."""
304
+ result: list[str] = []
305
+
306
+ # Class-level
307
+ cls_tx = model.tx_index.class_level.get(class_fqn)
308
+ if cls_tx:
309
+ label = f"@Transactional (class-level, {cls_tx.propagation})"
310
+ if cls_tx.read_only:
311
+ label += ", readOnly"
312
+ result.append(label)
313
+
314
+ # Method-level
315
+ for boundary in (model.tx_index.by_class.get(class_fqn) or []):
316
+ method = _method_name(boundary.symbol)
317
+ label = method + "()"
318
+ extras: list[str] = []
319
+ if boundary.propagation != _DEFAULT_PROPAGATION:
320
+ extras.append(boundary.propagation)
321
+ if boundary.read_only:
322
+ extras.append("readOnly")
323
+ if extras:
324
+ label += f" [{', '.join(extras)}]"
325
+ result.append(label)
326
+
327
+ return result
328
+
329
+
330
+ def _build_security(
331
+ class_fqn: str,
332
+ raw_nodes: list[dict],
333
+ cir: "CanonicalRepositoryIR",
334
+ ) -> list[str]:
335
+ """Security annotations from method-level nodes + cir.security_index."""
336
+ seen: set[str] = set()
337
+ result: list[str] = []
338
+
339
+ prefix = class_fqn + "#"
340
+
341
+ # From cir.security_index (handler_symbol → CanonicalSecurity)
342
+ for handler_sym, sec in (getattr(cir, "security_index", None) or {}).items():
343
+ if not (handler_sym == class_fqn or handler_sym.startswith(prefix)):
344
+ continue
345
+ policy = getattr(sec, "policy", "") or ""
346
+ roles = getattr(sec, "roles", []) or []
347
+ method = _method_name(handler_sym)
348
+ if roles:
349
+ label = f"{method}(): {policy} ({', '.join(roles)})"
350
+ else:
351
+ label = f"{method}(): {policy}"
352
+ if label not in seen:
353
+ seen.add(label)
354
+ result.append(label)
355
+
356
+ # From raw_ir method annotations
357
+ for node in raw_nodes:
358
+ fqn = node.get("fqn") or ""
359
+ if not fqn.startswith(prefix):
360
+ continue
361
+ anns: list[str] = node.get("annotations") or []
362
+ for ann in anns:
363
+ if any(ann.startswith(p) for p in _SECURITY_ANNOTATION_PREFIXES):
364
+ method = _method_name(fqn)
365
+ label = f"{method}(): {ann}"
366
+ if label not in seen:
367
+ seen.add(label)
368
+ result.append(label)
369
+
370
+ return result
371
+
372
+
373
+ def _build_endpoints(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
374
+ """REST endpoints declared on this controller class."""
375
+ endpoints = model.endpoint_index.endpoints_for(class_fqn)
376
+ result: list[str] = []
377
+ for ep in endpoints:
378
+ method = getattr(ep, "method", "") or "?"
379
+ path = getattr(ep, "path", "") or "?"
380
+ result.append(f"{method} {path}")
381
+ return sorted(set(result))
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Main entry point
386
+ # ---------------------------------------------------------------------------
387
+
388
+ def explain_class(
389
+ class_name: str,
390
+ cir: "CanonicalRepositoryIR",
391
+ model: "SpringSemanticModel",
392
+ ) -> ClassExplanation:
393
+ """Build a ClassExplanation for class_name from existing CIR + model.
394
+
395
+ Never raises — wraps all derivation in try/except.
396
+ """
397
+ warnings: list[str] = []
398
+
399
+ try:
400
+ class_fqn, all_matches = _resolve_fqn(class_name, cir)
401
+ except Exception:
402
+ class_fqn, all_matches = "", []
403
+
404
+ if not class_fqn:
405
+ return ClassExplanation(
406
+ class_name=class_name,
407
+ class_fqn=class_name,
408
+ stereotype="unknown",
409
+ purpose="Class not found in repository symbols.",
410
+ warnings=[f"'{class_name}' not found in CIR symbols. Is this a Java/Kotlin repo?"],
411
+ )
412
+
413
+ if len(all_matches) > 1:
414
+ warnings.append(
415
+ f"Ambiguous: {len(all_matches)} classes named '{class_name}'. "
416
+ f"Showing first: {class_fqn}"
417
+ )
418
+
419
+ raw_nodes = _get_raw_nodes(cir)
420
+
421
+ try:
422
+ stereotype = model.bean_graph.get_stereotype(class_fqn) or "unknown"
423
+ except Exception:
424
+ stereotype = "unknown"
425
+
426
+ try:
427
+ purpose = _build_purpose(class_fqn, raw_nodes, stereotype, cir, model)
428
+ except Exception:
429
+ purpose = f"{stereotype} class"
430
+
431
+ try:
432
+ public_methods = _build_public_methods(class_fqn, raw_nodes)
433
+ except Exception:
434
+ public_methods = []
435
+
436
+ try:
437
+ incoming_callers = _build_callers(class_fqn, cir)
438
+ except Exception:
439
+ incoming_callers = []
440
+
441
+ try:
442
+ outgoing_deps = _build_deps(class_fqn, cir)
443
+ except Exception:
444
+ outgoing_deps = []
445
+
446
+ try:
447
+ events_published = _build_events_published(class_fqn, model)
448
+ except Exception:
449
+ events_published = []
450
+
451
+ try:
452
+ events_consumed = _build_events_consumed(class_fqn, model)
453
+ except Exception:
454
+ events_consumed = []
455
+
456
+ try:
457
+ transactions = _build_transactions(class_fqn, model)
458
+ except Exception:
459
+ transactions = []
460
+
461
+ try:
462
+ security_constraints = _build_security(class_fqn, raw_nodes, cir)
463
+ except Exception:
464
+ security_constraints = []
465
+
466
+ try:
467
+ rest_endpoints = _build_endpoints(class_fqn, model)
468
+ except Exception:
469
+ rest_endpoints = []
470
+
471
+ return ClassExplanation(
472
+ class_name=class_name,
473
+ class_fqn=class_fqn,
474
+ stereotype=stereotype,
475
+ purpose=purpose,
476
+ public_methods=public_methods,
477
+ incoming_callers=incoming_callers,
478
+ outgoing_deps=outgoing_deps,
479
+ events_published=events_published,
480
+ events_consumed=events_consumed,
481
+ transactions=transactions,
482
+ security_constraints=security_constraints,
483
+ rest_endpoints=rest_endpoints,
484
+ warnings=warnings,
485
+ )
@@ -0,0 +1,53 @@
1
+ """fqn_utils.py — FQN normalization utilities (single source of truth).
2
+
3
+ All code that needs to distinguish class FQNs from member FQNs (methods, fields,
4
+ constructors) must use these functions. No direct `.split("#")`, `.rsplit(".")`,
5
+ or lowercase-heuristic checks elsewhere.
6
+
7
+ Symbol FQN conventions in the CIR:
8
+ Class/Interface/Enum: pkg.ClassName (no # or lowercase-last-seg)
9
+ Method: pkg.ClassName#methodName (hash separator)
10
+ Constructor: pkg.ClassName#<init> (hash, angle-bracket name)
11
+ Field: pkg.ClassName.fieldName (dot separator, lowercase last segment)
12
+ Inner class: pkg.ClassName.InnerClass (dot separator, uppercase last segment)
13
+ """
14
+ from __future__ import annotations
15
+
16
+
17
+ def normalize_owner_fqn(fqn: str) -> str:
18
+ """Extract the owning class FQN from any symbol FQN.
19
+
20
+ Examples:
21
+ PatientServiceImpl -> PatientServiceImpl
22
+ org.foo.PatientServiceImpl -> org.foo.PatientServiceImpl
23
+ org.foo.PatientServiceImpl#savePatient -> org.foo.PatientServiceImpl
24
+ org.foo.PatientServiceImpl#<init> -> org.foo.PatientServiceImpl
25
+ org.foo.PatientServiceImpl.dao -> org.foo.PatientServiceImpl
26
+ org.foo.PatientServiceImpl.InnerClass -> org.foo.PatientServiceImpl.InnerClass (unchanged)
27
+ """
28
+ if "#" in fqn:
29
+ return fqn.rsplit("#", 1)[0]
30
+ if "." in fqn:
31
+ last_seg = fqn.rsplit(".", 1)[1]
32
+ if last_seg and last_seg[0].islower():
33
+ return fqn.rsplit(".", 1)[0]
34
+ return fqn
35
+
36
+
37
+ def is_member_fqn(fqn: str) -> bool:
38
+ """Return True for method/field/constructor FQNs; False for type FQNs.
39
+
40
+ True: pkg.Class#method, pkg.Class#<init>, pkg.Class.fieldName
41
+ False: pkg.Class, pkg.outer.InnerClass, simple.Name
42
+ """
43
+ if "#" in fqn:
44
+ return True
45
+ if "." in fqn:
46
+ last_seg = fqn.rsplit(".", 1)[1]
47
+ return bool(last_seg and last_seg[0].islower())
48
+ return False
49
+
50
+
51
+ def is_type_fqn(fqn: str) -> bool:
52
+ """Return True for class/interface/enum/record FQNs; False for member FQNs."""
53
+ return not is_member_fqn(fqn)
@@ -20,6 +20,8 @@ from dataclasses import dataclass, field
20
20
  from pathlib import Path
21
21
  from typing import TYPE_CHECKING, Optional
22
22
 
23
+ from sourcecode.fqn_utils import is_member_fqn
24
+
23
25
  if TYPE_CHECKING:
24
26
  from sourcecode.canonical_ir import CanonicalRepositoryIR
25
27
  from sourcecode.spring_model import SpringSemanticModel
@@ -147,7 +149,7 @@ def _build_file_class_index(cir: "CanonicalRepositoryIR") -> dict[str, list[str]
147
149
  for node in nodes:
148
150
  fqn: str = node.get("fqn") or ""
149
151
  sf: str = node.get("source_file") or ""
150
- if not fqn or not sf or "#" in fqn:
152
+ if not fqn or not sf or is_member_fqn(fqn):
151
153
  continue
152
154
  index.setdefault(sf, []).append(fqn)
153
155
  return index
@@ -25,6 +25,7 @@ from dataclasses import dataclass, field
25
25
  from pathlib import Path
26
26
  from typing import TYPE_CHECKING, Optional
27
27
 
28
+ from sourcecode.fqn_utils import normalize_owner_fqn
28
29
  from sourcecode.spring_findings import SEVERITY_ORDER, SpringFinding
29
30
  from sourcecode.spring_model import SpringSemanticModel
30
31
 
@@ -311,19 +312,13 @@ def _bfs_callers(
311
312
  if etype in _SKIP_EDGE_TYPES:
312
313
  continue
313
314
  for caller in fqn_list:
314
- _add_caller(caller, depth)
315
- # CH-002: injects edge to a field/constructor node → also traverse
316
- # the containing class, bypassing the skipped contained_in edge.
317
- # Two formats emitted by the CIR parser:
318
- # Constructor injection: pkg.Class#<init> (hash separator)
319
- # Field injection: pkg.Class.field (dot, lowercase last segment)
320
315
  if etype == "injects":
321
- if "#" in caller:
322
- _add_caller(caller.rsplit("#", 1)[0], depth)
323
- elif "." in caller:
324
- last_seg = caller.rsplit(".", 1)[1]
325
- if last_seg and last_seg[0].islower():
326
- _add_caller(caller.rsplit(".", 1)[0], depth)
316
+ # CH-002: field (pkg.Class.field) and constructor (pkg.Class#<init>)
317
+ # FQNs are injection sites, not callers. Normalize to owning class so
318
+ # member FQNs never appear in direct_callers / indirect_callers.
319
+ _add_caller(normalize_owner_fqn(caller), depth)
320
+ else:
321
+ _add_caller(caller, depth)
327
322
 
328
323
  return direct, indirect, was_truncated
329
324
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes