sourcecode 1.35.11__tar.gz → 1.35.13__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.11 → sourcecode-1.35.13}/PKG-INFO +3 -3
- {sourcecode-1.35.11 → sourcecode-1.35.13}/README.md +2 -2
- {sourcecode-1.35.11 → sourcecode-1.35.13}/pyproject.toml +1 -1
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/__init__.py +1 -1
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/cli.py +154 -55
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/registry.py +1 -12
- sourcecode-1.35.13/src/sourcecode/pr_impact.py +475 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_security_audit.py +7 -1
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_tx_analyzer.py +3 -1
- {sourcecode-1.35.11 → sourcecode-1.35.13}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/.gitignore +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/.ruff.toml +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/CHANGELOG.md +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/CONTRIBUTING.md +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/LICENSE +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/SECURITY.md +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/raw +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/cache.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/cir_graphs.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/classifier.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/flow_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/license.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/progress.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/redactor.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/repository_ir.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/ris.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/scanner.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/schema.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/serializer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_impact.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/__init__.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/config.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/events.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/filters.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/transport.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-1.35.11 → sourcecode-1.35.13}/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.13
|
|
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.13
|
|
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.13
|
|
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.13"
|
|
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"
|
|
@@ -213,7 +213,7 @@ _HELP = _build_help_text()
|
|
|
213
213
|
# not consumed as a repository path.
|
|
214
214
|
_SUBCOMMANDS: frozenset[str] = frozenset(
|
|
215
215
|
{
|
|
216
|
-
"telemetry", "prepare-context", "version", "config",
|
|
216
|
+
"telemetry", "prepare-context", "version", "config",
|
|
217
217
|
"repo-ir", "mcp", "endpoints", "impact",
|
|
218
218
|
# Enterprise workflow commands
|
|
219
219
|
"onboard", "modernize", "fix-bug", "review-pr",
|
|
@@ -227,6 +227,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
227
227
|
"spring-audit",
|
|
228
228
|
# Spring impact chain
|
|
229
229
|
"impact-chain",
|
|
230
|
+
# PR blast-radius report
|
|
231
|
+
"pr-impact",
|
|
230
232
|
}
|
|
231
233
|
)
|
|
232
234
|
|
|
@@ -266,6 +268,7 @@ _OPTIONS_WITH_VALUE: frozenset[str] = frozenset({
|
|
|
266
268
|
"--symbol",
|
|
267
269
|
"--max-importers",
|
|
268
270
|
"--exclude",
|
|
271
|
+
"--files",
|
|
269
272
|
})
|
|
270
273
|
|
|
271
274
|
|
|
@@ -641,12 +644,6 @@ def main(
|
|
|
641
644
|
hidden=True,
|
|
642
645
|
help="Edge types for --graph-modules, comma-separated: imports,calls,contains,extends.",
|
|
643
646
|
),
|
|
644
|
-
no_tree: bool = typer.Option(
|
|
645
|
-
False,
|
|
646
|
-
"--no-tree",
|
|
647
|
-
hidden=True,
|
|
648
|
-
help="(Removed) No-op. File tree is excluded by default. Use --tree to include it.",
|
|
649
|
-
),
|
|
650
647
|
tree: bool = typer.Option(
|
|
651
648
|
False,
|
|
652
649
|
"--tree",
|
|
@@ -766,14 +763,6 @@ def main(
|
|
|
766
763
|
help="Limit total exported semantic nodes across all file contracts.",
|
|
767
764
|
min=1,
|
|
768
765
|
),
|
|
769
|
-
dependency_depth: int = typer.Option(
|
|
770
|
-
0,
|
|
771
|
-
"--dependency-depth",
|
|
772
|
-
hidden=True,
|
|
773
|
-
help="(Removed) Transitive resolution is not implemented. Pass 0 or omit.",
|
|
774
|
-
min=0,
|
|
775
|
-
max=5,
|
|
776
|
-
),
|
|
777
766
|
entrypoints_only: bool = typer.Option(
|
|
778
767
|
False,
|
|
779
768
|
"--entrypoints-only",
|
|
@@ -797,12 +786,6 @@ def main(
|
|
|
797
786
|
hidden=True,
|
|
798
787
|
help="Include a compact dependency graph in contract output.",
|
|
799
788
|
),
|
|
800
|
-
compress_types: bool = typer.Option(
|
|
801
|
-
False,
|
|
802
|
-
"--compress-types",
|
|
803
|
-
hidden=True,
|
|
804
|
-
help="(Removed) No observable effect when type signatures are not extracted. Omit.",
|
|
805
|
-
),
|
|
806
789
|
symbol: Optional[str] = typer.Option(
|
|
807
790
|
None,
|
|
808
791
|
"--symbol",
|
|
@@ -853,6 +836,7 @@ def main(
|
|
|
853
836
|
return
|
|
854
837
|
|
|
855
838
|
_t0 = time.monotonic()
|
|
839
|
+
no_tree: bool = False # set True by --agent; --no-tree flag removed
|
|
856
840
|
|
|
857
841
|
# Validate new flag choices
|
|
858
842
|
_MODE_CHOICES = ("contract", "minimal", "standard", "raw")
|
|
@@ -925,22 +909,6 @@ def main(
|
|
|
925
909
|
)
|
|
926
910
|
raise typer.Exit(code=2) # FIX-P2-7: arg validation → exit 2
|
|
927
911
|
|
|
928
|
-
if dependency_depth > 0:
|
|
929
|
-
typer.echo(
|
|
930
|
-
f"[warning] --dependency-depth {dependency_depth} has no effect: "
|
|
931
|
-
"transitive import resolution is not implemented for npm/yarn/pip projects. "
|
|
932
|
-
"Using depth=0 (direct dependencies only).",
|
|
933
|
-
err=True,
|
|
934
|
-
)
|
|
935
|
-
dependency_depth = 0
|
|
936
|
-
|
|
937
|
-
if compress_types:
|
|
938
|
-
typer.echo(
|
|
939
|
-
"[deprecated] --compress-types is removed: type signatures are rarely extracted "
|
|
940
|
-
"at default depth. Flag ignored.",
|
|
941
|
-
err=True,
|
|
942
|
-
)
|
|
943
|
-
|
|
944
912
|
# Pro gate for --full: removing truncation limits is enterprise-scale functionality.
|
|
945
913
|
if full:
|
|
946
914
|
from sourcecode.license import require_feature as _req_full
|
|
@@ -2174,11 +2142,9 @@ def main(
|
|
|
2174
2142
|
mode=mode,
|
|
2175
2143
|
rank_by=rank_by, # type: ignore[arg-type]
|
|
2176
2144
|
max_symbols=max_symbols,
|
|
2177
|
-
dependency_depth=dependency_depth,
|
|
2178
2145
|
entrypoints_only=entrypoints_only,
|
|
2179
2146
|
changed_only=changed_only,
|
|
2180
2147
|
symbol=symbol,
|
|
2181
|
-
compress_types=compress_types,
|
|
2182
2148
|
max_importers=max_importers,
|
|
2183
2149
|
semantic_calls=sm.semantic_calls or None,
|
|
2184
2150
|
code_notes=sm.code_notes or None,
|
|
@@ -4064,6 +4030,155 @@ def impact_chain_cmd(
|
|
|
4064
4030
|
typer.echo("✓ copied to clipboard", err=True)
|
|
4065
4031
|
|
|
4066
4032
|
|
|
4033
|
+
# ── PR Impact Report ──────────────────────────────────────────────────────────
|
|
4034
|
+
|
|
4035
|
+
@app.command("pr-impact")
|
|
4036
|
+
def pr_impact_cmd(
|
|
4037
|
+
path: Path = typer.Argument(
|
|
4038
|
+
Path("."),
|
|
4039
|
+
help="Repository root (default: current directory)",
|
|
4040
|
+
),
|
|
4041
|
+
files: Path = typer.Option(
|
|
4042
|
+
...,
|
|
4043
|
+
"--files",
|
|
4044
|
+
help="File containing the list of changed Java files, one path per line.",
|
|
4045
|
+
),
|
|
4046
|
+
output_path: Optional[Path] = typer.Option(
|
|
4047
|
+
None, "--output", "-o",
|
|
4048
|
+
help="Write output to a file instead of stdout.",
|
|
4049
|
+
),
|
|
4050
|
+
format: str = typer.Option(
|
|
4051
|
+
"text", "--format", "-f",
|
|
4052
|
+
help="Output format: text (default) or json.",
|
|
4053
|
+
show_default=True,
|
|
4054
|
+
),
|
|
4055
|
+
copy: bool = typer.Option(
|
|
4056
|
+
False, "--copy", "-c",
|
|
4057
|
+
help="Copy output to clipboard after a successful run.",
|
|
4058
|
+
),
|
|
4059
|
+
) -> None:
|
|
4060
|
+
"""PR blast-radius report: what can break if this PR is merged?
|
|
4061
|
+
|
|
4062
|
+
\b
|
|
4063
|
+
Reads a list of changed Java files and produces a consolidated report:
|
|
4064
|
+
- Modified classes found in the changed files
|
|
4065
|
+
- Affected REST endpoints reachable through the call chain
|
|
4066
|
+
- Direct callers of each modified class
|
|
4067
|
+
- Event publishers and consumers triggered by the change
|
|
4068
|
+
- @Transactional methods in the changed classes
|
|
4069
|
+
- Consolidated risk level (CRITICAL / HIGH / MEDIUM / LOW)
|
|
4070
|
+
|
|
4071
|
+
\b
|
|
4072
|
+
Reuses existing graph and impact analysis — no new parsers.
|
|
4073
|
+
JAVA/SPRING ONLY.
|
|
4074
|
+
|
|
4075
|
+
\b
|
|
4076
|
+
Examples:
|
|
4077
|
+
sourcecode pr-impact --files changed_files.txt
|
|
4078
|
+
sourcecode pr-impact /path/to/repo --files diff.txt --format json
|
|
4079
|
+
sourcecode pr-impact --files changes.txt --output pr_report.txt
|
|
4080
|
+
"""
|
|
4081
|
+
import json as _json
|
|
4082
|
+
|
|
4083
|
+
from sourcecode.repository_ir import find_java_files
|
|
4084
|
+
from sourcecode.canonical_ir import build_canonical_ir
|
|
4085
|
+
from sourcecode.spring_model import SpringSemanticModel
|
|
4086
|
+
from sourcecode.pr_impact import run_pr_impact
|
|
4087
|
+
|
|
4088
|
+
target = path.resolve()
|
|
4089
|
+
if not target.exists() or not target.is_dir():
|
|
4090
|
+
_emit_error_json(
|
|
4091
|
+
INVALID_INPUT_CODE,
|
|
4092
|
+
f"'{target}' is not a valid directory.",
|
|
4093
|
+
path=str(target),
|
|
4094
|
+
hint="Pass an existing repository directory.",
|
|
4095
|
+
expected="A directory path.",
|
|
4096
|
+
)
|
|
4097
|
+
raise typer.Exit(code=1)
|
|
4098
|
+
|
|
4099
|
+
if not files.exists():
|
|
4100
|
+
_emit_error_json(
|
|
4101
|
+
INVALID_INPUT_CODE,
|
|
4102
|
+
f"--files path '{files}' does not exist.",
|
|
4103
|
+
path=str(files),
|
|
4104
|
+
hint="Pass a file containing one Java file path per line.",
|
|
4105
|
+
expected="An existing file path.",
|
|
4106
|
+
)
|
|
4107
|
+
raise typer.Exit(code=1)
|
|
4108
|
+
|
|
4109
|
+
if format not in ("text", "json"):
|
|
4110
|
+
_emit_error_json(
|
|
4111
|
+
INVALID_INPUT_CODE,
|
|
4112
|
+
f"Invalid format '{format}'.",
|
|
4113
|
+
hint="format must be: text or json.",
|
|
4114
|
+
expected="text | json",
|
|
4115
|
+
)
|
|
4116
|
+
raise typer.Exit(code=1)
|
|
4117
|
+
|
|
4118
|
+
# Read changed-files list
|
|
4119
|
+
changed_files = [
|
|
4120
|
+
line.strip()
|
|
4121
|
+
for line in files.read_text(encoding="utf-8").splitlines()
|
|
4122
|
+
if line.strip()
|
|
4123
|
+
]
|
|
4124
|
+
if not changed_files:
|
|
4125
|
+
_emit_error_json(
|
|
4126
|
+
INVALID_INPUT_CODE,
|
|
4127
|
+
f"--files '{files}' is empty.",
|
|
4128
|
+
hint="File must contain at least one Java file path.",
|
|
4129
|
+
expected="One Java file path per line.",
|
|
4130
|
+
)
|
|
4131
|
+
raise typer.Exit(code=1)
|
|
4132
|
+
|
|
4133
|
+
file_list = find_java_files(target)
|
|
4134
|
+
if not file_list:
|
|
4135
|
+
data: dict = {
|
|
4136
|
+
"schema_version": "1.0",
|
|
4137
|
+
"modified_classes": [],
|
|
4138
|
+
"risk_level": "UNKNOWN",
|
|
4139
|
+
"risk_reason": "No Java files found in repository — Spring analysis requires Java source.",
|
|
4140
|
+
"analysis_warnings": ["No Java files found."],
|
|
4141
|
+
"metadata": {"changed_files_count": len(changed_files)},
|
|
4142
|
+
}
|
|
4143
|
+
output = _json.dumps(data, indent=2, ensure_ascii=False) if format == "json" else (
|
|
4144
|
+
"No Java files found in repository — Spring analysis requires Java source."
|
|
4145
|
+
)
|
|
4146
|
+
if output_path is not None:
|
|
4147
|
+
output_path.write_text(output, encoding="utf-8")
|
|
4148
|
+
typer.echo("PR impact report written to " + str(output_path), err=True)
|
|
4149
|
+
else:
|
|
4150
|
+
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
4151
|
+
sys.stdout.buffer.write(b"\n")
|
|
4152
|
+
sys.stdout.buffer.flush()
|
|
4153
|
+
return
|
|
4154
|
+
|
|
4155
|
+
cir = build_canonical_ir(file_list, target)
|
|
4156
|
+
model = SpringSemanticModel.build(cir)
|
|
4157
|
+
report = run_pr_impact(cir, changed_files, root=target, model=model)
|
|
4158
|
+
|
|
4159
|
+
if format == "json":
|
|
4160
|
+
output = _json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
|
|
4161
|
+
else:
|
|
4162
|
+
output = report.render_text()
|
|
4163
|
+
|
|
4164
|
+
if output_path is not None:
|
|
4165
|
+
output_path.write_text(output, encoding="utf-8")
|
|
4166
|
+
typer.echo(
|
|
4167
|
+
f"PR impact report written to {output_path} "
|
|
4168
|
+
f"(risk: {report.risk_level}, "
|
|
4169
|
+
f"{len(report.modified_classes)} classes, "
|
|
4170
|
+
f"{len(report.affected_endpoints)} endpoints)",
|
|
4171
|
+
err=True,
|
|
4172
|
+
)
|
|
4173
|
+
else:
|
|
4174
|
+
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
4175
|
+
sys.stdout.buffer.write(b"\n")
|
|
4176
|
+
sys.stdout.buffer.flush()
|
|
4177
|
+
if copy:
|
|
4178
|
+
if _copy_to_clipboard(output):
|
|
4179
|
+
typer.echo("✓ copied to clipboard", err=True)
|
|
4180
|
+
|
|
4181
|
+
|
|
4067
4182
|
# ── Enterprise Workflow Commands ──────────────────────────────────────────────
|
|
4068
4183
|
#
|
|
4069
4184
|
# These are the five canonical enterprise workflows. Each is a thin wrapper
|
|
@@ -4611,22 +4726,6 @@ def cold_start_cmd(
|
|
|
4611
4726
|
typer.echo(_out)
|
|
4612
4727
|
|
|
4613
4728
|
|
|
4614
|
-
# ── analyze (legacy alias) ────────────────────────────────────────────────────
|
|
4615
|
-
|
|
4616
|
-
@app.command("analyze", hidden=True)
|
|
4617
|
-
def analyze_cmd(
|
|
4618
|
-
path: Path = typer.Argument(Path("."), help="Repository path to analyze"),
|
|
4619
|
-
) -> None:
|
|
4620
|
-
"""[deprecated] Use: sourcecode [PATH]"""
|
|
4621
|
-
typer.echo(
|
|
4622
|
-
"Warning: 'analyze' subcommand is deprecated.\n"
|
|
4623
|
-
"Use: sourcecode .\n"
|
|
4624
|
-
" sourcecode /path/to/repo",
|
|
4625
|
-
err=True,
|
|
4626
|
-
)
|
|
4627
|
-
raise typer.Exit(code=1)
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
4729
|
# ── MCP server ────────────────────────────────────────────────────────────────
|
|
4631
4730
|
|
|
4632
4731
|
@mcp_app.command("serve")
|
|
@@ -1134,17 +1134,6 @@ repo_path: absolute path to the repository (default: current working directory).
|
|
|
1134
1134
|
|
|
1135
1135
|
def _internal_specs() -> list[ToolSpec]:
|
|
1136
1136
|
return [
|
|
1137
|
-
_alias_spec(
|
|
1138
|
-
"analyze",
|
|
1139
|
-
"Hidden legacy CLI alias. Not exposed to MCP.",
|
|
1140
|
-
("analyze",),
|
|
1141
|
-
(
|
|
1142
|
-
ToolParamSpec("path", "argument", str, required=False, default=".", is_path=True),
|
|
1143
|
-
),
|
|
1144
|
-
lambda inputs: ["analyze", str(inputs.get("path", "."))],
|
|
1145
|
-
internal=True,
|
|
1146
|
-
not_exposed_to_cli=True,
|
|
1147
|
-
),
|
|
1148
1137
|
_alias_spec(
|
|
1149
1138
|
"start_session",
|
|
1150
1139
|
"Internal orchestration helper. Not exposed to MCP.",
|
|
@@ -1370,7 +1359,7 @@ def build_tool_specs() -> tuple[ToolSpec, ...]:
|
|
|
1370
1359
|
_canonical_spec_for_runtime_command(runtime)
|
|
1371
1360
|
for runtime in discover_runtime_commands()
|
|
1372
1361
|
if (runtime.callback is not None or runtime.path == ())
|
|
1373
|
-
and
|
|
1362
|
+
and not runtime.hidden
|
|
1374
1363
|
]
|
|
1375
1364
|
# Mark canonical tools that should not be served via MCP (validate_registry still checks them)
|
|
1376
1365
|
canonical = [
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""pr_impact.py — PR Impact Report: blast radius for a list of changed Java files.
|
|
2
|
+
|
|
3
|
+
Answers: "What can I break if I merge this PR?"
|
|
4
|
+
|
|
5
|
+
Aggregates run_impact_chain() + event topology across all classes in changed files.
|
|
6
|
+
Produces a consolidated text report + structured dict.
|
|
7
|
+
|
|
8
|
+
Reuses: CIR, SpringSemanticModel, ImpactOrchestrator, EventGraph.
|
|
9
|
+
No new parsers or CIR traversals.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
cir = build_canonical_ir(find_java_files(root), root)
|
|
13
|
+
report = run_pr_impact(cir, ["src/.../UserService.java"], root=root)
|
|
14
|
+
print(report.render_text())
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Optional
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from sourcecode.canonical_ir import CanonicalRepositoryIR
|
|
25
|
+
from sourcecode.spring_model import SpringSemanticModel
|
|
26
|
+
|
|
27
|
+
_RISK_ORDER: dict[str, int] = {
|
|
28
|
+
"critical": 4, "high": 3, "medium": 2, "low": 1, "unknown": 0
|
|
29
|
+
}
|
|
30
|
+
_RISK_LABEL: dict[int, str] = {
|
|
31
|
+
4: "CRITICAL", 3: "HIGH", 2: "MEDIUM", 1: "LOW", 0: "UNKNOWN"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Output model
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PRImpactReport:
|
|
41
|
+
"""Consolidated impact report for a list of changed Java files."""
|
|
42
|
+
|
|
43
|
+
modified_classes: list[str] = field(default_factory=list)
|
|
44
|
+
affected_endpoints: list[dict] = field(default_factory=list) # {method, path}
|
|
45
|
+
direct_callers: list[str] = field(default_factory=list)
|
|
46
|
+
event_publishers: list[str] = field(default_factory=list) # human lines
|
|
47
|
+
event_consumers: list[str] = field(default_factory=list) # human lines
|
|
48
|
+
transactional_methods: list[str] = field(default_factory=list)
|
|
49
|
+
risk_level: str = "UNKNOWN"
|
|
50
|
+
risk_reason: str = ""
|
|
51
|
+
analysis_warnings: list[str] = field(default_factory=list)
|
|
52
|
+
metadata: dict = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"schema_version": "1.0",
|
|
57
|
+
"modified_classes": self.modified_classes,
|
|
58
|
+
"affected_endpoints": self.affected_endpoints,
|
|
59
|
+
"direct_callers": self.direct_callers,
|
|
60
|
+
"event_flow": {
|
|
61
|
+
"publishers": self.event_publishers,
|
|
62
|
+
"consumers": self.event_consumers,
|
|
63
|
+
},
|
|
64
|
+
"transactional_methods": self.transactional_methods,
|
|
65
|
+
"risk_level": self.risk_level,
|
|
66
|
+
"risk_reason": self.risk_reason,
|
|
67
|
+
"analysis_warnings": self.analysis_warnings,
|
|
68
|
+
"metadata": self.metadata,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def render_text(self) -> str:
|
|
72
|
+
sep = "=" * 50
|
|
73
|
+
lines = [sep, "PR IMPACT REPORT", "=" * 16, ""]
|
|
74
|
+
|
|
75
|
+
def _short(fqn: str) -> str:
|
|
76
|
+
return fqn.rsplit(".", 1)[-1] if "." in fqn else fqn
|
|
77
|
+
|
|
78
|
+
def _short_method(fqn: str) -> str:
|
|
79
|
+
if "#" in fqn:
|
|
80
|
+
cls, meth = fqn.rsplit("#", 1)
|
|
81
|
+
return f"{_short(cls)}.{meth}()"
|
|
82
|
+
return _short(fqn)
|
|
83
|
+
|
|
84
|
+
lines.append("Modified:")
|
|
85
|
+
lines.append("")
|
|
86
|
+
if self.modified_classes:
|
|
87
|
+
for cls in self.modified_classes:
|
|
88
|
+
lines.append(f" * {_short(cls)}")
|
|
89
|
+
else:
|
|
90
|
+
lines.append(" (no Spring classes found in changed files)")
|
|
91
|
+
lines.append("")
|
|
92
|
+
|
|
93
|
+
if self.affected_endpoints:
|
|
94
|
+
lines.append("Affected Endpoints:")
|
|
95
|
+
lines.append("")
|
|
96
|
+
for ep in self.affected_endpoints:
|
|
97
|
+
lines.append(f" * {ep.get('method', '?')} {ep.get('path', '?')}")
|
|
98
|
+
lines.append("")
|
|
99
|
+
|
|
100
|
+
if self.direct_callers:
|
|
101
|
+
lines.append("Direct Callers:")
|
|
102
|
+
lines.append("")
|
|
103
|
+
for caller in self.direct_callers:
|
|
104
|
+
lines.append(f" * {_short(caller)}")
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
event_items = self.event_publishers + self.event_consumers
|
|
108
|
+
if event_items:
|
|
109
|
+
lines.append("Event Flow:")
|
|
110
|
+
lines.append("")
|
|
111
|
+
for item in event_items:
|
|
112
|
+
lines.append(f" * {item}")
|
|
113
|
+
lines.append("")
|
|
114
|
+
|
|
115
|
+
if self.transactional_methods:
|
|
116
|
+
lines.append("Transactional Impact:")
|
|
117
|
+
lines.append("")
|
|
118
|
+
for m in self.transactional_methods:
|
|
119
|
+
lines.append(f" * {_short_method(m)}")
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
lines.append("Risk Level:")
|
|
123
|
+
lines.append(self.risk_level)
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
if self.risk_reason:
|
|
127
|
+
lines.append("Reason:")
|
|
128
|
+
lines.append(self.risk_reason)
|
|
129
|
+
lines.append("")
|
|
130
|
+
|
|
131
|
+
lines.append(sep)
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# File → class mapping
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _build_file_class_index(cir: "CanonicalRepositoryIR") -> dict[str, list[str]]:
|
|
140
|
+
"""Return {relative_source_file: [class_fqns]} from CIR raw IR nodes.
|
|
141
|
+
|
|
142
|
+
Only collects class-level nodes (no '#' in fqn) — method/field nodes are excluded
|
|
143
|
+
because impact-chain is queried at class granularity.
|
|
144
|
+
"""
|
|
145
|
+
index: dict[str, list[str]] = {}
|
|
146
|
+
nodes: list[dict] = (cir._raw_ir.get("graph") or {}).get("nodes") or []
|
|
147
|
+
for node in nodes:
|
|
148
|
+
fqn: str = node.get("fqn") or ""
|
|
149
|
+
sf: str = node.get("source_file") or ""
|
|
150
|
+
if not fqn or not sf or "#" in fqn:
|
|
151
|
+
continue
|
|
152
|
+
index.setdefault(sf, []).append(fqn)
|
|
153
|
+
return index
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_changed_files(
|
|
157
|
+
file_list: list[str],
|
|
158
|
+
file_class_index: dict[str, list[str]],
|
|
159
|
+
root: Path,
|
|
160
|
+
) -> tuple[list[str], list[str]]:
|
|
161
|
+
"""Map changed file paths to class FQNs.
|
|
162
|
+
|
|
163
|
+
Matching order:
|
|
164
|
+
1. Exact key in file_class_index (path already relative to repo root)
|
|
165
|
+
2. Relative path derived from absolute path via root
|
|
166
|
+
3. Suffix match (e.g., "UserService.java" matches any CIR file ending with it)
|
|
167
|
+
|
|
168
|
+
Returns (class_fqns, warnings). class_fqns is deduplicated, order-preserving.
|
|
169
|
+
"""
|
|
170
|
+
class_fqns: list[str] = []
|
|
171
|
+
warnings: list[str] = []
|
|
172
|
+
seen_classes: set[str] = set()
|
|
173
|
+
|
|
174
|
+
for raw_path in file_list:
|
|
175
|
+
path_str = raw_path.strip()
|
|
176
|
+
if not path_str:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
norm = path_str.replace("\\", "/")
|
|
180
|
+
candidates: list[str] = []
|
|
181
|
+
|
|
182
|
+
# 1. Exact match
|
|
183
|
+
if norm in file_class_index:
|
|
184
|
+
candidates = file_class_index[norm]
|
|
185
|
+
else:
|
|
186
|
+
# 2. Absolute path → relative to root
|
|
187
|
+
try:
|
|
188
|
+
abs_p = Path(path_str)
|
|
189
|
+
if abs_p.is_absolute():
|
|
190
|
+
rel_str = str(abs_p.relative_to(root)).replace("\\", "/")
|
|
191
|
+
if rel_str in file_class_index:
|
|
192
|
+
candidates = file_class_index[rel_str]
|
|
193
|
+
except (ValueError, Exception):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# 3. Suffix match
|
|
197
|
+
if not candidates:
|
|
198
|
+
matches = [
|
|
199
|
+
k for k in file_class_index
|
|
200
|
+
if k == norm or k.endswith("/" + norm.lstrip("/"))
|
|
201
|
+
]
|
|
202
|
+
if len(matches) == 1:
|
|
203
|
+
candidates = file_class_index[matches[0]]
|
|
204
|
+
elif len(matches) > 1:
|
|
205
|
+
warnings.append(
|
|
206
|
+
f"Ambiguous path '{path_str}' matched {len(matches)} files; "
|
|
207
|
+
"using first match."
|
|
208
|
+
)
|
|
209
|
+
candidates = file_class_index[matches[0]]
|
|
210
|
+
|
|
211
|
+
if not candidates:
|
|
212
|
+
warnings.append(
|
|
213
|
+
f"No Spring classes found for '{path_str}' — "
|
|
214
|
+
"file not in CIR or has no class symbols."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
for cls in candidates:
|
|
218
|
+
if cls not in seen_classes:
|
|
219
|
+
seen_classes.add(cls)
|
|
220
|
+
class_fqns.append(cls)
|
|
221
|
+
|
|
222
|
+
return class_fqns, warnings
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Event flow
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _collect_event_flow(
|
|
230
|
+
class_fqns_set: set[str],
|
|
231
|
+
model: "SpringSemanticModel",
|
|
232
|
+
) -> tuple[list[str], list[str]]:
|
|
233
|
+
"""Return (publisher_lines, consumer_lines) describing event flow for changed classes.
|
|
234
|
+
|
|
235
|
+
publisher_lines: "Publishes <EventType>" for events published by changed classes.
|
|
236
|
+
consumer_lines: "Consumed by <Listener>" when changed class publishes an event that
|
|
237
|
+
has listeners, or "Listens to <EventType>" when a changed class
|
|
238
|
+
is itself a listener.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def _short(fqn: str) -> str:
|
|
242
|
+
return fqn.rsplit(".", 1)[-1] if "." in fqn else fqn
|
|
243
|
+
|
|
244
|
+
def _class_of(fqn: str) -> str:
|
|
245
|
+
return fqn.split("#")[0] if "#" in fqn else fqn
|
|
246
|
+
|
|
247
|
+
publisher_lines: list[str] = []
|
|
248
|
+
consumer_lines: list[str] = []
|
|
249
|
+
seen: set[str] = set()
|
|
250
|
+
|
|
251
|
+
# Changed class publishes an event → report publish + downstream consumers
|
|
252
|
+
for event_type, publishers in model.event_graph.publishers.items():
|
|
253
|
+
for pub_fqn in publishers:
|
|
254
|
+
if _class_of(pub_fqn) not in class_fqns_set:
|
|
255
|
+
continue
|
|
256
|
+
pub_key = f"pub:{event_type}"
|
|
257
|
+
if pub_key not in seen:
|
|
258
|
+
seen.add(pub_key)
|
|
259
|
+
publisher_lines.append(f"Publishes {_short(event_type)}")
|
|
260
|
+
for consumer_fqn in model.event_graph.listeners_of(event_type):
|
|
261
|
+
con_key = f"con:{event_type}:{consumer_fqn}"
|
|
262
|
+
if con_key not in seen:
|
|
263
|
+
seen.add(con_key)
|
|
264
|
+
consumer_lines.append(f"Consumed by {_short(consumer_fqn)}")
|
|
265
|
+
|
|
266
|
+
# Changed class is a listener → report what it listens to
|
|
267
|
+
for event_type, listeners in model.event_graph.listeners.items():
|
|
268
|
+
for lst_fqn in listeners:
|
|
269
|
+
if _class_of(lst_fqn) not in class_fqns_set:
|
|
270
|
+
continue
|
|
271
|
+
lst_class = _class_of(lst_fqn)
|
|
272
|
+
lst_key = f"lst:{lst_class}:{event_type}"
|
|
273
|
+
if lst_key not in seen:
|
|
274
|
+
seen.add(lst_key)
|
|
275
|
+
consumer_lines.append(f"Listens to {_short(event_type)}")
|
|
276
|
+
|
|
277
|
+
return publisher_lines, consumer_lines
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Transactional methods
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def _collect_tx_methods(
|
|
285
|
+
class_fqns_set: set[str],
|
|
286
|
+
model: "SpringSemanticModel",
|
|
287
|
+
) -> list[str]:
|
|
288
|
+
"""Return FQNs with @Transactional boundaries declared in changed classes."""
|
|
289
|
+
tx_methods: list[str] = []
|
|
290
|
+
seen: set[str] = set()
|
|
291
|
+
|
|
292
|
+
for cls in class_fqns_set:
|
|
293
|
+
# Class-level @Transactional: the class symbol itself is the boundary
|
|
294
|
+
if cls in model.tx_index.class_level:
|
|
295
|
+
if cls not in seen:
|
|
296
|
+
seen.add(cls)
|
|
297
|
+
tx_methods.append(cls)
|
|
298
|
+
# Method-level @Transactional
|
|
299
|
+
for boundary in model.tx_index.by_class.get(cls, []):
|
|
300
|
+
sym = boundary.symbol
|
|
301
|
+
if sym not in seen:
|
|
302
|
+
seen.add(sym)
|
|
303
|
+
tx_methods.append(sym)
|
|
304
|
+
|
|
305
|
+
return tx_methods
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Risk consolidation
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _compute_risk(
|
|
313
|
+
endpoints: list[dict],
|
|
314
|
+
callers: list[str],
|
|
315
|
+
event_publishers: list[str],
|
|
316
|
+
event_consumers: list[str],
|
|
317
|
+
tx_methods: list[str],
|
|
318
|
+
individual_risks: list[str],
|
|
319
|
+
) -> tuple[str, str]:
|
|
320
|
+
"""Return (risk_level_label, reason_string).
|
|
321
|
+
|
|
322
|
+
Base risk from individual impact chains. Boost when multiple dimensions present.
|
|
323
|
+
"""
|
|
324
|
+
base = max((_RISK_ORDER.get(r.lower(), 0) for r in individual_risks), default=0)
|
|
325
|
+
|
|
326
|
+
reasons: list[str] = []
|
|
327
|
+
if endpoints:
|
|
328
|
+
reasons.append("Public API")
|
|
329
|
+
if event_publishers or event_consumers:
|
|
330
|
+
reasons.append("Event Flow")
|
|
331
|
+
if tx_methods:
|
|
332
|
+
reasons.append("Transaction Boundary")
|
|
333
|
+
if len(callers) > 5:
|
|
334
|
+
reasons.append("High Call Fan-in")
|
|
335
|
+
|
|
336
|
+
level = base
|
|
337
|
+
if len(reasons) >= 3 and level < _RISK_ORDER["high"]:
|
|
338
|
+
level = _RISK_ORDER["high"]
|
|
339
|
+
elif len(reasons) >= 2 and level < _RISK_ORDER["medium"]:
|
|
340
|
+
level = _RISK_ORDER["medium"]
|
|
341
|
+
|
|
342
|
+
label = _RISK_LABEL.get(level, "LOW")
|
|
343
|
+
reason = " + ".join(reasons) if reasons else "No high-risk signals detected"
|
|
344
|
+
return label, reason
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# Entry point
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def run_pr_impact(
|
|
352
|
+
cir: "CanonicalRepositoryIR",
|
|
353
|
+
changed_files: list[str],
|
|
354
|
+
*,
|
|
355
|
+
root: Path,
|
|
356
|
+
model: Optional["SpringSemanticModel"] = None,
|
|
357
|
+
) -> PRImpactReport:
|
|
358
|
+
"""Run PR impact analysis for a list of changed Java file paths.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
cir: CanonicalRepositoryIR from build_canonical_ir().
|
|
362
|
+
changed_files: Paths to changed Java files (relative, absolute, or bare name).
|
|
363
|
+
root: Repo root (used for absolute-path normalization).
|
|
364
|
+
model: Pre-built SpringSemanticModel. Built internally if None.
|
|
365
|
+
|
|
366
|
+
Returns PRImpactReport — always serializable, never raises.
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
return _run_pr_impact_internal(cir, changed_files, root=root, model=model)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
return PRImpactReport(
|
|
372
|
+
risk_level="UNKNOWN",
|
|
373
|
+
risk_reason="Internal error during analysis.",
|
|
374
|
+
analysis_warnings=[f"Internal error: {type(exc).__name__}: {exc}"],
|
|
375
|
+
metadata={"changed_files_count": len(changed_files)},
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _run_pr_impact_internal(
|
|
380
|
+
cir: "CanonicalRepositoryIR",
|
|
381
|
+
changed_files: list[str],
|
|
382
|
+
*,
|
|
383
|
+
root: Path,
|
|
384
|
+
model: Optional["SpringSemanticModel"],
|
|
385
|
+
) -> PRImpactReport:
|
|
386
|
+
from sourcecode.spring_model import SpringSemanticModel
|
|
387
|
+
from sourcecode.spring_impact import run_impact_chain
|
|
388
|
+
|
|
389
|
+
t0 = time.monotonic()
|
|
390
|
+
warnings: list[str] = []
|
|
391
|
+
|
|
392
|
+
if model is None:
|
|
393
|
+
model = SpringSemanticModel.build(cir)
|
|
394
|
+
|
|
395
|
+
# 1. Map file paths → class FQNs
|
|
396
|
+
file_class_index = _build_file_class_index(cir)
|
|
397
|
+
class_fqns, file_warnings = _resolve_changed_files(changed_files, file_class_index, root)
|
|
398
|
+
warnings.extend(file_warnings)
|
|
399
|
+
|
|
400
|
+
if not class_fqns:
|
|
401
|
+
return PRImpactReport(
|
|
402
|
+
risk_level="UNKNOWN",
|
|
403
|
+
risk_reason="No Spring classes found in changed files.",
|
|
404
|
+
analysis_warnings=warnings,
|
|
405
|
+
metadata={
|
|
406
|
+
"changed_files_count": len(changed_files),
|
|
407
|
+
"classes_analyzed": 0,
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
class_fqns_set = set(class_fqns)
|
|
412
|
+
|
|
413
|
+
# 2. Impact chain per modified class
|
|
414
|
+
all_direct_callers: list[str] = []
|
|
415
|
+
all_endpoints: list[dict] = []
|
|
416
|
+
individual_risks: list[str] = []
|
|
417
|
+
seen_callers: set[str] = set()
|
|
418
|
+
seen_ep_ids: set[str] = set()
|
|
419
|
+
|
|
420
|
+
for cls in class_fqns:
|
|
421
|
+
result = run_impact_chain(
|
|
422
|
+
cir, cls,
|
|
423
|
+
root=root,
|
|
424
|
+
model=model,
|
|
425
|
+
prebuilt_findings=[], # skip audit findings — focus on structural impact
|
|
426
|
+
)
|
|
427
|
+
warnings.extend(result.analysis_warnings)
|
|
428
|
+
individual_risks.append(result.risk_level)
|
|
429
|
+
|
|
430
|
+
for caller in result.direct_callers:
|
|
431
|
+
caller_class = caller.split("#")[0] if "#" in caller else caller
|
|
432
|
+
if caller_class not in class_fqns_set and caller_class not in seen_callers:
|
|
433
|
+
seen_callers.add(caller_class)
|
|
434
|
+
all_direct_callers.append(caller_class)
|
|
435
|
+
|
|
436
|
+
for ep in result.endpoints_affected:
|
|
437
|
+
ep_id = ep.endpoint_id
|
|
438
|
+
if ep_id not in seen_ep_ids:
|
|
439
|
+
seen_ep_ids.add(ep_id)
|
|
440
|
+
all_endpoints.append({"method": ep.method, "path": ep.path})
|
|
441
|
+
|
|
442
|
+
# 3. Event flow
|
|
443
|
+
event_publishers, event_consumers = _collect_event_flow(class_fqns_set, model)
|
|
444
|
+
|
|
445
|
+
# 4. Transactional boundaries in changed classes
|
|
446
|
+
tx_methods = _collect_tx_methods(class_fqns_set, model)
|
|
447
|
+
|
|
448
|
+
# 5. Consolidated risk
|
|
449
|
+
risk_level, risk_reason = _compute_risk(
|
|
450
|
+
all_endpoints,
|
|
451
|
+
all_direct_callers,
|
|
452
|
+
event_publishers,
|
|
453
|
+
event_consumers,
|
|
454
|
+
tx_methods,
|
|
455
|
+
individual_risks,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
elapsed_ms = round((time.monotonic() - t0) * 1000, 2)
|
|
459
|
+
|
|
460
|
+
return PRImpactReport(
|
|
461
|
+
modified_classes=class_fqns,
|
|
462
|
+
affected_endpoints=all_endpoints,
|
|
463
|
+
direct_callers=all_direct_callers,
|
|
464
|
+
event_publishers=event_publishers,
|
|
465
|
+
event_consumers=event_consumers,
|
|
466
|
+
transactional_methods=tx_methods,
|
|
467
|
+
risk_level=risk_level,
|
|
468
|
+
risk_reason=risk_reason,
|
|
469
|
+
analysis_warnings=warnings,
|
|
470
|
+
metadata={
|
|
471
|
+
"changed_files_count": len(changed_files),
|
|
472
|
+
"classes_analyzed": len(class_fqns),
|
|
473
|
+
"analysis_time_ms": elapsed_ms,
|
|
474
|
+
},
|
|
475
|
+
)
|
|
@@ -469,9 +469,15 @@ def run_security_audit(
|
|
|
469
469
|
|
|
470
470
|
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
471
471
|
|
|
472
|
+
_spring_detected = (
|
|
473
|
+
(model is not None and bool(model.bean_graph.beans))
|
|
474
|
+
or tx_index.stats()["total"] > 0
|
|
475
|
+
or cir.metadata.get("security_model", "unknown") != "unknown"
|
|
476
|
+
)
|
|
477
|
+
|
|
472
478
|
result = SpringAuditResult(
|
|
473
479
|
repo_id=getattr(cir, "cir_hash", "")[:16],
|
|
474
|
-
spring_detected=
|
|
480
|
+
spring_detected=_spring_detected,
|
|
475
481
|
scope="security",
|
|
476
482
|
findings=findings,
|
|
477
483
|
limitations=[
|
|
@@ -719,9 +719,11 @@ def run_tx_audit(
|
|
|
719
719
|
|
|
720
720
|
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
721
721
|
|
|
722
|
+
_spring_detected = tx_index.stats()["total"] > 0 or bool(model.bean_graph.beans)
|
|
723
|
+
|
|
722
724
|
result = SpringAuditResult(
|
|
723
725
|
repo_id=getattr(cir, "cir_hash", "")[:16],
|
|
724
|
-
spring_detected=
|
|
726
|
+
spring_detected=_spring_detected,
|
|
725
727
|
scope="tx",
|
|
726
728
|
findings=findings,
|
|
727
729
|
limitations=[
|
|
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
|