sanityops-cli 0.1.3__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 (53) hide show
  1. sanityops_cli/__init__.py +16 -0
  2. sanityops_cli/agents/__init__.py +14 -0
  3. sanityops_cli/agents/repair_agent/__init__.py +19 -0
  4. sanityops_cli/agents/repair_agent/agent.py +230 -0
  5. sanityops_cli/agents/repair_agent/prompts.py +100 -0
  6. sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
  7. sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
  8. sanityops_cli/agents/scanner_agent/agent.py +332 -0
  9. sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
  10. sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
  11. sanityops_cli/agents/scanner_agent/prompts.py +316 -0
  12. sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
  13. sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
  14. sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
  15. sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
  16. sanityops_cli/api/__init__.py +16 -0
  17. sanityops_cli/api/client.py +403 -0
  18. sanityops_cli/commands/__init__.py +14 -0
  19. sanityops_cli/commands/config.py +312 -0
  20. sanityops_cli/commands/init.py +132 -0
  21. sanityops_cli/commands/inspect.py +646 -0
  22. sanityops_cli/constants/__init__.py +14 -0
  23. sanityops_cli/constants/config_defaults.py +22 -0
  24. sanityops_cli/constants/exit_codes.py +19 -0
  25. sanityops_cli/defect_checker/__init__.py +16 -0
  26. sanityops_cli/defect_checker/checker.py +100 -0
  27. sanityops_cli/defect_checker/llm_config.py +112 -0
  28. sanityops_cli/defect_checker/markdown_reporter.py +249 -0
  29. sanityops_cli/defect_checker/renderer.py +203 -0
  30. sanityops_cli/exceptions/__init__.py +14 -0
  31. sanityops_cli/exceptions/api_exceptions.py +60 -0
  32. sanityops_cli/exceptions/base_exceptions.py +25 -0
  33. sanityops_cli/help_panel.py +49 -0
  34. sanityops_cli/logging/__init__.py +18 -0
  35. sanityops_cli/logging/logger.py +108 -0
  36. sanityops_cli/main.py +123 -0
  37. sanityops_cli/progress/__init__.py +18 -0
  38. sanityops_cli/progress/tracker.py +159 -0
  39. sanityops_cli/renderers/__init__.py +14 -0
  40. sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
  41. sanityops_cli/templates/__init__.py +14 -0
  42. sanityops_cli/templates/inspect_config.yaml +55 -0
  43. sanityops_cli/utils/__init__.py +14 -0
  44. sanityops_cli/utils/artifact_packer.py +407 -0
  45. sanityops_cli/utils/config_loader.py +296 -0
  46. sanityops_cli/utils/config_resolver.py +358 -0
  47. sanityops_cli/utils/validators.py +117 -0
  48. sanityops_cli-0.1.3.dist-info/METADATA +213 -0
  49. sanityops_cli-0.1.3.dist-info/RECORD +53 -0
  50. sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
  51. sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
  52. sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
  53. sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,19 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ # Exit codes for sanityops-cli
17
+ EXIT_SUCCESS = 0
18
+ EXIT_DEFECTS_FOUND = 1
19
+ EXIT_FAILURE = 2
@@ -0,0 +1,16 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """Defect checking for extracted artifacts via the defect-check SDK."""
@@ -0,0 +1,100 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """Convert FindingsResult into defect-check SDK input and invoke the SDK."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import defect_check
21
+ from defect_check.models import DefectCheckOptions
22
+
23
+ from sanityops_cli.agents.scanner_agent.models.finding import (
24
+ Finding,
25
+ FindingsResult,
26
+ FindingType,
27
+ )
28
+
29
+
30
+ class DefectChecker:
31
+ """Checks extracted artifacts for defects using the defect-check SDK."""
32
+
33
+ def __init__(self, llm_config: dict[str, str]):
34
+ """Initialize with the resolved LLM config.
35
+
36
+ Args:
37
+ llm_config: dict with keys llm_provider, llm_api_key,
38
+ llm_model_id, llm_base_url.
39
+ """
40
+ self.llm_config = llm_config
41
+
42
+ async def check(
43
+ self,
44
+ findings: FindingsResult,
45
+ check_level: str = "L2",
46
+ ) -> dict:
47
+ """Run defect check and return the raw SDK response dict."""
48
+ tools = self._convert_tools(findings.tools)
49
+ prompts = self._convert_prompts(findings.prompts)
50
+ skills = self._convert_skills(findings.skills)
51
+ options = DefectCheckOptions(
52
+ enable_cross_check=True,
53
+ )
54
+
55
+ return await defect_check.check(
56
+ tools=tools,
57
+ prompts=prompts,
58
+ skills=skills,
59
+ check_level=check_level,
60
+ llm_provider=self.llm_config.get("llm_provider"),
61
+ llm_api_key=self.llm_config.get("llm_api_key"),
62
+ llm_model_id=self.llm_config.get("llm_model_id"),
63
+ llm_base_url=self.llm_config.get("llm_base_url"),
64
+ options=options
65
+ )
66
+
67
+ def _convert_tools(self, tools: list[Finding]) -> list[dict]:
68
+ result: list[dict] = []
69
+ for finding in tools:
70
+ content = finding.content
71
+ if content is None or finding.type != FindingType.TOOL:
72
+ continue
73
+ result.append({
74
+ "name": content.name,
75
+ "description": content.description,
76
+ "parameters": content.parameters,
77
+ })
78
+ return result
79
+
80
+ def _convert_prompts(self, prompts: list[Finding]) -> list[dict]:
81
+ result: list[dict] = []
82
+ for finding in prompts:
83
+ content = finding.content
84
+ if content is None or finding.type != FindingType.PROMPT:
85
+ continue
86
+ result.append({"content": content.content})
87
+ return result
88
+
89
+ def _convert_skills(self, skills: list[Finding]) -> list[dict]:
90
+ result: list[dict] = []
91
+ for finding in skills:
92
+ content = finding.content
93
+ if content is None or finding.type != FindingType.SKILL:
94
+ continue
95
+ result.append({
96
+ "id": finding.relative,
97
+ "name": content.name,
98
+ "content": content.to_markdown(),
99
+ })
100
+ return result
@@ -0,0 +1,112 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """Auto-detect LLM configuration for the defect-check SDK."""
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ import yaml
23
+
24
+ # Workaround: sanityops-agent 0.0.2 installs modules flat in site-packages root
25
+ # instead of under sanityops_agent/. Import from flat location.
26
+ try:
27
+ from sanityops_agent.config import ProviderConfig
28
+ except ModuleNotFoundError:
29
+ from config import ProviderConfig # type: ignore[import-untyped]
30
+
31
+ # Default config file location
32
+ DEFAULT_CONFIG_DIR = ".sanityops"
33
+ DEFAULT_CONFIG_FILE = "inspect_config.yaml"
34
+
35
+
36
+ def _read_model_section(config_path: str | None) -> dict[str, str]:
37
+ """Read model section from config file if present.
38
+
39
+ Args:
40
+ config_path: Path to config file, or None to use default location.
41
+
42
+ Returns:
43
+ Dict with keys llm_provider, llm_api_key, llm_model_id, llm_base_url
44
+ if model section is present and valid. Empty dict {} if model section
45
+ absent or file missing/unreadable.
46
+ """
47
+ # Resolve config file path
48
+ if config_path:
49
+ p = Path(config_path).expanduser()
50
+ else:
51
+ p = Path.cwd() / DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_FILE
52
+
53
+ # If file doesn't exist, return empty (fall back to env vars)
54
+ if not p.exists() or not p.is_file():
55
+ return {}
56
+
57
+ # Try to read and parse YAML
58
+ try:
59
+ with open(p, encoding="utf-8") as f:
60
+ data = yaml.safe_load(f)
61
+ except (yaml.YAMLError, OSError):
62
+ return {}
63
+
64
+ if not isinstance(data, dict):
65
+ return {}
66
+
67
+ model = data.get("model")
68
+ if not isinstance(model, dict):
69
+ return {}
70
+
71
+ # Check for required fields - if any missing, fall back to env vars
72
+ provider = model.get("provider")
73
+ api_key = model.get("api_key")
74
+ model_id = model.get("model_id")
75
+
76
+ if not provider or not api_key or not model_id:
77
+ return {}
78
+
79
+ # All required fields present and non-empty
80
+ return {
81
+ "llm_provider": str(provider),
82
+ "llm_api_key": str(api_key),
83
+ "llm_model_id": str(model_id),
84
+ "llm_base_url": str(model.get("base_url", "")),
85
+ }
86
+
87
+
88
+ def resolve_llm_config(config_path: str | None = None) -> dict[str, str]:
89
+ """Resolve the four LLM params accepted by defect_check.check().
90
+
91
+ Precedence: config file `model:` section -> ProviderConfig (env vars).
92
+
93
+ Args:
94
+ config_path: Optional path to config file. If None, looks for
95
+ .sanityops/inspect_config.yaml in current working directory.
96
+
97
+ Returns:
98
+ dict with keys: llm_provider, llm_api_key, llm_model_id, llm_base_url
99
+ """
100
+ # Try config file first
101
+ config_values = _read_model_section(config_path)
102
+ if config_values:
103
+ return config_values
104
+
105
+ # Fall back to ProviderConfig (env vars)
106
+ config = ProviderConfig()
107
+ return {
108
+ "llm_provider": config.LLM_PROVIDER,
109
+ "llm_api_key": config.API_KEY,
110
+ "llm_model_id": config.MODEL_ID,
111
+ "llm_base_url": config.BASE_URL or "",
112
+ }
@@ -0,0 +1,249 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """Generate and persist a full Markdown report from a defect-check response dict."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import html
21
+ import logging
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from sanityops_cli.defect_checker.renderer import _MODULE_LABELS
27
+
28
+ _log = logging.getLogger(__name__)
29
+
30
+
31
+ def _esc_md_cell(text: str | None) -> str:
32
+ """Escape markdown table cell content: pipes and newlines."""
33
+ if text is None:
34
+ return "—"
35
+ text = html.escape(str(text))
36
+ # Replace pipe to avoid breaking table columns
37
+ text = text.replace("|", r"\|")
38
+ # Replace newlines with <br> so multi-line content stays in one cell
39
+ text = text.replace("\n", "<br>")
40
+ # Strip leading/trailing whitespace to keep table compact
41
+ return text.strip()
42
+
43
+
44
+ def _format_score(score: dict[str, Any] | None) -> str:
45
+ """Format score/grade/gate for a section header, or empty string."""
46
+ if score is None:
47
+ return ""
48
+ parts = []
49
+ if "total_score" in score:
50
+ max_score = score.get("max_score", 100.0)
51
+ parts.append(f"score {score['total_score']}/{max_score}")
52
+ if score.get("grade"):
53
+ parts.append(f"grade {score['grade']}")
54
+ if score.get("gate_result"):
55
+ parts.append(f"gate {score['gate_result']}")
56
+ return ", ".join(parts)
57
+
58
+
59
+ def _aggregate_cross_defects(cross_results: list[dict]) -> list[dict]:
60
+ """Aggregate defects from all CROSS sub-results (PS/PT/ST) into one list.
61
+
62
+ Maps SDK DefectItem format to CrossScoringCalculator format:
63
+ id -> defect_id
64
+ severity -> defect_level
65
+ category -> relation (QD-PS, QD-PT, QD-ST)
66
+ """
67
+ defects = []
68
+ for result in cross_results:
69
+ for d in result.get("defects") or []:
70
+ if not isinstance(d, dict):
71
+ continue
72
+ defects.append({
73
+ "defect_id": d.get("id"),
74
+ "defect_level": d.get("severity"),
75
+ "relation": d.get("category"),
76
+ })
77
+ return defects
78
+
79
+
80
+ def _calculate_cross_score(
81
+ defects: list[dict],
82
+ check_level: str,
83
+ ) -> dict | None:
84
+ """Compute merged CROSS score using SDK's CrossScoringCalculator.
85
+
86
+ The SDK uses weighted deductions per PS/PT/ST group:
87
+ P0:P1:P2 = 5:3:1 deduction weights
88
+ Gate FAIL when any group has P0 defect
89
+
90
+ Returns None on any failure so the report degrades gracefully instead of
91
+ crashing. This is intentional: the SDK import or calculation may fail in
92
+ unexpected ways, and we prefer an unscored CROSS section over no report.
93
+ """
94
+ if not defects:
95
+ return None
96
+ try:
97
+ from defect_check.cross.scoring import CrossScoringCalculator
98
+ scoring = CrossScoringCalculator().calculate_score(defects, check_level, mode=None)
99
+ except Exception as e:
100
+ # Intentional broad catch: SDK import, configuration, or calculation
101
+ # failures should not prevent report generation.
102
+ _log.warning("Cross scoring calculation failed: %s", e)
103
+ return None
104
+ return {
105
+ "total_score": scoring.get("total_score"),
106
+ "gate_result": scoring.get("gate_result", "PASS"),
107
+ }
108
+
109
+
110
+ def _build_report(
111
+ response: dict[str, Any],
112
+ *,
113
+ project_id: str | None,
114
+ check_level: str,
115
+ generated_at: datetime,
116
+ ) -> str:
117
+ """Render the full markdown report body."""
118
+ summary = response.get("summary", {})
119
+ results = response.get("results", [])
120
+ errors = response.get("errors", [])
121
+ metadata = response.get("metadata", {})
122
+ status = response.get("status", "unknown")
123
+
124
+ lines: list[str] = []
125
+ lines.append("# Inspect Report")
126
+ lines.append("")
127
+
128
+ # Meta
129
+ lines.append(f"- **Generated**: {generated_at.strftime('%Y-%m-%d %H:%M:%S')}")
130
+ lines.append(f"- **Check level**: {check_level}")
131
+ lines.append(f"- **Status**: {status}")
132
+ if project_id:
133
+ lines.append(f"- **Project ID**: {project_id}")
134
+ exec_time = metadata.get("execution_time_seconds")
135
+ if exec_time is not None:
136
+ lines.append(f"- **Execution time**: {exec_time:.2f}s")
137
+ lines.append("")
138
+
139
+ # Summary
140
+ lines.append("## Summary")
141
+ lines.append("")
142
+ lines.append("| Metric | Value |")
143
+ lines.append("|---|---|")
144
+ total = summary.get("total_defects", 0)
145
+ p0 = summary.get("p0_count", 0)
146
+ p1 = summary.get("p1_count", 0)
147
+ p2 = summary.get("p2_count", 0)
148
+ gate = summary.get("gate_result", "PASS")
149
+ lines.append(f"| Total defects | {total} |")
150
+ lines.append(f"| Severity | P0: {p0} / P1: {p1} / P2: {p2} |")
151
+ lines.append(f"| Gate | {gate} |")
152
+ lines.append("")
153
+
154
+ # Separate CROSS from other modules and merge
155
+ module_results = [r for r in results if r.get("module") != "CROSS"]
156
+ cross_results = [r for r in results if r.get("module") == "CROSS"]
157
+
158
+ if cross_results:
159
+ # Merge all CROSS sub-results into single section
160
+ cross_defects = [d for r in cross_results for d in (r.get("defects") or [])]
161
+ mapped_defects = _aggregate_cross_defects(cross_results)
162
+ cross_score = _calculate_cross_score(mapped_defects, check_level)
163
+
164
+ module_results.append({
165
+ "module": "CROSS",
166
+ "status": "completed",
167
+ "defects": cross_defects,
168
+ "score": cross_score,
169
+ })
170
+
171
+ # Per-module results
172
+ for result in module_results:
173
+ module = result.get("module", "")
174
+ label = _MODULE_LABELS.get(module, module)
175
+ result_status = result.get("status", "unknown")
176
+ score = result.get("score")
177
+ defects = result.get("defects", [])
178
+
179
+ score_str = _format_score(score)
180
+ header = f"## {label} ({module}) — {result_status}"
181
+ if score_str:
182
+ header += f", {score_str}"
183
+ lines.append(header)
184
+ lines.append("")
185
+
186
+ if not defects:
187
+ lines.append("No defects found.")
188
+ lines.append("")
189
+ continue
190
+
191
+ lines.append("| ID | Name | Severity | Description | Location | Impact | Fix |")
192
+ lines.append("|---|---|---|---|---|---|---|")
193
+ for defect in defects:
194
+ defect_id = _esc_md_cell(defect.get("id"))
195
+ name = _esc_md_cell(defect.get("name"))
196
+ severity = _esc_md_cell(defect.get("severity"))
197
+ description = _esc_md_cell(defect.get("description"))
198
+ location = _esc_md_cell(defect.get("location"))
199
+ impact = _esc_md_cell(defect.get("impact"))
200
+ fix = _esc_md_cell(defect.get("fix_suggestion"))
201
+ lines.append(
202
+ f"| {defect_id} | {name} | {severity} | {description} |"
203
+ f" {location} | {impact} | {fix} |"
204
+ )
205
+ lines.append("")
206
+
207
+ # Errors section
208
+ if errors:
209
+ lines.append("## Errors")
210
+ lines.append("")
211
+ for err in errors:
212
+ code = err.get("code", "unknown")
213
+ message = err.get("message", "")
214
+ retryable = err.get("retryable", False)
215
+ lines.append(f"- **{code}** (retryable={retryable}): {message}")
216
+ lines.append("")
217
+
218
+ return "\n".join(lines)
219
+
220
+
221
+ def save_markdown_report(
222
+ response: dict[str, Any],
223
+ output_dir: Path,
224
+ *,
225
+ project_id: str | None = None,
226
+ check_level: str = "L2",
227
+ ) -> Path:
228
+ """Persist a full markdown report and return the written file path.
229
+
230
+ Args:
231
+ response: Raw dict returned by defect_check.check().
232
+ output_dir: Directory where the report will be written.
233
+ project_id: Optional project identifier to include in the report.
234
+ check_level: Inspection level (L1/L2/L3) to include in the report.
235
+
236
+ Returns:
237
+ Path to the written markdown file.
238
+ """
239
+ output_dir = output_dir.expanduser().resolve()
240
+ output_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ generated_at = datetime.now()
243
+ filename = f"inspect-{generated_at.strftime('%Y%m%d-%H%M%S-%f')}.md"
244
+ path = output_dir / filename
245
+
246
+ body = _build_report(response, project_id=project_id, check_level=check_level, generated_at=generated_at)
247
+ path.write_text(body, encoding="utf-8")
248
+
249
+ return path