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.
Files changed (104) hide show
  1. {sourcecode-1.35.11 → sourcecode-1.35.13}/PKG-INFO +3 -3
  2. {sourcecode-1.35.11 → sourcecode-1.35.13}/README.md +2 -2
  3. {sourcecode-1.35.11 → sourcecode-1.35.13}/pyproject.toml +1 -1
  4. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/cli.py +154 -55
  6. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/registry.py +1 -12
  7. sourcecode-1.35.13/src/sourcecode/pr_impact.py +475 -0
  8. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_security_audit.py +7 -1
  9. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_tx_analyzer.py +3 -1
  10. {sourcecode-1.35.11 → sourcecode-1.35.13}/.github/workflows/build-windows.yml +0 -0
  11. {sourcecode-1.35.11 → sourcecode-1.35.13}/.gitignore +0 -0
  12. {sourcecode-1.35.11 → sourcecode-1.35.13}/.ruff.toml +0 -0
  13. {sourcecode-1.35.11 → sourcecode-1.35.13}/CHANGELOG.md +0 -0
  14. {sourcecode-1.35.11 → sourcecode-1.35.13}/CONTRIBUTING.md +0 -0
  15. {sourcecode-1.35.11 → sourcecode-1.35.13}/LICENSE +0 -0
  16. {sourcecode-1.35.11 → sourcecode-1.35.13}/SECURITY.md +0 -0
  17. {sourcecode-1.35.11 → sourcecode-1.35.13}/raw +0 -0
  18. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/adaptive_scanner.py +0 -0
  19. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/architecture_analyzer.py +0 -0
  20. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/architecture_summary.py +0 -0
  21. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/ast_extractor.py +0 -0
  22. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/cache.py +0 -0
  23. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/canonical_ir.py +0 -0
  24. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/cir_graphs.py +0 -0
  25. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/classifier.py +0 -0
  26. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/code_notes_analyzer.py +0 -0
  27. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/confidence_analyzer.py +0 -0
  28. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/context_scorer.py +0 -0
  29. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/context_summarizer.py +0 -0
  30. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/contract_model.py +0 -0
  31. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/contract_pipeline.py +0 -0
  32. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/coverage_parser.py +0 -0
  33. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/dependency_analyzer.py +0 -0
  34. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/__init__.py +0 -0
  35. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/base.py +0 -0
  36. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/csproj_parser.py +0 -0
  37. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/dart.py +0 -0
  38. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/dotnet.py +0 -0
  39. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/elixir.py +0 -0
  40. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/go.py +0 -0
  41. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/heuristic.py +0 -0
  42. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/hybrid.py +0 -0
  43. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/java.py +0 -0
  44. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/jvm_ext.py +0 -0
  45. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/nodejs.py +0 -0
  46. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/parsers.py +0 -0
  47. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/php.py +0 -0
  48. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/project.py +0 -0
  49. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/python.py +0 -0
  50. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/ruby.py +0 -0
  51. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/rust.py +0 -0
  52. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/systems.py +0 -0
  53. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/terraform.py +0 -0
  54. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/detectors/tooling.py +0 -0
  55. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/doc_analyzer.py +0 -0
  56. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/entrypoint_classifier.py +0 -0
  57. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/env_analyzer.py +0 -0
  58. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/error_schema.py +0 -0
  59. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/file_classifier.py +0 -0
  60. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/flow_analyzer.py +0 -0
  61. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/git_analyzer.py +0 -0
  62. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/graph_analyzer.py +0 -0
  63. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/license.py +0 -0
  64. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/__init__.py +0 -0
  65. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  66. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  67. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  68. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  69. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  70. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/orchestrator.py +0 -0
  71. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/runner.py +0 -0
  72. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp/server.py +0 -0
  73. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/mcp_nudge.py +0 -0
  74. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/metrics_analyzer.py +0 -0
  75. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/output_budget.py +0 -0
  76. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/path_filters.py +0 -0
  77. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/pr_comment_renderer.py +0 -0
  78. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/prepare_context.py +0 -0
  79. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/progress.py +0 -0
  80. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/ranking_engine.py +0 -0
  81. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/redactor.py +0 -0
  82. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/relevance_scorer.py +0 -0
  83. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/repo_classifier.py +0 -0
  84. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/repository_ir.py +0 -0
  85. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/ris.py +0 -0
  86. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/runtime_classifier.py +0 -0
  87. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/scanner.py +0 -0
  88. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/schema.py +0 -0
  89. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/semantic_analyzer.py +0 -0
  90. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/serializer.py +0 -0
  91. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_event_topology.py +0 -0
  92. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_findings.py +0 -0
  93. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_impact.py +0 -0
  94. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_model.py +0 -0
  95. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/spring_semantic.py +0 -0
  96. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/summarizer.py +0 -0
  97. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/__init__.py +0 -0
  98. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/config.py +0 -0
  99. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/consent.py +0 -0
  100. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/events.py +0 -0
  101. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/filters.py +0 -0
  102. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/telemetry/transport.py +0 -0
  103. {sourcecode-1.35.11 → sourcecode-1.35.13}/src/sourcecode/tree_utils.py +0 -0
  104. {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.11
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.10-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.10
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.10-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.10
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.11"
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.11"
3
+ __version__ = "1.35.13"
@@ -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", "analyze",
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 (not runtime.hidden or runtime.path == ("analyze",))
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=True,
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=True,
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