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.
- {sourcecode-1.35.13 → sourcecode-1.35.15}/PKG-INFO +3 -3
- {sourcecode-1.35.13 → sourcecode-1.35.15}/README.md +2 -2
- {sourcecode-1.35.13 → sourcecode-1.35.15}/pyproject.toml +1 -1
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/__init__.py +1 -1
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/cir_graphs.py +9 -5
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/cli.py +117 -0
- sourcecode-1.35.15/src/sourcecode/explain.py +485 -0
- sourcecode-1.35.15/src/sourcecode/fqn_utils.py +53 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/pr_impact.py +3 -1
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_impact.py +7 -12
- {sourcecode-1.35.13 → sourcecode-1.35.15}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/.gitignore +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/.ruff.toml +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/CHANGELOG.md +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/CONTRIBUTING.md +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/LICENSE +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/SECURITY.md +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/raw +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/cache.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/classifier.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/flow_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/license.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/registry.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/progress.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/redactor.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/repository_ir.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/ris.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/scanner.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/schema.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/serializer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_security_audit.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/spring_tx_analyzer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/__init__.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/config.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/events.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/filters.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/telemetry/transport.py +0 -0
- {sourcecode-1.35.13 → sourcecode-1.35.15}/src/sourcecode/tree_utils.py +0 -0
- {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.
|
|
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
|
-

|
|
43
43
|

|
|
44
44
|
|
|
45
45
|
---
|
|
@@ -113,7 +113,7 @@ pipx install sourcecode
|
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
115
|
sourcecode version
|
|
116
|
-
# sourcecode 1.35.
|
|
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
|
-

|
|
6
6
|

|
|
7
7
|
|
|
8
8
|
---
|
|
@@ -76,7 +76,7 @@ pipx install sourcecode
|
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
78
|
sourcecode version
|
|
79
|
-
# sourcecode 1.35.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|