oneport-debug-core 0.1.0__py3-none-any.whl

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 (36) hide show
  1. oneport_debug_core/__init__.py +18 -0
  2. oneport_debug_core/cli/__init__.py +5 -0
  3. oneport_debug_core/cli/output.py +104 -0
  4. oneport_debug_core/config/__init__.py +5 -0
  5. oneport_debug_core/config/settings.py +138 -0
  6. oneport_debug_core/engine/__init__.py +6 -0
  7. oneport_debug_core/engine/context_builder.py +85 -0
  8. oneport_debug_core/engine/orchestrator.py +176 -0
  9. oneport_debug_core/git/__init__.py +6 -0
  10. oneport_debug_core/git/diff_utils.py +73 -0
  11. oneport_debug_core/git/multi_repo_client.py +121 -0
  12. oneport_debug_core/git/symbol_indexers/__init__.py +15 -0
  13. oneport_debug_core/git/symbol_indexers/base_indexer.py +32 -0
  14. oneport_debug_core/git/symbol_indexers/go_indexer.py +143 -0
  15. oneport_debug_core/git/symbol_indexers/java_indexer.py +261 -0
  16. oneport_debug_core/git/symbol_indexers/typescript_indexer.py +181 -0
  17. oneport_debug_core/llm/__init__.py +6 -0
  18. oneport_debug_core/llm/base.py +22 -0
  19. oneport_debug_core/llm/circuit_breaker.py +162 -0
  20. oneport_debug_core/llm/providers/__init__.py +7 -0
  21. oneport_debug_core/llm/providers/anthropic.py +39 -0
  22. oneport_debug_core/llm/providers/local_inference.py +73 -0
  23. oneport_debug_core/llm/providers/openai.py +42 -0
  24. oneport_debug_core/llm/router.py +131 -0
  25. oneport_debug_core/models/__init__.py +5 -0
  26. oneport_debug_core/models/rca.py +109 -0
  27. oneport_debug_core/security/__init__.py +6 -0
  28. oneport_debug_core/security/audit_logger.py +158 -0
  29. oneport_debug_core/security/auth_cli.py +114 -0
  30. oneport_debug_core/security/cli_auth.py +192 -0
  31. oneport_debug_core/security/secret_scanner.py +146 -0
  32. oneport_debug_core/security/secrets_manager.py +86 -0
  33. oneport_debug_core-0.1.0.dist-info/METADATA +42 -0
  34. oneport_debug_core-0.1.0.dist-info/RECORD +36 -0
  35. oneport_debug_core-0.1.0.dist-info/WHEEL +4 -0
  36. oneport_debug_core-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """oneport-debug-core: Shared foundation for all oneport-debug enterprise tools."""
4
+
5
+ from oneport_debug_core.models.rca import RCAResult, CodeLocation, Evidence
6
+ from oneport_debug_core.engine.orchestrator import Orchestrator
7
+ from oneport_debug_core.config.settings import AppConfig, load_config
8
+
9
+ __all__ = [
10
+ "RCAResult",
11
+ "CodeLocation",
12
+ "Evidence",
13
+ "Orchestrator",
14
+ "AppConfig",
15
+ "load_config",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ from oneport_debug_core.cli.output import print_rca, print_error, print_success, print_step, console
4
+
5
+ __all__ = ["print_rca", "print_error", "print_success", "print_step", "console"]
@@ -0,0 +1,104 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ Shared Rich-based terminal output utilities.
5
+ All 5 CLI tools use these to produce consistent, readable output.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from typing import Any
12
+
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+ from rich.syntax import Syntax
17
+ from rich import box
18
+
19
+ from oneport_debug_core.models.rca import RCAResult, Severity
20
+
21
+ console = Console()
22
+
23
+
24
+ def ensure_utf8_console() -> None:
25
+ """
26
+ Force stdout/stderr to UTF-8 so Unicode glyphs (✔ ✖ → ■ █) used in CLI
27
+ output don't raise UnicodeEncodeError on Windows consoles that default to
28
+ cp1252. Idempotent and safe to call from every CLI entry point; a no-op
29
+ where the stream can't be reconfigured (e.g. under pytest capture).
30
+ """
31
+ for stream in (sys.stdout, sys.stderr):
32
+ reconfigure = getattr(stream, "reconfigure", None)
33
+ if reconfigure is None:
34
+ continue
35
+ try:
36
+ reconfigure(encoding="utf-8", errors="replace")
37
+ except (ValueError, OSError):
38
+ pass
39
+
40
+ _SEVERITY_COLORS = {
41
+ Severity.CRITICAL: "bold red",
42
+ Severity.HIGH: "red",
43
+ Severity.MEDIUM: "yellow",
44
+ Severity.LOW: "green",
45
+ }
46
+
47
+
48
+ def print_rca(result: RCAResult, output_format: str = "rich") -> None:
49
+ if output_format == "json":
50
+ console.print_json(result.model_dump_json(indent=2))
51
+ return
52
+
53
+ color = _SEVERITY_COLORS.get(result.severity, "white")
54
+ confidence_bar = "█" * int(result.confidence * 10) + "░" * (10 - int(result.confidence * 10))
55
+
56
+ console.print()
57
+ console.print(Panel(
58
+ f"[bold]{result.summary}[/bold]",
59
+ title=f"[{color}]■ {result.severity.value.upper()}[/{color}] oneport-debug/{result.module}",
60
+ border_style=color,
61
+ ))
62
+
63
+ console.print(f"\n[bold cyan]Root Cause[/bold cyan]\n{result.root_cause}\n")
64
+ console.print(f"[dim]Confidence:[/dim] [{color}]{confidence_bar}[/{color}] {result.confidence:.0%} | Model: {result.model_used} | {result.duration_ms}ms\n")
65
+
66
+ if result.locations:
67
+ table = Table(title="Affected Code Locations", box=box.SIMPLE_HEAVY, show_lines=False)
68
+ table.add_column("Repo", style="cyan", no_wrap=True)
69
+ table.add_column("File", style="white")
70
+ table.add_column("Line", style="yellow", justify="right")
71
+ table.add_column("Function", style="magenta")
72
+ table.add_column("Blame", style="dim")
73
+ for loc in result.locations:
74
+ table.add_row(
75
+ loc.repo.split("/")[-1],
76
+ loc.file_path,
77
+ str(loc.line_number or "—"),
78
+ loc.function_name or "—",
79
+ loc.blame_author or "—",
80
+ )
81
+ console.print(table)
82
+
83
+ if result.recommended_fix:
84
+ console.print(Panel(
85
+ result.recommended_fix,
86
+ title="[bold green]Recommended Fix[/bold green]",
87
+ border_style="green",
88
+ ))
89
+
90
+ console.print(f"[dim]Generated: {result.generated_at.isoformat()} | Trace: {result.trace_id or 'N/A'}[/dim]\n")
91
+
92
+
93
+ def print_error(message: str, hint: str | None = None) -> None:
94
+ console.print(f"[bold red]✖ Error:[/bold red] {message}")
95
+ if hint:
96
+ console.print(f"[dim] Hint: {hint}[/dim]")
97
+
98
+
99
+ def print_success(message: str) -> None:
100
+ console.print(f"[bold green]✔[/bold green] {message}")
101
+
102
+
103
+ def print_step(message: str) -> None:
104
+ console.print(f"[cyan]→[/cyan] {message}")
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ from oneport_debug_core.config.settings import AppConfig, RunMode, load_config
4
+
5
+ __all__ = ["AppConfig", "RunMode", "load_config"]
@@ -0,0 +1,138 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ AppConfig — single source of truth for every oneport-debug tool.
5
+ Loaded from env vars, .env file, or YAML (enterprise config management compatible).
6
+ All secrets are never logged (SecretStr).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ import yaml
16
+ from pydantic import AliasChoices, Field, SecretStr, field_validator
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+
20
+ class RunMode(str, Enum):
21
+ CLOUD = "cloud" # Anthropic API (default)
22
+ LOCAL = "local" # Air-gapped on-prem inference
23
+ HYBRID = "hybrid" # Local primary, cloud fallback (with explicit opt-in)
24
+
25
+
26
+ class AnthropicSettings(BaseSettings):
27
+ model_config = SettingsConfigDict(env_prefix="ANTHROPIC_")
28
+ api_key: SecretStr = Field(default=SecretStr(""), description="ANTHROPIC_API_KEY")
29
+ model: str = Field("claude-sonnet-4-6", description="Model ID")
30
+ max_tokens: int = Field(4096, ge=256, le=8192)
31
+ timeout_s: int = Field(60, ge=10)
32
+
33
+
34
+ class OpenAISettings(BaseSettings):
35
+ model_config = SettingsConfigDict(env_prefix="OPENAI_")
36
+ api_key: SecretStr = Field(default=SecretStr(""), description="OPENAI_API_KEY")
37
+ model: str = Field("gpt-4o", description="Fallback model ID")
38
+
39
+
40
+ class LocalInferenceSettings(BaseSettings):
41
+ model_config = SettingsConfigDict(env_prefix="LOCAL_INFERENCE_")
42
+ url: str = Field("http://localhost:11434", description="Ollama / vLLM / llama.cpp base URL")
43
+ model: str = Field("deepseek-coder:6.7b", description="Model name on local server")
44
+ api_key: SecretStr = Field(default=SecretStr(""), description="Optional bearer token")
45
+ allowed_hosts: list[str] = Field(
46
+ default_factory=lambda: ["localhost", "127.0.0.1"],
47
+ description="Allowlist — enforced by network_enforcer in air-gap mode",
48
+ )
49
+ timeout_s: int = Field(120, ge=10)
50
+
51
+
52
+ class AuditSettings(BaseSettings):
53
+ model_config = SettingsConfigDict(env_prefix="AUDIT_")
54
+ log_path: Path = Field(
55
+ default=Path("/var/log/oneport-debug/audit.jsonl"),
56
+ description="AUDIT_LOG_PATH — must be write-accessible; use /dev/stdout in containers",
57
+ )
58
+ enabled: bool = Field(True, description="Disable only in unit tests")
59
+ max_file_mb: int = Field(100, ge=1)
60
+
61
+
62
+ class AppConfig(BaseSettings):
63
+ model_config = SettingsConfigDict(
64
+ env_file=".env",
65
+ env_file_encoding="utf-8",
66
+ extra="ignore",
67
+ # Fields below use validation_alias for their documented env vars; without
68
+ # this, the field NAME (e.g. AppConfig(mode=...)) would be silently ignored.
69
+ populate_by_name=True,
70
+ )
71
+
72
+ # Each top-level field is read from its documented env var via validation_alias.
73
+ # The uppercased field name is kept as an alias so load_config()'s YAML overlay
74
+ # (which sets os.environ[KEY.upper()]) keeps working.
75
+ mode: RunMode = Field(
76
+ RunMode.CLOUD,
77
+ validation_alias=AliasChoices("ONEPORT_MODE", "MODE"),
78
+ description="ONEPORT_MODE=cloud|local|hybrid",
79
+ )
80
+ anthropic: AnthropicSettings = Field(default_factory=AnthropicSettings)
81
+ openai: OpenAISettings = Field(default_factory=OpenAISettings)
82
+ local_inference: LocalInferenceSettings = Field(default_factory=LocalInferenceSettings)
83
+ audit: AuditSettings = Field(default_factory=AuditSettings)
84
+
85
+ # Enterprise proxy / certificate settings
86
+ http_proxy: str | None = Field(
87
+ None,
88
+ validation_alias=AliasChoices("HTTPS_PROXY", "HTTP_PROXY"),
89
+ description="HTTPS_PROXY — for enterprise network egress",
90
+ )
91
+ ca_bundle: Path | None = Field(
92
+ None,
93
+ validation_alias=AliasChoices("SSL_CERT_FILE", "CA_BUNDLE"),
94
+ description="SSL_CERT_FILE — corporate CA bundle path",
95
+ )
96
+
97
+ # Telemetry — used by the tool itself (not the code being debugged)
98
+ otel_endpoint: str | None = Field(
99
+ None,
100
+ validation_alias=AliasChoices("OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_ENDPOINT"),
101
+ description="OTEL_EXPORTER_OTLP_ENDPOINT",
102
+ )
103
+ service_name: str = Field(
104
+ "oneport-debug",
105
+ validation_alias=AliasChoices("OTEL_SERVICE_NAME", "SERVICE_NAME"),
106
+ description="OTEL_SERVICE_NAME",
107
+ )
108
+
109
+ @field_validator("mode", mode="before")
110
+ @classmethod
111
+ def normalise_mode(cls, v: object) -> str:
112
+ # The default is a RunMode enum; str(RunMode.CLOUD) is 'RunMode.CLOUD',
113
+ # which would fail enum validation. Pass enums through by value and only
114
+ # normalise raw strings coming from env/YAML (e.g. "CLOUD" -> "cloud").
115
+ if isinstance(v, RunMode):
116
+ return v.value
117
+ return str(v).strip().lower()
118
+
119
+
120
+ def load_config(yaml_path: Path | None = None) -> AppConfig:
121
+ """
122
+ Load AppConfig with optional YAML overlay.
123
+ YAML keys map 1-to-1 to env var names (snake_case), allowing enterprise
124
+ config management tools (Ansible, Helm values) to supply configuration
125
+ without polluting the environment.
126
+ """
127
+ overrides: dict = {}
128
+ if yaml_path and yaml_path.exists():
129
+ with yaml_path.open() as fh:
130
+ overrides = yaml.safe_load(fh) or {}
131
+
132
+ # Overlay YAML values as env vars so pydantic-settings picks them up
133
+ for key, value in overrides.items():
134
+ env_key = key.upper()
135
+ if env_key not in os.environ:
136
+ os.environ[env_key] = str(value)
137
+
138
+ return AppConfig()
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ from oneport_debug_core.engine.orchestrator import Orchestrator
4
+ from oneport_debug_core.engine.context_builder import ContextBuilder
5
+
6
+ __all__ = ["Orchestrator", "ContextBuilder"]
@@ -0,0 +1,85 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ ContextBuilder — assembles structured LLM prompts from evidence payloads.
5
+
6
+ The prompt schema is intentionally strict:
7
+ - JSON-only output is requested (no prose)
8
+ - Required fields are listed so the model never omits them
9
+ - Token budget is controlled (evidence is truncated, not silently dropped)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ from oneport_debug_core.models.rca import Evidence
17
+
18
+ # Maximum characters of evidence raw data to include per source
19
+ _MAX_EVIDENCE_CHARS = 6000
20
+ _MAX_TOTAL_EVIDENCE_CHARS = 20_000
21
+
22
+
23
+ class ContextBuilder:
24
+ def build_rca_prompt(
25
+ self,
26
+ module: str,
27
+ evidence: list[Evidence],
28
+ extra: dict[str, Any] | None = None,
29
+ ) -> str:
30
+ evidence_block = self._format_evidence(evidence)
31
+ extra_block = json.dumps(extra or {}, indent=2, default=str)
32
+
33
+ return f"""You are an expert enterprise software debugger analyzing a production incident.
34
+ Module: {module}
35
+
36
+ ## Evidence
37
+ {evidence_block}
38
+
39
+ ## Additional Context
40
+ {extra_block}
41
+
42
+ ## Task
43
+ Analyze the evidence and return a JSON object with EXACTLY these fields:
44
+ {{
45
+ "severity": "critical|high|medium|low",
46
+ "summary": "One paragraph plain-English summary of what happened",
47
+ "root_cause": "Precise technical root cause — service, component, and mechanism",
48
+ "confidence": 0.0–1.0,
49
+ "locations": [
50
+ {{
51
+ "repo": "git remote URL or alias",
52
+ "file_path": "repo-relative path",
53
+ "line_number": integer or null,
54
+ "function_name": "string or null",
55
+ "commit_sha": "string or null",
56
+ "blame_author": "string or null"
57
+ }}
58
+ ],
59
+ "recommended_fix": "Step-by-step fix instructions with exact code changes",
60
+ "affected_services": ["service-a", "service-b"],
61
+ "tags": {{"env": "prod", "team": "payments"}}
62
+ }}
63
+
64
+ Rules:
65
+ - Output ONLY the JSON object. No markdown, no explanation, no preamble.
66
+ - If you cannot determine a field, use null — never omit it.
67
+ - locations must reference actual symbols from the evidence; do not hallucinate file paths.
68
+ - confidence = 1.0 only if you have direct log/heap/policy evidence pointing to the exact line.
69
+ """
70
+
71
+ @staticmethod
72
+ def _format_evidence(evidence: list[Evidence]) -> str:
73
+ parts: list[str] = []
74
+ total = 0
75
+ for ev in evidence:
76
+ raw_str = json.dumps(ev.raw, indent=2, default=str)
77
+ if len(raw_str) > _MAX_EVIDENCE_CHARS:
78
+ raw_str = raw_str[:_MAX_EVIDENCE_CHARS] + "\n... [truncated for token budget]"
79
+ chunk = f"### Source: {ev.source} (collected {ev.collected_at.isoformat()})\n{raw_str}"
80
+ if total + len(chunk) > _MAX_TOTAL_EVIDENCE_CHARS:
81
+ parts.append("... [remaining evidence truncated — token budget exceeded]")
82
+ break
83
+ parts.append(chunk)
84
+ total += len(chunk)
85
+ return "\n\n".join(parts) if parts else "(no evidence provided)"
@@ -0,0 +1,176 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """
4
+ Orchestrator — the shared analysis engine used by all 5 oneport-debug tools.
5
+
6
+ Every public method:
7
+ 1. Builds a structured prompt from evidence
8
+ 2. Calls the LLMRouter (cloud or local depending on config)
9
+ 3. Parses the response into a typed RCAResult
10
+ 4. Writes a tamper-evident audit log entry (required for SOX/HIPAA/PCI-DSS)
11
+ 5. Returns the RCAResult to the caller
12
+
13
+ The orchestrator never crashes on a bad LLM response — it degrades gracefully
14
+ to a low-confidence RCAResult so the CI pipeline / Slack alert still fires.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import time
21
+ from typing import Any
22
+
23
+ import os
24
+
25
+ import structlog
26
+
27
+ from oneport_debug_core.config.settings import AppConfig
28
+ from oneport_debug_core.engine.context_builder import ContextBuilder
29
+ from oneport_debug_core.llm.router import LLMRouter
30
+ from oneport_debug_core.llm.base import LLMCompletionOptions
31
+ from oneport_debug_core.models.rca import RCAResult, CodeLocation, Evidence, Severity
32
+ from oneport_debug_core.security.audit_logger import AuditLogger
33
+ from oneport_debug_core.security.secret_scanner import SecretScanner
34
+
35
+ log = structlog.get_logger(__name__)
36
+
37
+
38
+ class Orchestrator:
39
+ def __init__(self, config: AppConfig) -> None:
40
+ self._config = config
41
+ self.llm = LLMRouter(config)
42
+ self._audit = AuditLogger(config.audit)
43
+ self._ctx = ContextBuilder()
44
+ self._scanner = SecretScanner()
45
+
46
+ async def analyze(
47
+ self,
48
+ module: str,
49
+ evidence: list[Evidence],
50
+ extra_context: dict[str, Any] | None = None,
51
+ trace_id: str | None = None,
52
+ ) -> RCAResult:
53
+ """
54
+ Core entry point. Called by every tool's CLI command.
55
+ `module` identifies which tool is calling (tracer, apm, iam, cicd, local).
56
+ """
57
+ t0 = time.monotonic()
58
+
59
+ prompt = self._ctx.build_rca_prompt(
60
+ module=module,
61
+ evidence=evidence,
62
+ extra=extra_context or {},
63
+ )
64
+
65
+ try:
66
+ # Redact secrets before the prompt leaves the machine
67
+ scan_result = self._scanner.scan(prompt)
68
+ if scan_result.findings:
69
+ log.warning(
70
+ "orchestrator.secrets_redacted",
71
+ module=module,
72
+ findings=[f["type"] for f in scan_result.findings],
73
+ )
74
+ safe_prompt = scan_result.redacted_content
75
+
76
+ raw = await self.llm.complete(safe_prompt, LLMCompletionOptions(temperature=0.05, max_tokens=4000))
77
+ result = self._parse_rca(raw, module, evidence, trace_id)
78
+ except Exception as err:
79
+ log.error("orchestrator.llm_failed", module=module, error=str(err))
80
+ result = self._degraded_rca(module, evidence, trace_id, reason=str(err))
81
+
82
+ result.duration_ms = int((time.monotonic() - t0) * 1000)
83
+ result.model_used = self.llm.active_provider
84
+
85
+ await self._audit.record(
86
+ action="rca_generated",
87
+ module=module,
88
+ provider=self.llm.active_provider,
89
+ mode=str(self._config.mode.value),
90
+ duration_ms=result.duration_ms,
91
+ confidence=result.confidence,
92
+ trace_id=trace_id,
93
+ secrets_redacted=len(scan_result.findings) if "scan_result" in dir() else 0,
94
+ )
95
+
96
+ return result
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # Private helpers #
100
+ # ------------------------------------------------------------------ #
101
+
102
+ def _parse_rca(
103
+ self,
104
+ raw: str,
105
+ module: str,
106
+ evidence: list[Evidence],
107
+ trace_id: str | None,
108
+ ) -> RCAResult:
109
+ json_str = self._extract_json(raw)
110
+ try:
111
+ obj = json.loads(json_str)
112
+ except json.JSONDecodeError:
113
+ log.warning("orchestrator.json_parse_failed", raw_preview=raw[:300])
114
+ return self._degraded_rca(module, evidence, trace_id, reason="Model returned non-JSON")
115
+
116
+ locations = [
117
+ CodeLocation(
118
+ repo=str(loc.get("repo", "unknown")),
119
+ file_path=str(loc.get("file_path", "unknown")),
120
+ line_number=loc.get("line_number"),
121
+ function_name=loc.get("function_name"),
122
+ commit_sha=loc.get("commit_sha"),
123
+ blame_author=loc.get("blame_author"),
124
+ )
125
+ for loc in (obj.get("locations") or [])
126
+ if isinstance(loc, dict)
127
+ ]
128
+
129
+ return RCAResult(
130
+ module=module,
131
+ trace_id=trace_id,
132
+ severity=self._safe_severity(obj.get("severity", "medium")),
133
+ summary=str(obj.get("summary", "No summary returned.")),
134
+ root_cause=str(obj.get("root_cause", "Unable to determine root cause.")),
135
+ confidence=obj.get("confidence", 0.3),
136
+ locations=locations,
137
+ recommended_fix=obj.get("recommended_fix"),
138
+ evidence=evidence,
139
+ affected_services=obj.get("affected_services", []),
140
+ tags=obj.get("tags", {}),
141
+ )
142
+
143
+ @staticmethod
144
+ def _degraded_rca(
145
+ module: str,
146
+ evidence: list[Evidence],
147
+ trace_id: str | None,
148
+ reason: str,
149
+ ) -> RCAResult:
150
+ return RCAResult(
151
+ module=module,
152
+ trace_id=trace_id,
153
+ severity=Severity.LOW,
154
+ summary=f"Analysis degraded: {reason}",
155
+ root_cause=reason,
156
+ confidence=0.05,
157
+ evidence=evidence,
158
+ )
159
+
160
+ @staticmethod
161
+ def _extract_json(raw: str) -> str:
162
+ """Strip markdown fences and extract the outermost JSON object."""
163
+ fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", raw, re.IGNORECASE)
164
+ if fence:
165
+ return fence.group(1).strip()
166
+ first, last = raw.find("{"), raw.rfind("}")
167
+ if first != -1 and last > first:
168
+ return raw[first : last + 1]
169
+ return raw
170
+
171
+ @staticmethod
172
+ def _safe_severity(v: Any) -> Severity:
173
+ try:
174
+ return Severity(str(v).lower())
175
+ except ValueError:
176
+ return Severity.MEDIUM
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ from oneport_debug_core.git.multi_repo_client import MultiRepoClient
4
+ from oneport_debug_core.git.diff_utils import parse_unified_diff, added_lines, line_to_function
5
+
6
+ __all__ = ["MultiRepoClient", "parse_unified_diff", "added_lines", "line_to_function"]
@@ -0,0 +1,73 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 OnePort Debug Contributors
3
+ """Unified diff parser and line-number mapper used by tracer and cicd modules."""
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class DiffHunk:
12
+ file_path: str
13
+ old_start: int
14
+ new_start: int
15
+ lines: list[str]
16
+
17
+
18
+ def parse_unified_diff(diff_text: str) -> list[DiffHunk]:
19
+ """Parse a unified diff into structured hunks."""
20
+ hunks: list[DiffHunk] = []
21
+ current_file = ""
22
+ hunk_header = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
23
+
24
+ for line in diff_text.splitlines():
25
+ if line.startswith("+++ b/"):
26
+ current_file = line[6:]
27
+ elif m := hunk_header.match(line):
28
+ hunks.append(DiffHunk(
29
+ file_path=current_file,
30
+ old_start=int(m.group(1)),
31
+ new_start=int(m.group(2)),
32
+ lines=[],
33
+ ))
34
+ elif hunks:
35
+ hunks[-1].lines.append(line)
36
+
37
+ return hunks
38
+
39
+
40
+ def added_lines(hunks: list[DiffHunk], file_path: str) -> list[int]:
41
+ """Return new line numbers of added (+) lines for a given file."""
42
+ result: list[int] = []
43
+ for hunk in hunks:
44
+ if hunk.file_path != file_path:
45
+ continue
46
+ current_line = hunk.new_start
47
+ for line in hunk.lines:
48
+ if line.startswith("+"):
49
+ result.append(current_line)
50
+ current_line += 1
51
+ elif not line.startswith("-"):
52
+ current_line += 1
53
+ return result
54
+
55
+
56
+ def line_to_function(source: str, line_number: int) -> str | None:
57
+ """
58
+ Heuristic: walk backwards from line_number to find the enclosing
59
+ function/method definition. Works for Python, Java, Go, JS/TS, Kotlin.
60
+ """
61
+ fn_patterns = [
62
+ re.compile(r"^\s*(?:async\s+)?def\s+(\w+)"), # Python
63
+ re.compile(r"^\s*(?:public|private|protected|static|\s)*\w+\s+(\w+)\s*\("), # Java/Kotlin
64
+ re.compile(r"^\s*func(?:\s+\(\w+\s+\*?\w+\))?\s+(\w+)"), # Go
65
+ re.compile(r"^\s*(?:async\s+)?(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\()"), # JS/TS
66
+ ]
67
+ lines = source.splitlines()
68
+ for i in range(min(line_number - 1, len(lines) - 1), -1, -1):
69
+ for pat in fn_patterns:
70
+ m = pat.match(lines[i])
71
+ if m:
72
+ return next(g for g in m.groups() if g)
73
+ return None