sourcecode 1.33.25__tar.gz → 1.35.1__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 (100) hide show
  1. {sourcecode-1.33.25 → sourcecode-1.35.1}/PKG-INFO +2 -2
  2. {sourcecode-1.33.25 → sourcecode-1.35.1}/README.md +1 -1
  3. {sourcecode-1.33.25 → sourcecode-1.35.1}/pyproject.toml +1 -1
  4. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/cli.py +182 -0
  6. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/server.py +35 -0
  7. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/repository_ir.py +12 -1
  8. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/ris.py +47 -0
  9. sourcecode-1.35.1/src/sourcecode/spring_findings.py +130 -0
  10. sourcecode-1.35.1/src/sourcecode/spring_model.py +258 -0
  11. sourcecode-1.35.1/src/sourcecode/spring_security_audit.py +506 -0
  12. sourcecode-1.35.1/src/sourcecode/spring_semantic.py +340 -0
  13. sourcecode-1.35.1/src/sourcecode/spring_tx_analyzer.py +672 -0
  14. {sourcecode-1.33.25 → sourcecode-1.35.1}/.github/workflows/build-windows.yml +0 -0
  15. {sourcecode-1.33.25 → sourcecode-1.35.1}/.gitignore +0 -0
  16. {sourcecode-1.33.25 → sourcecode-1.35.1}/.ruff.toml +0 -0
  17. {sourcecode-1.33.25 → sourcecode-1.35.1}/CHANGELOG.md +0 -0
  18. {sourcecode-1.33.25 → sourcecode-1.35.1}/CONTRIBUTING.md +0 -0
  19. {sourcecode-1.33.25 → sourcecode-1.35.1}/LICENSE +0 -0
  20. {sourcecode-1.33.25 → sourcecode-1.35.1}/SECURITY.md +0 -0
  21. {sourcecode-1.33.25 → sourcecode-1.35.1}/raw +0 -0
  22. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/adaptive_scanner.py +0 -0
  23. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/architecture_analyzer.py +0 -0
  24. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/architecture_summary.py +0 -0
  25. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/ast_extractor.py +0 -0
  26. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/cache.py +0 -0
  27. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/canonical_ir.py +0 -0
  28. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/classifier.py +0 -0
  29. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/code_notes_analyzer.py +0 -0
  30. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/confidence_analyzer.py +0 -0
  31. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/context_scorer.py +0 -0
  32. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/context_summarizer.py +0 -0
  33. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/contract_model.py +0 -0
  34. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/contract_pipeline.py +0 -0
  35. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/coverage_parser.py +0 -0
  36. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/dependency_analyzer.py +0 -0
  37. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/__init__.py +0 -0
  38. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/base.py +0 -0
  39. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/csproj_parser.py +0 -0
  40. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/dart.py +0 -0
  41. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/dotnet.py +0 -0
  42. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/elixir.py +0 -0
  43. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/go.py +0 -0
  44. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/heuristic.py +0 -0
  45. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/hybrid.py +0 -0
  46. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/java.py +0 -0
  47. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/jvm_ext.py +0 -0
  48. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/nodejs.py +0 -0
  49. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/parsers.py +0 -0
  50. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/php.py +0 -0
  51. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/project.py +0 -0
  52. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/python.py +0 -0
  53. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/ruby.py +0 -0
  54. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/rust.py +0 -0
  55. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/systems.py +0 -0
  56. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/terraform.py +0 -0
  57. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/detectors/tooling.py +0 -0
  58. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/doc_analyzer.py +0 -0
  59. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/entrypoint_classifier.py +0 -0
  60. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/env_analyzer.py +0 -0
  61. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/error_schema.py +0 -0
  62. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/file_classifier.py +0 -0
  63. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/flow_analyzer.py +0 -0
  64. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/git_analyzer.py +0 -0
  65. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/graph_analyzer.py +0 -0
  66. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/license.py +0 -0
  67. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/__init__.py +0 -0
  68. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  69. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  70. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  71. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  72. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  73. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/orchestrator.py +0 -0
  74. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/registry.py +0 -0
  75. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp/runner.py +0 -0
  76. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/mcp_nudge.py +0 -0
  77. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/metrics_analyzer.py +0 -0
  78. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/output_budget.py +0 -0
  79. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/path_filters.py +0 -0
  80. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/pr_comment_renderer.py +0 -0
  81. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/prepare_context.py +0 -0
  82. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/progress.py +0 -0
  83. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/ranking_engine.py +0 -0
  84. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/redactor.py +0 -0
  85. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/relevance_scorer.py +0 -0
  86. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/repo_classifier.py +0 -0
  87. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/runtime_classifier.py +0 -0
  88. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/scanner.py +0 -0
  89. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/schema.py +0 -0
  90. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/semantic_analyzer.py +0 -0
  91. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/serializer.py +0 -0
  92. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/summarizer.py +0 -0
  93. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/telemetry/__init__.py +0 -0
  94. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/telemetry/config.py +0 -0
  95. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/telemetry/consent.py +0 -0
  96. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/telemetry/events.py +0 -0
  97. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/telemetry/filters.py +0 -0
  98. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/telemetry/transport.py +0 -0
  99. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/tree_utils.py +0 -0
  100. {sourcecode-1.33.25 → sourcecode-1.35.1}/src/sourcecode/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.33.25
3
+ Version: 1.35.1
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.33.25-blue)
42
+ ![Version](https://img.shields.io/badge/version-1.35.1-blue)
43
43
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
44
44
 
45
45
  ---
@@ -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.33.25-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.35.1-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
7
7
 
8
8
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.33.25"
7
+ version = "1.35.1"
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.33.25"
3
+ __version__ = "1.35.1"
@@ -223,6 +223,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
223
223
  "cache",
224
224
  # RIS bootstrap
225
225
  "cold-start",
226
+ # Spring semantic audit
227
+ "spring-audit",
226
228
  }
227
229
  )
228
230
 
@@ -3697,6 +3699,186 @@ def endpoints_cmd(
3697
3699
  _nudge()
3698
3700
 
3699
3701
 
3702
+ # ── Spring Semantic Audit ─────────────────────────────────────────────────────
3703
+
3704
+ @app.command("spring-audit")
3705
+ def spring_audit_cmd(
3706
+ path: Path = typer.Argument(
3707
+ Path("."),
3708
+ help="Repository path to audit (default: current directory)",
3709
+ ),
3710
+ output_path: Optional[Path] = typer.Option(
3711
+ None, "--output", "-o",
3712
+ help="Write output to a file instead of stdout.",
3713
+ ),
3714
+ format: str = typer.Option(
3715
+ "json",
3716
+ "--format",
3717
+ "-f",
3718
+ help="Output format: json (default) or yaml.",
3719
+ show_default=True,
3720
+ ),
3721
+ copy: bool = typer.Option(
3722
+ False,
3723
+ "--copy",
3724
+ "-c",
3725
+ help="Copy output to system clipboard after a successful run.",
3726
+ ),
3727
+ scope: str = typer.Option(
3728
+ "all",
3729
+ "--scope",
3730
+ "-s",
3731
+ help="Audit scope: all (default), tx, or security.",
3732
+ show_default=True,
3733
+ ),
3734
+ min_severity: str = typer.Option(
3735
+ "low",
3736
+ "--min-severity",
3737
+ help="Minimum severity to include: critical, high, medium, or low (default).",
3738
+ show_default=True,
3739
+ ),
3740
+ ) -> None:
3741
+ """Spring semantic audit: TX anomalies (TX-001..005) + security surface (SEC-001..003).
3742
+
3743
+ \b
3744
+ Detects:
3745
+ TX-001 @Transactional on private/final method (CGLIB proxy bypass)
3746
+ TX-002 REQUIRES_NEW nested in REQUIRED call chain
3747
+ TX-003 readOnly=true boundary propagating to write operation
3748
+ TX-004 NOT_SUPPORTED/NEVER within active TX chain
3749
+ TX-005 Exception swallowing inside @Transactional
3750
+ SEC-001 Unsecured endpoint in annotation_based security model
3751
+ SEC-002 CVE-2025-41248: @PreAuthorize on inherited method from generic supertype
3752
+ SEC-003 @Transactional on @Controller/@RestController (TX in wrong layer)
3753
+
3754
+ \b
3755
+ Examples:
3756
+ sourcecode spring-audit .
3757
+ sourcecode spring-audit /path/to/repo
3758
+ sourcecode spring-audit . --scope security
3759
+ sourcecode spring-audit . --min-severity high
3760
+ sourcecode spring-audit . --output audit.json
3761
+ """
3762
+ import json as _json
3763
+
3764
+ from sourcecode.repository_ir import find_java_files
3765
+ from sourcecode.canonical_ir import build_canonical_ir
3766
+ from sourcecode.spring_findings import SpringAuditResult, SpringFinding
3767
+ from sourcecode.spring_tx_analyzer import run_tx_audit
3768
+ from sourcecode.spring_security_audit import run_security_audit
3769
+ from sourcecode.spring_model import SpringSemanticModel
3770
+
3771
+ target = path.resolve()
3772
+ if not target.exists() or not target.is_dir():
3773
+ _emit_error_json(
3774
+ INVALID_INPUT_CODE,
3775
+ f"'{target}' is not a valid directory.",
3776
+ path=str(target),
3777
+ hint="Pass an existing repository directory.",
3778
+ expected="A directory path.",
3779
+ )
3780
+ raise typer.Exit(code=1)
3781
+
3782
+ if scope not in ("all", "tx", "security"):
3783
+ _emit_error_json(
3784
+ INVALID_INPUT_CODE,
3785
+ f"Invalid scope '{scope}'.",
3786
+ hint="scope must be one of: all, tx, security.",
3787
+ expected="all | tx | security",
3788
+ )
3789
+ raise typer.Exit(code=1)
3790
+
3791
+ if min_severity not in ("critical", "high", "medium", "low"):
3792
+ _emit_error_json(
3793
+ INVALID_INPUT_CODE,
3794
+ f"Invalid min-severity '{min_severity}'.",
3795
+ hint="min-severity must be one of: critical, high, medium, low.",
3796
+ expected="critical | high | medium | low",
3797
+ )
3798
+ raise typer.Exit(code=1)
3799
+
3800
+ file_list = find_java_files(target)
3801
+ if not file_list:
3802
+ data = SpringAuditResult(
3803
+ spring_detected=False,
3804
+ scope=scope,
3805
+ limitations=["No Java files found in repository — Spring audit requires Java source."],
3806
+ metadata={"java_files_found": 0},
3807
+ ).finalize().to_dict()
3808
+ output = _serialize_dict(data, format)
3809
+ if output_path is not None:
3810
+ output_path.write_text(output, encoding="utf-8")
3811
+ typer.echo("Spring audit written to " + str(output_path), err=True)
3812
+ else:
3813
+ sys.stdout.buffer.write(output.encode("utf-8"))
3814
+ sys.stdout.buffer.write(b"\n")
3815
+ sys.stdout.buffer.flush()
3816
+ return
3817
+
3818
+ cir = build_canonical_ir(file_list, target)
3819
+ _model = SpringSemanticModel.build(cir)
3820
+
3821
+ results: list[SpringAuditResult] = []
3822
+ if scope in ("all", "tx"):
3823
+ results.append(run_tx_audit(cir, root=target, min_severity=min_severity, model=_model))
3824
+ if scope in ("all", "security"):
3825
+ results.append(run_security_audit(cir, root=target, min_severity=min_severity, model=_model))
3826
+
3827
+ if len(results) == 1:
3828
+ combined = results[0]
3829
+ else:
3830
+ all_findings: list[SpringFinding] = []
3831
+ all_limitations: list[str] = []
3832
+ merged_meta: dict = {}
3833
+ for r in results:
3834
+ all_findings.extend(r.findings)
3835
+ all_limitations.extend(r.limitations)
3836
+ merged_meta.update(r.metadata)
3837
+ combined = SpringAuditResult(
3838
+ repo_id=results[0].repo_id,
3839
+ spring_detected=any(r.spring_detected for r in results),
3840
+ scope="all",
3841
+ findings=all_findings,
3842
+ limitations=all_limitations,
3843
+ metadata=merged_meta,
3844
+ ).finalize()
3845
+
3846
+ # Populate git_head from repo HEAD — non-fatal.
3847
+ try:
3848
+ import subprocess as _sub_sa
3849
+ _sha_r = _sub_sa.run(
3850
+ ["git", "-C", str(target), "rev-parse", "--short", "HEAD"],
3851
+ capture_output=True, text=True, timeout=3,
3852
+ )
3853
+ if _sha_r.returncode == 0:
3854
+ combined.git_head = _sha_r.stdout.strip()
3855
+ except Exception:
3856
+ pass
3857
+
3858
+ data = combined.to_dict()
3859
+
3860
+ # Non-fatal RIS side-effect — persist summary only (not full findings).
3861
+ try:
3862
+ from sourcecode.ris import update_ris_spring_audit as _ris_sa
3863
+ _ris_sa(target, data)
3864
+ except Exception:
3865
+ pass
3866
+
3867
+ output = _serialize_dict(data, format)
3868
+
3869
+ if output_path is not None:
3870
+ output_path.write_text(output, encoding="utf-8")
3871
+ total = combined.summary.get("total_findings", 0)
3872
+ typer.echo(f"Spring audit written to {output_path} ({total} findings)", err=True)
3873
+ else:
3874
+ sys.stdout.buffer.write(output.encode("utf-8"))
3875
+ sys.stdout.buffer.write(b"\n")
3876
+ sys.stdout.buffer.flush()
3877
+ if copy:
3878
+ if _copy_to_clipboard(output):
3879
+ typer.echo("✓ copied to clipboard", err=True)
3880
+
3881
+
3700
3882
  # ── Enterprise Workflow Commands ──────────────────────────────────────────────
3701
3883
  #
3702
3884
  # These are the five canonical enterprise workflows. Each is a thin wrapper
@@ -614,6 +614,41 @@ def get_endpoints(repo_path: str = ".") -> dict:
614
614
  )
615
615
 
616
616
 
617
+ @mcp.tool()
618
+ def get_spring_audit(repo_path: str = ".", scope: str = "all") -> dict:
619
+ """Spring semantic audit: TX anomalies + security surface findings. JAVA/SPRING ONLY.
620
+
621
+ Do NOT call this on non-Java repositories — it will return spring_detected=false.
622
+
623
+ Maps to: sourcecode spring-audit <repo_path> --scope <scope>
624
+ Returns: SpringAuditResult with schema_version, spring_detected, scope, summary,
625
+ findings list (id, pattern_id, category, severity, confidence, title,
626
+ symbol, source_file, evidence, explanation, fix_hint), limitations, metadata.
627
+ Patterns: TX-001..005 (transaction anomalies), SEC-001..003 (security surface).
628
+ scope: "all" (default) | "tx" | "security".
629
+ repo_path: absolute path to the Java repository (default: current working directory).
630
+ """
631
+ _raw = repo_path
632
+ try:
633
+ if not isinstance(repo_path, str):
634
+ return _err("repo_path must be a string", "INVALID_ARGUMENT")
635
+ if scope not in ("all", "tx", "security"):
636
+ return _err(
637
+ f"Invalid scope '{scope}' — must be one of: all, tx, security",
638
+ "INVALID_ARGUMENT",
639
+ )
640
+ repo_path = _normalize_repo_path(repo_path)
641
+ _path_err = _check_repo_path(repo_path)
642
+ if _path_err is not None:
643
+ return _path_err
644
+ return _execute(["spring-audit", repo_path, "--scope", scope])
645
+ except Exception as exc:
646
+ return _err(
647
+ f"Internal error: {type(exc).__name__}: {exc} — repo_path recibido: {_raw}",
648
+ "INTERNAL_ERROR",
649
+ )
650
+
651
+
617
652
  @mcp.tool()
618
653
  def get_module_context(repo_path: str = ".", module: str = "") -> dict:
619
654
  """Compact analysis of a specific module or subdirectory within a repository.
@@ -238,6 +238,14 @@ _LOMBOK_CTOR_ANNOTATIONS: frozenset[str] = frozenset({
238
238
  "@AllArgsConstructor", # injects all non-static fields
239
239
  })
240
240
 
241
+ # Transaction annotations whose args must be captured for semantic analysis.
242
+ _TX_ANNOTATIONS: frozenset[str] = frozenset({"@Transactional"})
243
+
244
+ # Combined set used in _extract_symbols annotation-value capture.
245
+ _CAPTURE_ANN_ARGS: frozenset[str] = (
246
+ _ENDPOINT_ANNOTATIONS | _PERMISSION_ANNOTATIONS | _PATH_ANNOTATIONS | _TX_ANNOTATIONS
247
+ )
248
+
241
249
  _JAVA_ROLE_MAP: dict[str, str] = {
242
250
  # Spring MVC / Spring Boot
243
251
  "@RestController": "controller",
@@ -563,7 +571,7 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
563
571
  ann_args = ann_m.group(2) or ""
564
572
  if ann not in pending_anns:
565
573
  pending_anns.append(ann)
566
- if ann_args and (ann in _ENDPOINT_ANNOTATIONS or ann in _PERMISSION_ANNOTATIONS or ann in _PATH_ANNOTATIONS):
574
+ if ann_args and ann in _CAPTURE_ANN_ARGS:
567
575
  # P1 fix: attempt to resolve constant expressions before storing.
568
576
  # Transforms '"/" + SECTION_KEY' → '"/category"' when constant
569
577
  # is defined in this file. Falls back to original if unresolvable.
@@ -2234,6 +2242,9 @@ def _assemble(
2234
2242
  "role": spring_role_map.get(s.symbol, "other"),
2235
2243
  "in_degree": in_deg.get(s.symbol, 0),
2236
2244
  "out_degree": out_deg.get(s.symbol, 0),
2245
+ "annotations": list(s.annotations),
2246
+ "annotation_values": dict(s.annotation_values),
2247
+ "modifiers": list(s.modifiers),
2237
2248
  }
2238
2249
  for s in sorted_syms
2239
2250
  ]
@@ -275,6 +275,53 @@ def maybe_update_ris(repo_root: Path, core_dict: dict, git_head: str) -> None:
275
275
  pass
276
276
 
277
277
 
278
+ def update_ris_spring_audit(repo_root: Path, audit_result: dict) -> None:
279
+ """Persist spring-audit summary into the RIS metadata section.
280
+
281
+ Stores summary-only snapshot (no full findings) in metadata["spring_audit"].
282
+ Called from spring_audit_cmd after a successful run. Never raises.
283
+ """
284
+ try:
285
+ if not isinstance(audit_result, dict):
286
+ return
287
+ summary = audit_result.get("summary") or {}
288
+ existing = load_ris(repo_root)
289
+ if existing is None:
290
+ from sourcecode.cache import repo_id as _repo_id_fn
291
+ now = _now_iso()
292
+ existing = RepositoryIntelligenceSnapshot(
293
+ repo_id=_repo_id_fn(repo_root),
294
+ created_at=now,
295
+ last_updated_at=now,
296
+ git_head="",
297
+ version=RIS_SCHEMA_VERSION,
298
+ structural_map={},
299
+ api_surface={},
300
+ dependency_graph={},
301
+ compact_summary={},
302
+ agent_index={},
303
+ git_context_snapshot={},
304
+ metadata={"snapshot_source": "existing_snapshot_system", "confidence": 0.0, "partial": True},
305
+ )
306
+
307
+ spring_audit_cache = {
308
+ "total_findings": summary.get("total_findings", 0),
309
+ "by_severity": summary.get("by_severity", {}),
310
+ "by_category": summary.get("by_category", {}),
311
+ "confidence_level": summary.get("confidence_level", ""),
312
+ "scope": audit_result.get("scope", "all"),
313
+ "spring_detected": audit_result.get("spring_detected", False),
314
+ "last_run_at": audit_result.get("generated_at", _now_iso()),
315
+ }
316
+ updated_meta = {**existing.metadata, "spring_audit": spring_audit_cache}
317
+ updated = RepositoryIntelligenceSnapshot(
318
+ **{**existing.__dict__, "metadata": updated_meta, "last_updated_at": _now_iso()}
319
+ )
320
+ save_ris(repo_root, updated)
321
+ except Exception:
322
+ pass
323
+
324
+
278
325
  def update_ris_api_surface(repo_root: Path, endpoints_data: dict) -> None:
279
326
  """Update the api_surface section from an ``endpoints`` command output.
280
327
 
@@ -0,0 +1,130 @@
1
+ """spring_findings.py — Shared finding schema for Spring semantic audit.
2
+
3
+ SpringFinding is the canonical output unit.
4
+ SpringAuditResult is the top-level envelope returned by CLI and MCP.
5
+
6
+ IDs are deterministic: same symbol + pattern → same ID across runs.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Optional
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # SpringFinding
19
+ # ---------------------------------------------------------------------------
20
+
21
+ @dataclass
22
+ class SpringFinding:
23
+ """Single audit finding — one anomaly in one symbol."""
24
+
25
+ id: str # deterministic: "{pattern_id}-{symbol_hash[:12]}"
26
+ pattern_id: str # "TX-001", "SEC-001", ...
27
+ category: str # "tx" | "security"
28
+ severity: str # "critical" | "high" | "medium" | "low"
29
+ confidence: str # "high" | "medium" | "low"
30
+ title: str
31
+ symbol: str # FQN of affected symbol
32
+ source_file: str
33
+ evidence: dict # pattern-specific structured evidence
34
+ explanation: str # 2-3 sentences: what + why it matters
35
+ fix_hint: str # one actionable sentence
36
+ limitations: list[str] = field(default_factory=list)
37
+ related_symbols: list[str] = field(default_factory=list)
38
+
39
+ @staticmethod
40
+ def make_id(pattern_id: str, symbol: str) -> str:
41
+ h = hashlib.sha256(f"{pattern_id}:{symbol}".encode()).hexdigest()[:12]
42
+ return f"{pattern_id}-{h}"
43
+
44
+ def to_dict(self) -> dict:
45
+ d: dict = {
46
+ "id": self.id,
47
+ "pattern_id": self.pattern_id,
48
+ "category": self.category,
49
+ "severity": self.severity,
50
+ "confidence": self.confidence,
51
+ "title": self.title,
52
+ "symbol": self.symbol,
53
+ "source_file": self.source_file,
54
+ "evidence": self.evidence,
55
+ "explanation": self.explanation,
56
+ "fix_hint": self.fix_hint,
57
+ }
58
+ if self.limitations:
59
+ d["limitations"] = self.limitations
60
+ if self.related_symbols:
61
+ d["related_symbols"] = self.related_symbols
62
+ return d
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # SpringAuditResult
67
+ # ---------------------------------------------------------------------------
68
+
69
+ @dataclass
70
+ class SpringAuditResult:
71
+ """Top-level envelope returned by spring-audit command and MCP tool."""
72
+
73
+ schema_version: str = "1.0"
74
+ repo_id: str = ""
75
+ git_head: str = ""
76
+ generated_at: str = ""
77
+ spring_detected: bool = False
78
+ scope: str = "all" # "all" | "tx" | "security"
79
+ findings: list[SpringFinding] = field(default_factory=list)
80
+ limitations: list[str] = field(default_factory=list)
81
+ metadata: dict = field(default_factory=dict)
82
+
83
+ # Populated by finalize()
84
+ summary: dict = field(default_factory=dict)
85
+
86
+ def finalize(self) -> "SpringAuditResult":
87
+ """Compute summary stats. Call after all findings are added."""
88
+ if not self.generated_at:
89
+ self.generated_at = datetime.now(timezone.utc).isoformat()
90
+
91
+ by_severity: dict[str, int] = {
92
+ "critical": 0, "high": 0, "medium": 0, "low": 0
93
+ }
94
+ by_category: dict[str, int] = {}
95
+ for f in self.findings:
96
+ by_severity[f.severity] = by_severity.get(f.severity, 0) + 1
97
+ by_category[f.category] = by_category.get(f.category, 0) + 1
98
+
99
+ # Overall confidence: lowest confidence of any high/critical finding
100
+ high_findings = [f for f in self.findings if f.severity in ("high", "critical")]
101
+ if not high_findings:
102
+ conf_level = "high"
103
+ elif all(f.confidence == "high" for f in high_findings):
104
+ conf_level = "high"
105
+ elif any(f.confidence == "low" for f in high_findings):
106
+ conf_level = "low"
107
+ else:
108
+ conf_level = "medium"
109
+
110
+ self.summary = {
111
+ "total_findings": len(self.findings),
112
+ "by_severity": by_severity,
113
+ "by_category": by_category,
114
+ "confidence_level": conf_level,
115
+ }
116
+ return self
117
+
118
+ def to_dict(self) -> dict:
119
+ return {
120
+ "schema_version": self.schema_version,
121
+ "repo_id": self.repo_id,
122
+ "git_head": self.git_head,
123
+ "generated_at": self.generated_at,
124
+ "spring_detected": self.spring_detected,
125
+ "scope": self.scope,
126
+ "summary": self.summary,
127
+ "findings": [f.to_dict() for f in self.findings],
128
+ "limitations": self.limitations,
129
+ "metadata": self.metadata,
130
+ }