sourcecode 1.35.12__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.
Files changed (104) hide show
  1. {sourcecode-1.35.12 → sourcecode-1.35.13}/PKG-INFO +3 -3
  2. {sourcecode-1.35.12 → sourcecode-1.35.13}/README.md +2 -2
  3. {sourcecode-1.35.12 → sourcecode-1.35.13}/pyproject.toml +1 -1
  4. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/cli.py +152 -0
  6. sourcecode-1.35.13/src/sourcecode/pr_impact.py +475 -0
  7. {sourcecode-1.35.12 → sourcecode-1.35.13}/.github/workflows/build-windows.yml +0 -0
  8. {sourcecode-1.35.12 → sourcecode-1.35.13}/.gitignore +0 -0
  9. {sourcecode-1.35.12 → sourcecode-1.35.13}/.ruff.toml +0 -0
  10. {sourcecode-1.35.12 → sourcecode-1.35.13}/CHANGELOG.md +0 -0
  11. {sourcecode-1.35.12 → sourcecode-1.35.13}/CONTRIBUTING.md +0 -0
  12. {sourcecode-1.35.12 → sourcecode-1.35.13}/LICENSE +0 -0
  13. {sourcecode-1.35.12 → sourcecode-1.35.13}/SECURITY.md +0 -0
  14. {sourcecode-1.35.12 → sourcecode-1.35.13}/raw +0 -0
  15. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/adaptive_scanner.py +0 -0
  16. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/architecture_analyzer.py +0 -0
  17. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/architecture_summary.py +0 -0
  18. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/ast_extractor.py +0 -0
  19. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/cache.py +0 -0
  20. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/canonical_ir.py +0 -0
  21. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/cir_graphs.py +0 -0
  22. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/classifier.py +0 -0
  23. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/code_notes_analyzer.py +0 -0
  24. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/confidence_analyzer.py +0 -0
  25. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/context_scorer.py +0 -0
  26. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/context_summarizer.py +0 -0
  27. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/contract_model.py +0 -0
  28. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/contract_pipeline.py +0 -0
  29. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/coverage_parser.py +0 -0
  30. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/dependency_analyzer.py +0 -0
  31. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/__init__.py +0 -0
  32. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/base.py +0 -0
  33. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/csproj_parser.py +0 -0
  34. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/dart.py +0 -0
  35. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/dotnet.py +0 -0
  36. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/elixir.py +0 -0
  37. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/go.py +0 -0
  38. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/heuristic.py +0 -0
  39. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/hybrid.py +0 -0
  40. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/java.py +0 -0
  41. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/jvm_ext.py +0 -0
  42. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/nodejs.py +0 -0
  43. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/parsers.py +0 -0
  44. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/php.py +0 -0
  45. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/project.py +0 -0
  46. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/python.py +0 -0
  47. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/ruby.py +0 -0
  48. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/rust.py +0 -0
  49. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/systems.py +0 -0
  50. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/terraform.py +0 -0
  51. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/detectors/tooling.py +0 -0
  52. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/doc_analyzer.py +0 -0
  53. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/entrypoint_classifier.py +0 -0
  54. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/env_analyzer.py +0 -0
  55. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/error_schema.py +0 -0
  56. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/file_classifier.py +0 -0
  57. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/flow_analyzer.py +0 -0
  58. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/git_analyzer.py +0 -0
  59. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/graph_analyzer.py +0 -0
  60. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/license.py +0 -0
  61. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/__init__.py +0 -0
  62. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  63. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  64. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  65. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  66. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  67. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/orchestrator.py +0 -0
  68. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/registry.py +0 -0
  69. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/runner.py +0 -0
  70. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp/server.py +0 -0
  71. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/mcp_nudge.py +0 -0
  72. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/metrics_analyzer.py +0 -0
  73. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/output_budget.py +0 -0
  74. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/path_filters.py +0 -0
  75. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/pr_comment_renderer.py +0 -0
  76. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/prepare_context.py +0 -0
  77. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/progress.py +0 -0
  78. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/ranking_engine.py +0 -0
  79. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/redactor.py +0 -0
  80. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/relevance_scorer.py +0 -0
  81. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/repo_classifier.py +0 -0
  82. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/repository_ir.py +0 -0
  83. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/ris.py +0 -0
  84. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/runtime_classifier.py +0 -0
  85. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/scanner.py +0 -0
  86. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/schema.py +0 -0
  87. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/semantic_analyzer.py +0 -0
  88. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/serializer.py +0 -0
  89. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_event_topology.py +0 -0
  90. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_findings.py +0 -0
  91. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_impact.py +0 -0
  92. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_model.py +0 -0
  93. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_security_audit.py +0 -0
  94. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_semantic.py +0 -0
  95. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/spring_tx_analyzer.py +0 -0
  96. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/summarizer.py +0 -0
  97. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/telemetry/__init__.py +0 -0
  98. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/telemetry/config.py +0 -0
  99. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/telemetry/consent.py +0 -0
  100. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/telemetry/events.py +0 -0
  101. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/telemetry/filters.py +0 -0
  102. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/telemetry/transport.py +0 -0
  103. {sourcecode-1.35.12 → sourcecode-1.35.13}/src/sourcecode/tree_utils.py +0 -0
  104. {sourcecode-1.35.12 → 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.12
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
- ![Version](https://img.shields.io/badge/version-1.35.12-blue)
42
+ ![Version](https://img.shields.io/badge/version-1.35.13-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.12
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
- ![Version](https://img.shields.io/badge/version-1.35.12-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.35.13-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.12
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.12"
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"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.12"
3
+ __version__ = "1.35.13"
@@ -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
 
@@ -4027,6 +4030,155 @@ def impact_chain_cmd(
4027
4030
  typer.echo("✓ copied to clipboard", err=True)
4028
4031
 
4029
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
+
4030
4182
  # ── Enterprise Workflow Commands ──────────────────────────────────────────────
4031
4183
  #
4032
4184
  # These are the five canonical enterprise workflows. Each is a thin wrapper
@@ -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
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes