sourcecode 1.56.0__tar.gz → 1.58.0__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 (121) hide show
  1. {sourcecode-1.56.0 → sourcecode-1.58.0}/CHANGELOG.md +34 -0
  2. {sourcecode-1.56.0 → sourcecode-1.58.0}/PKG-INFO +9 -3
  3. {sourcecode-1.56.0 → sourcecode-1.58.0}/README.md +8 -2
  4. {sourcecode-1.56.0 → sourcecode-1.58.0}/pyproject.toml +1 -1
  5. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/__init__.py +1 -1
  6. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/license.py +15 -2
  7. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_impact.py +60 -1
  8. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/validation_surface.py +258 -12
  9. {sourcecode-1.56.0 → sourcecode-1.58.0}/.github/workflows/build-windows.yml +0 -0
  10. {sourcecode-1.56.0 → sourcecode-1.58.0}/.gitignore +0 -0
  11. {sourcecode-1.56.0 → sourcecode-1.58.0}/.ruff.toml +0 -0
  12. {sourcecode-1.56.0 → sourcecode-1.58.0}/CONTRIBUTING.md +0 -0
  13. {sourcecode-1.56.0 → sourcecode-1.58.0}/LICENSE +0 -0
  14. {sourcecode-1.56.0 → sourcecode-1.58.0}/SECURITY.md +0 -0
  15. {sourcecode-1.56.0 → sourcecode-1.58.0}/raw +0 -0
  16. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/adaptive_scanner.py +0 -0
  17. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/architecture_analyzer.py +0 -0
  18. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/architecture_summary.py +0 -0
  19. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/ast_extractor.py +0 -0
  20. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/cache.py +0 -0
  21. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/canonical_ir.py +0 -0
  22. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/cir_graphs.py +0 -0
  23. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/classifier.py +0 -0
  24. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/cli.py +0 -0
  25. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  26. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/confidence_analyzer.py +0 -0
  27. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/context_scorer.py +0 -0
  28. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/context_summarizer.py +0 -0
  29. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/contract_model.py +0 -0
  30. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/contract_pipeline.py +0 -0
  31. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/coverage_parser.py +0 -0
  32. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/dependency_analyzer.py +0 -0
  33. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/__init__.py +0 -0
  34. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/base.py +0 -0
  35. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  36. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/dart.py +0 -0
  37. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/dotnet.py +0 -0
  38. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/elixir.py +0 -0
  39. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/go.py +0 -0
  40. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/heuristic.py +0 -0
  41. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/hybrid.py +0 -0
  42. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/java.py +0 -0
  43. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  44. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/nodejs.py +0 -0
  45. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/parsers.py +0 -0
  46. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/php.py +0 -0
  47. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/project.py +0 -0
  48. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/python.py +0 -0
  49. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/ruby.py +0 -0
  50. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/rust.py +0 -0
  51. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/systems.py +0 -0
  52. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/terraform.py +0 -0
  53. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/detectors/tooling.py +0 -0
  54. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/doc_analyzer.py +0 -0
  55. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  56. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/env_analyzer.py +0 -0
  57. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/error_schema.py +0 -0
  58. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/explain.py +0 -0
  59. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/file_chunker.py +0 -0
  60. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/file_classifier.py +0 -0
  61. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/flow_analyzer.py +0 -0
  62. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/format_contract.py +0 -0
  63. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/fqn_utils.py +0 -0
  64. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/git_analyzer.py +0 -0
  65. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/graph_analyzer.py +0 -0
  66. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/integration_detector.py +0 -0
  67. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/__init__.py +0 -0
  68. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  69. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  70. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  71. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  72. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  73. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  74. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/registry.py +0 -0
  75. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/runner.py +0 -0
  76. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp/server.py +0 -0
  77. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/mcp_nudge.py +0 -0
  78. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/metrics_analyzer.py +0 -0
  79. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/migrate_check.py +0 -0
  80. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/openapi_surface.py +0 -0
  81. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/output_budget.py +0 -0
  82. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/path_filters.py +0 -0
  83. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  84. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/pr_impact.py +0 -0
  85. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/prepare_context.py +0 -0
  86. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/progress.py +0 -0
  87. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/ranking_engine.py +0 -0
  88. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/redactor.py +0 -0
  89. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/relevance_scorer.py +0 -0
  90. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/rename_refactor.py +0 -0
  91. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/repo_classifier.py +0 -0
  92. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/repository_ir.py +0 -0
  93. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/ris.py +0 -0
  94. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/runtime_classifier.py +0 -0
  95. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/scanner.py +0 -0
  96. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/schema.py +0 -0
  97. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/security_config.py +0 -0
  98. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/semantic_analyzer.py +0 -0
  99. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/serializer.py +0 -0
  100. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_event_topology.py +0 -0
  101. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_findings.py +0 -0
  102. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_model.py +0 -0
  103. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_security_audit.py +0 -0
  104. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_semantic.py +0 -0
  105. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  106. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/summarizer.py +0 -0
  107. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/telemetry/__init__.py +0 -0
  108. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/telemetry/config.py +0 -0
  109. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/telemetry/consent.py +0 -0
  110. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/telemetry/events.py +0 -0
  111. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/telemetry/filters.py +0 -0
  112. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/telemetry/transport.py +0 -0
  113. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/tree_utils.py +0 -0
  114. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/version_check.py +0 -0
  115. {sourcecode-1.56.0 → sourcecode-1.58.0}/src/sourcecode/workspace.py +0 -0
  116. {sourcecode-1.56.0 → sourcecode-1.58.0}/supabase/functions/README.md +0 -0
  117. {sourcecode-1.56.0 → sourcecode-1.58.0}/supabase/functions/get-license/index.ts +0 -0
  118. {sourcecode-1.56.0 → sourcecode-1.58.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  119. {sourcecode-1.56.0 → sourcecode-1.58.0}/supabase/functions/telemetry/index.ts +0 -0
  120. {sourcecode-1.56.0 → sourcecode-1.58.0}/supabase/sql/license_event_ordering.sql +0 -0
  121. {sourcecode-1.56.0 → sourcecode-1.58.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.58.0] — 2026-06-19
4
+
5
+ ### Added
6
+ - **`validation` recovers DTO constraints from source when no OpenAPI spec is
7
+ present.** Previously `validated_fields` read `0` for any repo without a spec
8
+ on disk, even when DTOs carried bean-validation annotations. The command now
9
+ locates each body-shaped handler's `@Valid`/`@Validated` parameter, resolves
10
+ the DTO in-repo, and reads its field constraints (following one in-repo
11
+ supertype so inherited constraints are kept). Recovered routes are tagged
12
+ `source="source-derived"` at medium confidence with a `binding` hint
13
+ (body vs form). The core symbol/endpoint extractor is untouched, so the
14
+ OpenAPI-driven path is unchanged. Verified: spring-petclinic 0 → 13 validated
15
+ fields; repos with no `@Valid` usage correctly stay at 0 (no false positives).
16
+
17
+ ### Changed
18
+ - **`impact-chain` output now matches the `impact` command's risk schema.**
19
+ `risk_score`, `confidence_score`, `confidence_level`, and `explanation` are
20
+ emitted at the top level (previously `risk_score` lived only in `metadata`
21
+ and there was no `explanation`). The legacy `confidence` string is retained
22
+ and equals `confidence_level`. Risk/confidence formulas are unchanged — only
23
+ the output contract was aligned so agents parse both commands with one shape.
24
+
25
+ ## [1.57.0] — 2026-06-19
26
+
27
+ ### Changed
28
+ - **TEMPORARY: Pro unlocked for everyone (early-adoption phase).** A new
29
+ `_PRO_UNLOCK_ALL` switch in `license.py` (env `SOURCECODE_PRO_UNLOCK`, default on)
30
+ floors `is_pro` to `True` at init, so anyone who installs gets Pro from the start —
31
+ removing onboarding friction to maximize adoption. The gate *logic*
32
+ (`require_feature` / `require_repo_or_pro` / `require_pro`, size limits, upgrade
33
+ prompts, telemetry) is left fully intact; this only raises the entitlement floor.
34
+ Real Pro license activation still works. To resume the paywall: set
35
+ `_PRO_UNLOCK_ALL = False` (or `SOURCECODE_PRO_UNLOCK=0`) — no other change needed.
36
+
3
37
  ## [1.56.0] — 2026-06-19
4
38
 
5
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.56.0
3
+ Version: 1.58.0
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
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  **Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
42
42
 
43
- ![Version](https://img.shields.io/badge/version-1.56.0-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.58.0-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.9%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.53.0
117
+ # sourcecode 1.58.0
118
118
  ```
119
119
 
120
120
  ---
@@ -150,6 +150,8 @@ sourcecode impact-chain OrderPlacedEvent /path/to/repo --type events
150
150
  sourcecode endpoints /path/to/repo
151
151
 
152
152
  # Request-body validation per endpoint: constraints + custom validators (free)
153
+ # Recovers constraints from the OpenAPI spec, or directly from Java DTO
154
+ # bean-validation annotations when no spec is present.
153
155
  sourcecode validation /path/to/repo
154
156
 
155
157
  # Onboard to an unfamiliar codebase
@@ -295,6 +297,10 @@ Specifically:
295
297
 
296
298
  ## Pricing
297
299
 
300
+ > **🎉 Early-adoption: Pro is currently unlocked for everyone.** During this phase
301
+ > every install runs with full Pro entitlements — no size gate, no key required. The
302
+ > tiers below describe the model the paywall will return to later.
303
+
298
304
  Two tiers. **Gating is by repo size and automation — never by command.** Every
299
305
  command runs at full power on Free for small and mid-size repos. You upgrade
300
306
  when the work gets bigger or automated.
@@ -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.56.0-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.58.0-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.9%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.53.0
79
+ # sourcecode 1.58.0
80
80
  ```
81
81
 
82
82
  ---
@@ -112,6 +112,8 @@ sourcecode impact-chain OrderPlacedEvent /path/to/repo --type events
112
112
  sourcecode endpoints /path/to/repo
113
113
 
114
114
  # Request-body validation per endpoint: constraints + custom validators (free)
115
+ # Recovers constraints from the OpenAPI spec, or directly from Java DTO
116
+ # bean-validation annotations when no spec is present.
115
117
  sourcecode validation /path/to/repo
116
118
 
117
119
  # Onboard to an unfamiliar codebase
@@ -257,6 +259,10 @@ Specifically:
257
259
 
258
260
  ## Pricing
259
261
 
262
+ > **🎉 Early-adoption: Pro is currently unlocked for everyone.** During this phase
263
+ > every install runs with full Pro entitlements — no size gate, no key required. The
264
+ > tiers below describe the model the paywall will return to later.
265
+
260
266
  Two tiers. **Gating is by repo size and automation — never by command.** Every
261
267
  command runs at full power on Free for small and mid-size repos. You upgrade
262
268
  when the work gets bigger or automated.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.56.0"
7
+ version = "1.58.0"
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.56.0"
3
+ __version__ = "1.58.0"
@@ -177,6 +177,17 @@ _FEATURE_INFO: dict[str, dict[str, str]] = {
177
177
  _license_data: Optional[dict] = None
178
178
  is_pro: bool = False
179
179
 
180
+ # ---------------------------------------------------------------------------
181
+ # TEMPORARY PRO UNLOCK (early-adoption phase)
182
+ # ---------------------------------------------------------------------------
183
+ # Floors `is_pro` to True at init so every gate passes — anyone who installs
184
+ # gets Pro from the start (remove onboarding friction, maximize adoption).
185
+ # The gate LOGIC below (require_feature / require_repo_or_pro / require_pro) is
186
+ # left fully intact; this only raises the entitlement floor. To resume the
187
+ # paywall: set _PRO_UNLOCK_ALL = False (or SOURCECODE_PRO_UNLOCK=0), no other
188
+ # change needed. Tests of the gated paths set SOURCECODE_PRO_UNLOCK=0.
189
+ _PRO_UNLOCK_ALL = os.environ.get("SOURCECODE_PRO_UNLOCK", "1") != "0"
190
+
180
191
 
181
192
  def _secure_dir() -> None:
182
193
  """Create ~/.sourcecode owner-only (0700). Holds the license secret.
@@ -323,7 +334,7 @@ def _maybe_revalidate() -> None:
323
334
 
324
335
  if not result.get("valid"):
325
336
  _license_data = None
326
- is_pro = False
337
+ is_pro = _PRO_UNLOCK_ALL # TEMPORARY: keep Pro floor during unlock
327
338
  try:
328
339
  if _LICENSE_FILE.exists():
329
340
  _LICENSE_FILE.unlink()
@@ -334,7 +345,7 @@ def _maybe_revalidate() -> None:
334
345
  _license_data["plan"] = result.get("plan", "pro")
335
346
  _license_data["features"] = result.get("features", [])
336
347
  _license_data["validated_at"] = datetime.now(timezone.utc).isoformat()
337
- is_pro = _license_data.get("plan") == "pro"
348
+ is_pro = _PRO_UNLOCK_ALL or _license_data.get("plan") == "pro"
338
349
  try:
339
350
  _write_license_file(_license_data)
340
351
  except Exception:
@@ -349,6 +360,8 @@ def _init() -> None:
349
360
  and _license_data.get("plan") == "pro"
350
361
  and _license_data.get("status", "active") != "inactive"
351
362
  )
363
+ if _PRO_UNLOCK_ALL:
364
+ is_pro = True # TEMPORARY: early-adoption Pro unlock (see _PRO_UNLOCK_ALL)
352
365
 
353
366
 
354
367
  _init()
@@ -70,6 +70,17 @@ _SEVERITY_WEIGHT: dict[str, float] = {
70
70
  "low": 1.0,
71
71
  }
72
72
 
73
+ # Maps the categorical confidence to a numeric score so impact-chain output
74
+ # carries the same `confidence_score`/`confidence_level` pair as the `impact`
75
+ # command. Deterministic — mirrors impact's confidence banding (high≥0.75,
76
+ # medium≥0.45).
77
+ _CONFIDENCE_SCORE: dict[str, float] = {
78
+ "high": 0.9,
79
+ "medium": 0.6,
80
+ "low": 0.3,
81
+ "unknown": 0.0,
82
+ }
83
+
73
84
 
74
85
  # ---------------------------------------------------------------------------
75
86
  # Output model
@@ -117,10 +128,16 @@ class ImpactChainResult:
117
128
  impact_findings: list[dict] = field(default_factory=list) # SpringFinding.to_dict() filtered
118
129
  analysis_warnings: list[str] = field(default_factory=list)
119
130
  risk_level: str = "unknown" # "critical" | "high" | "medium" | "low" | "unknown"
120
- confidence: str = "high" # "high" | "medium" | "low"
131
+ risk_score: float = 0.0 # numeric score behind risk_level (parity with `impact`)
132
+ confidence: str = "high" # "high" | "medium" | "low" (legacy field, kept)
133
+ explanation: str = "" # human-readable rationale (parity with `impact`)
121
134
  metadata: dict = field(default_factory=dict)
122
135
 
123
136
  def to_dict(self) -> dict:
137
+ # `confidence_level`/`confidence_score` mirror the `impact` command's schema
138
+ # so AI agents parse both commands with one shape. `confidence` (string) is
139
+ # retained for backward compatibility — it equals `confidence_level`.
140
+ confidence_level = self.confidence
124
141
  d: dict = {
125
142
  "schema_version": self.schema_version,
126
143
  "symbol": self.symbol,
@@ -134,7 +151,11 @@ class ImpactChainResult:
134
151
  "impact_findings": self.impact_findings,
135
152
  "analysis_warnings": self.analysis_warnings,
136
153
  "risk_level": self.risk_level,
154
+ "risk_score": self.risk_score,
137
155
  "confidence": self.confidence,
156
+ "confidence_level": confidence_level,
157
+ "confidence_score": _CONFIDENCE_SCORE.get(confidence_level, 0.0),
158
+ "explanation": self.explanation,
138
159
  "metadata": self.metadata,
139
160
  }
140
161
  return d
@@ -680,6 +701,33 @@ def _compute_risk(
680
701
  return level, round(total, 2)
681
702
 
682
703
 
704
+ def _build_chain_explanation(
705
+ risk_level: str,
706
+ direct_callers: int,
707
+ indirect_callers: int,
708
+ endpoints_affected: int,
709
+ findings: int,
710
+ confidence: str,
711
+ ) -> str:
712
+ """Human-readable rationale, phrased to match the `impact` command's
713
+ explanation so both surfaces read consistently."""
714
+ if not direct_callers and not indirect_callers and not endpoints_affected:
715
+ return "No callers or endpoints found in the impact chain. Low-risk isolated change."
716
+ parts: list[str] = []
717
+ if direct_callers:
718
+ parts.append(f"{direct_callers} direct caller{'s' if direct_callers != 1 else ''}")
719
+ if indirect_callers:
720
+ parts.append(f"{indirect_callers} indirect caller{'s' if indirect_callers != 1 else ''}")
721
+ if endpoints_affected:
722
+ parts.append(f"{endpoints_affected} endpoint{'s' if endpoints_affected != 1 else ''} exposed")
723
+ if findings:
724
+ parts.append(f"{findings} TX/SEC finding{'s' if findings != 1 else ''} in chain")
725
+ text = f"Risk={risk_level.upper()}: {'; '.join(parts)}."
726
+ if confidence != "high":
727
+ text += f" (confidence={confidence}: IR may be incomplete)"
728
+ return text
729
+
730
+
683
731
  # ---------------------------------------------------------------------------
684
732
  # Security surface aggregation
685
733
  # ---------------------------------------------------------------------------
@@ -762,6 +810,7 @@ class ImpactOrchestrator:
762
810
  analysis_warnings=warnings or [f"Symbol '{symbol}' not found in CIR."],
763
811
  risk_level="unknown",
764
812
  confidence="low",
813
+ explanation=f"Symbol {symbol!r} not found in the IR.",
765
814
  metadata={"analysis_depth": depth},
766
815
  )
767
816
 
@@ -1041,7 +1090,16 @@ class ImpactOrchestrator:
1041
1090
  impact_findings=impact_findings,
1042
1091
  analysis_warnings=warnings,
1043
1092
  risk_level=risk_level,
1093
+ risk_score=risk_score,
1044
1094
  confidence=confidence,
1095
+ explanation=_build_chain_explanation(
1096
+ risk_level,
1097
+ len(direct_callers),
1098
+ len(indirect_callers),
1099
+ len(endpoints_affected),
1100
+ len(impact_findings),
1101
+ confidence,
1102
+ ),
1045
1103
  metadata={
1046
1104
  "analysis_depth": depth,
1047
1105
  "callers_total": len(direct_callers) + len(indirect_callers),
@@ -1132,4 +1190,5 @@ def run_impact_chain(
1132
1190
  analysis_warnings=[f"Internal error: {type(exc).__name__}: {exc}"],
1133
1191
  risk_level="unknown",
1134
1192
  confidence="low",
1193
+ explanation=f"Internal error during analysis: {type(exc).__name__}.",
1135
1194
  )
@@ -180,6 +180,226 @@ def discover_custom_validators(root: Path) -> "dict[str, CustomConstraint]":
180
180
  return catalog
181
181
 
182
182
 
183
+ # ---------------------------------------------------------------------------
184
+ # Source-derived constraints (no OpenAPI spec)
185
+ #
186
+ # When a repo ships no OpenAPI spec, declarative DTO constraints still live in
187
+ # the Java source as bean-validation annotations. We recover them directly:
188
+ # locate the handler's validated body parameter (@Valid/@Validated), resolve
189
+ # that DTO class in-repo, and read its fields' constraint annotations. This is
190
+ # pure extraction — it never fabricates constraints, and it is reported with
191
+ # source="source-derived" + a lower confidence than spec-carried constraints.
192
+ # ---------------------------------------------------------------------------
193
+
194
+ # jakarta/javax bean-validation built-in constraints. @Valid marks nested
195
+ # validation, which still means "this field is validated".
196
+ _BEAN_CONSTRAINTS = frozenset({
197
+ "NotNull", "NotEmpty", "NotBlank", "Null", "AssertTrue", "AssertFalse",
198
+ "Min", "Max", "DecimalMin", "DecimalMax", "Digits", "Positive",
199
+ "PositiveOrZero", "Negative", "NegativeOrZero", "Size", "Pattern", "Email",
200
+ "Past", "PastOrPresent", "Future", "FutureOrPresent", "Valid",
201
+ })
202
+ _VALIDATE_MARKERS = frozenset({"Valid", "Validated"})
203
+ # A class-typed identifier (starts uppercase) — heuristic for "a DTO type".
204
+ _CLASS_TYPE_RE = re.compile(r"^[A-Z][\w]*$")
205
+ _ANN_TOKEN_RE = re.compile(r"@(\w+)\s*(?:\(([^)]*)\))?")
206
+ _FIELD_DECL_RE = re.compile(
207
+ r"^\s*(?:private|protected|public)\s+"
208
+ r"(?:final\s+|static\s+|transient\s+|volatile\s+)*"
209
+ r"[\w.$<>\[\], ]+?\s+(\w+)\s*[;=]"
210
+ )
211
+ _CLASS_EXTENDS_RE = re.compile(r"\bclass\s+\w+\s+extends\s+(\w+)")
212
+
213
+
214
+ def _index_repo_classes(root: Path) -> "dict[str, Path]":
215
+ """Map a class's simple name → its source file (first non-test match)."""
216
+ index: "dict[str, Path]" = {}
217
+ count = 0
218
+ for jf in root.rglob("*.java"):
219
+ rel = str(jf).replace("\\", "/")
220
+ if is_test_path(rel) or "/target/" in rel:
221
+ continue
222
+ count += 1
223
+ if count > _SCAN_CAP:
224
+ break
225
+ index.setdefault(jf.stem, jf)
226
+ return index
227
+
228
+
229
+ def _balanced_parens(src: str, open_idx: int) -> "Optional[str]":
230
+ """Given the index of a '(', return the inner text up to its matching ')'."""
231
+ depth = 0
232
+ for i in range(open_idx, len(src)):
233
+ c = src[i]
234
+ if c == "(":
235
+ depth += 1
236
+ elif c == ")":
237
+ depth -= 1
238
+ if depth == 0:
239
+ return src[open_idx + 1:i]
240
+ return None
241
+
242
+
243
+ def _split_top_level(params: str) -> "list[str]":
244
+ """Split a parameter list on top-level commas (ignoring generics/parens)."""
245
+ out: "list[str]" = []
246
+ depth = 0
247
+ buf: "list[str]" = []
248
+ for c in params:
249
+ if c in "<(":
250
+ depth += 1
251
+ elif c in ">)":
252
+ depth -= 1
253
+ if c == "," and depth == 0:
254
+ out.append("".join(buf))
255
+ buf = []
256
+ else:
257
+ buf.append(c)
258
+ if buf:
259
+ out.append("".join(buf))
260
+ return out
261
+
262
+
263
+ def _handler_body_dto(controller_src: str, handler: str) -> "Optional[tuple[str, str]]":
264
+ """Find ``handler``'s validated body parameter. Returns (dto_simple, binding)
265
+ where binding is 'body' (@RequestBody), 'form' (@ModelAttribute / implicit),
266
+ or None when the handler has no @Valid/@Validated DTO parameter."""
267
+ for m in re.finditer(r"\b" + re.escape(handler) + r"\s*\(", controller_src):
268
+ params = _balanced_parens(controller_src, m.end() - 1)
269
+ if params is None:
270
+ continue
271
+ for raw in _split_top_level(params):
272
+ raw = raw.strip()
273
+ if not raw:
274
+ continue
275
+ anns = {a.group(1) for a in _ANN_TOKEN_RE.finditer(raw)}
276
+ if not (anns & _VALIDATE_MARKERS):
277
+ continue
278
+ # Strip annotations, then read the parameter's declared type.
279
+ without_ann = _ANN_TOKEN_RE.sub("", raw).strip()
280
+ without_ann = re.sub(r"^final\s+", "", without_ann)
281
+ parts = without_ann.split()
282
+ if not parts:
283
+ continue
284
+ dto = re.sub(r"<.*", "", parts[0]).strip()
285
+ if not _CLASS_TYPE_RE.match(dto):
286
+ continue
287
+ binding = "body" if "RequestBody" in anns else "form"
288
+ return dto, binding
289
+ return None
290
+
291
+
292
+ def _dto_field_constraints(
293
+ dto: str,
294
+ class_index: "dict[str, Path]",
295
+ catalog: "dict[str, CustomConstraint]",
296
+ _seen: "Optional[set[str]]" = None,
297
+ ) -> "list[dict[str, Any]]":
298
+ """Read a DTO's validated fields (own file + in-repo supertypes, depth-guarded)."""
299
+ if _seen is None:
300
+ _seen = set()
301
+ if dto in _seen or dto not in class_index:
302
+ return []
303
+ _seen.add(dto)
304
+ try:
305
+ src = class_index[dto].read_text(encoding="utf-8", errors="replace")
306
+ except OSError:
307
+ return []
308
+
309
+ fields: "list[dict[str, Any]]" = []
310
+ pending: "list[tuple[str, str]]" = [] # (annotation, args)
311
+ for line in src.splitlines():
312
+ stripped = line.strip()
313
+ if stripped.startswith("@"):
314
+ mt = _ANN_TOKEN_RE.match(stripped)
315
+ if mt:
316
+ pending.append((mt.group(1), (mt.group(2) or "").strip()))
317
+ continue
318
+ fm = _FIELD_DECL_RE.match(line)
319
+ if fm:
320
+ rules: "list[dict[str, Any]]" = []
321
+ customs: "list[dict[str, Any]]" = []
322
+ for ann, args in pending:
323
+ if ann in _BEAN_CONSTRAINTS:
324
+ rule: "dict[str, Any]" = {"kind": ann}
325
+ if args:
326
+ rule["value"] = args
327
+ rules.append(rule)
328
+ elif ann in catalog:
329
+ customs.append({"annotation": ann, "resolved": True})
330
+ if rules or customs:
331
+ entry: "dict[str, Any]" = {"name": fm.group(1)}
332
+ if rules:
333
+ entry["rules"] = rules
334
+ if customs:
335
+ entry["customValidators"] = customs
336
+ fields.append(entry)
337
+ pending = []
338
+ elif stripped and not stripped.startswith("//") and not stripped.startswith("*"):
339
+ # Any other meaningful line breaks the annotation→field adjacency.
340
+ pending = []
341
+
342
+ # Follow a single in-repo supertype so inherited constraints are not lost.
343
+ ext = _CLASS_EXTENDS_RE.search(src)
344
+ if ext:
345
+ seen_names = {f["name"] for f in fields}
346
+ for inherited in _dto_field_constraints(ext.group(1), class_index, catalog, _seen):
347
+ if inherited["name"] not in seen_names:
348
+ fields.append(inherited)
349
+ return fields
350
+
351
+
352
+ def _recover_source_endpoints(
353
+ root: Path,
354
+ endpoints: "list[dict[str, Any]]",
355
+ catalog: "dict[str, CustomConstraint]",
356
+ ) -> "tuple[list[dict[str, Any]], int]":
357
+ """Build validation routes for source endpoints whose handler validates a
358
+ DTO. Returns (routes, validated_field_count). Only body-shaped verbs are
359
+ considered, matching the OpenAPI path's scope."""
360
+ class_index = _index_repo_classes(root)
361
+ controller_cache: "dict[str, Optional[str]]" = {}
362
+ routes: "list[dict[str, Any]]" = []
363
+ total = 0
364
+ for ep in endpoints:
365
+ if not _is_body_endpoint(ep):
366
+ continue
367
+ controller = ep.get("controller")
368
+ handler = ep.get("handler")
369
+ if not controller or not handler or controller not in class_index:
370
+ continue
371
+ if controller not in controller_cache:
372
+ try:
373
+ controller_cache[controller] = class_index[controller].read_text(
374
+ encoding="utf-8", errors="replace"
375
+ )
376
+ except OSError:
377
+ controller_cache[controller] = None
378
+ csrc = controller_cache[controller]
379
+ if csrc is None:
380
+ continue
381
+ dto_binding = _handler_body_dto(csrc, str(handler))
382
+ if dto_binding is None:
383
+ continue
384
+ dto, binding = dto_binding
385
+ validated = _dto_field_constraints(dto, class_index, catalog)
386
+ if not validated:
387
+ continue
388
+ total += len(validated)
389
+ routes.append({
390
+ "method": ep.get("method"),
391
+ "path": ep.get("path"),
392
+ "controller": controller,
393
+ "handler": handler,
394
+ "schema": dto,
395
+ "binding": binding,
396
+ "source": "source-derived",
397
+ "confidence": "medium",
398
+ "validatedFields": validated,
399
+ })
400
+ return routes, total
401
+
402
+
183
403
  def _field_rules(fieldc: "dict[str, Any]") -> "list[dict[str, Any]]":
184
404
  """Render a constraint dict's built-in rules as a list of {kind, value}."""
185
405
  rules: "list[dict[str, Any]]" = []
@@ -303,18 +523,44 @@ def build_validation_surface(
303
523
  if spec_path:
304
524
  result["openapi_spec"] = spec_path
305
525
  else:
306
- # No OpenAPI spec on disk / under target/generated-sources. Declarative
307
- # DTO constraints cannot be recovered, so a sea of zeros here is expected
308
- # and NOT a sign the repo lacks validation it just isn't OpenAPI-driven.
309
- # Surface this explicitly so the result is not silently misread as
310
- # "no validation anywhere".
526
+ # No OpenAPI spec on disk / under target/generated-sources. Recover
527
+ # declarative constraints directly from the Java DTOs that handlers
528
+ # validate (@Valid/@Validated body params), so a repo without a spec is
529
+ # no longer reported as a sea of zeros.
311
530
  result["openapi_spec"] = None
312
- result["note"] = (
313
- "No OpenAPI spec found (no spec on disk or under "
314
- "target/generated-sources). Declarative DTO constraints cannot be "
315
- "recovered; only source-declared custom validators are reported. "
316
- "Body-endpoint and validated-field counts will read zero unless an "
317
- "OpenAPI spec is present — this is expected, not a missing-validation "
318
- "finding."
531
+ source_routes, source_fields = _recover_source_endpoints(
532
+ root, endpoints_data.get("endpoints", []), catalog
319
533
  )
534
+ if source_routes:
535
+ existing = {(r.get("method"), r.get("path")) for r in out_endpoints}
536
+ for r in source_routes:
537
+ if (r.get("method"), r.get("path")) not in existing:
538
+ out_endpoints.append(r)
539
+ total_validated_fields += source_fields
540
+ # Recompute the gaps that depended on the now-recovered routes.
541
+ recovered = {(r.get("method"), r.get("path")) for r in source_routes}
542
+ gaps = [
543
+ g for g in gaps
544
+ if (g.get("method"), g.get("path")) not in recovered
545
+ ]
546
+ result["endpoints"] = out_endpoints
547
+ result["gaps"] = gaps
548
+ result["summary"]["endpoints_with_body"] = len(out_endpoints)
549
+ result["summary"]["validated_fields"] = total_validated_fields
550
+ result["summary"]["gaps"] = len(gaps)
551
+ result["summary"]["source_derived_routes"] = len(source_routes)
552
+ result["note"] = (
553
+ "No OpenAPI spec found; constraints were recovered from Java DTO "
554
+ "source (bean-validation annotations on @Valid/@Validated handler "
555
+ "bodies). Routes carry source=\"source-derived\" at medium "
556
+ "confidence. Custom-validator linkage and nested generics may be "
557
+ "partial; OpenAPI-carried constraints would be more complete."
558
+ )
559
+ else:
560
+ result["note"] = (
561
+ "No OpenAPI spec found and no source DTO constraints recovered "
562
+ "(no handler validates an in-repo DTO via @Valid/@Validated, or "
563
+ "the DTOs declare no bean-validation annotations). This is "
564
+ "expected for such repos, not a missing-validation finding."
565
+ )
320
566
  return result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes