sanityops-cli 0.1.6__tar.gz → 0.1.8__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.
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/PKG-INFO +2 -2
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/pyproject.toml +2 -2
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/__init__.py +1 -1
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/llm_config.py +5 -2
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/markdown_reporter.py +42 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/renderer.py +74 -6
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_llm_config.py +100 -67
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_markdown_reporter.py +226 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_renderer.py +296 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/dependabot.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/scripts/pr_review_bot.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/workflows/ai-review.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/workflows/ci.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/workflows/release.yml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.gitignore +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/CHANGELOG.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/CODE_OF_CONDUCT.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/CONTRIBUTING.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/LICENSE +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/NOTICE +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/README.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/SECURITY.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-13-model-config-comments-in-init-template.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-14-loading-animations.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-14-skills-files-banner-readme.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-28-copyright-compliance.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-08-config-command.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-20-cross-artifact-rendering.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-20-help-panel-migration.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-20-repair-feature.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-13-model-config-comments-in-init-template-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-14-loading-animations-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-14-skills-files-banner-readme-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-28-copyright-compliance-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-08-config-command-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-20-cross-artifact-rendering-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-20-help-panel-migration-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-20-repair-feature-design.md +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/entry.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/release.sh +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/scripts/generate-checksums.ps1 +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/scripts/install.ps1 +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/scripts/install.sh +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/agent.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/prompts.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/tools/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/agent.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/models/finding.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/prompts.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/grep_tool.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/api/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/api/client.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/config.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/init.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/inspect.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/constants/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/constants/config_defaults.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/constants/exit_codes.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/checker.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/exceptions/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/exceptions/api_exceptions.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/exceptions/base_exceptions.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/help_panel.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/logging/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/logging/logger.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/main.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/progress/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/progress/tracker.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/renderers/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/templates/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/templates/inspect_config.yaml +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/artifact_packer.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/config_loader.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/config_resolver.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/validators.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/test_config.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/test_init.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/test_inspect_repair.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/test_help_panel.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/repair_agent/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/repair_agent/test_agent.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/repair_agent/test_store_repairs_tool.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/models/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/models/test_finding.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_agent.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_deterministic_capture.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_progress_hook.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_prompts.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/api/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/api/test_client.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/commands/test_inspect.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/commands/test_version.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_checker.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/logging/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/logging/test_logger.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/progress/__init__.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/progress/test_tracker.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/utils/test_artifact_packer.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/utils/test_config_loader.py +0 -0
- {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/utils/test_config_resolver.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sanityops-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: CLI tool for static defect inspection of AI logical artifacts
|
|
5
5
|
Project-URL: Homepage, https://sanityops.org
|
|
6
6
|
Project-URL: Documentation, https://sanityops.org
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
23
23
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
24
|
Requires-Python: >=3.11
|
|
25
25
|
Requires-Dist: anyio>=4.0
|
|
26
|
-
Requires-Dist: defect-check
|
|
26
|
+
Requires-Dist: defect-check>=0.0.1
|
|
27
27
|
Requires-Dist: httpx>=0.27
|
|
28
28
|
Requires-Dist: pyyaml>=6.0
|
|
29
29
|
Requires-Dist: rich>=13.0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sanityops-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "CLI tool for static defect inspection of AI logical artifacts"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -31,7 +31,7 @@ dependencies = [
|
|
|
31
31
|
"httpx>=0.27",
|
|
32
32
|
"tomli>=2.0",
|
|
33
33
|
"tomli-w>=1.0",
|
|
34
|
-
"defect-check",
|
|
34
|
+
"defect-check>=0.0.1",
|
|
35
35
|
"pyyaml>=6.0",
|
|
36
36
|
"sanityops-agent>=0.0.4",
|
|
37
37
|
"anyio>=4.0",
|
|
@@ -102,10 +102,13 @@ def resolve_llm_config(config_path: str | None = None) -> dict[str, str]:
|
|
|
102
102
|
if config_values:
|
|
103
103
|
return config_values
|
|
104
104
|
|
|
105
|
-
# Fall back to ProviderConfig (env vars)
|
|
105
|
+
# Fall back to ProviderConfig (env vars). PROVIDER is the field that reads
|
|
106
|
+
# the documented LLM_PROVIDER variable (env_prefix is "LLM_"); the
|
|
107
|
+
# LLM_PROVIDER *alias* field listens on LLM_LLM_PROVIDER, so reading it here
|
|
108
|
+
# left llm_provider None and made the SDK refuse every env-configured run.
|
|
106
109
|
config = ProviderConfig()
|
|
107
110
|
return {
|
|
108
|
-
"llm_provider": config.
|
|
111
|
+
"llm_provider": config.PROVIDER,
|
|
109
112
|
"llm_api_key": config.API_KEY,
|
|
110
113
|
"llm_model_id": config.MODEL_ID,
|
|
111
114
|
"llm_base_url": config.BASE_URL or "",
|
{sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/markdown_reporter.py
RENAMED
|
@@ -77,6 +77,40 @@ def _aggregate_cross_defects(cross_results: list[dict]) -> list[dict]:
|
|
|
77
77
|
return defects
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def _format_permission_table(defects: list[dict]) -> list[str]:
|
|
81
|
+
"""Generate extended markdown table for QD-PM permission defects.
|
|
82
|
+
|
|
83
|
+
Columns: ID | Name | Severity | Description | Action | Permission Side | Duty Side | Fix
|
|
84
|
+
|
|
85
|
+
Note: Missing fields display as em dash (—) via _esc_md_cell(None).
|
|
86
|
+
This differs from terminal renderer which silently omits missing fields.
|
|
87
|
+
"""
|
|
88
|
+
lines = []
|
|
89
|
+
lines.append("| ID | Name | Severity | Description | Action | Permission Side | Duty Side | Fix |")
|
|
90
|
+
lines.append("|---|---|---|---|---|---|---|---|")
|
|
91
|
+
|
|
92
|
+
for defect in defects:
|
|
93
|
+
details = defect.get("details", {})
|
|
94
|
+
if not isinstance(details, dict):
|
|
95
|
+
details = {}
|
|
96
|
+
|
|
97
|
+
defect_id = _esc_md_cell(defect.get("id") or "unknown-permission-defect")
|
|
98
|
+
name = _esc_md_cell(defect.get("name"))
|
|
99
|
+
severity = _esc_md_cell(defect.get("severity"))
|
|
100
|
+
description = _esc_md_cell(defect.get("description"))
|
|
101
|
+
action = _esc_md_cell(details.get("action"))
|
|
102
|
+
permission_side = _esc_md_cell(details.get("permission_side"))
|
|
103
|
+
duty_side = _esc_md_cell(details.get("duty_side"))
|
|
104
|
+
fix = _esc_md_cell(defect.get("fix_suggestion"))
|
|
105
|
+
|
|
106
|
+
lines.append(
|
|
107
|
+
f"| {defect_id} | {name} | {severity} | {description} | {action} | "
|
|
108
|
+
f"{permission_side} | {duty_side} | {fix} |"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return lines
|
|
112
|
+
|
|
113
|
+
|
|
80
114
|
def _calculate_cross_score(
|
|
81
115
|
defects: list[dict],
|
|
82
116
|
check_level: str,
|
|
@@ -188,6 +222,14 @@ def _build_report(
|
|
|
188
222
|
lines.append("")
|
|
189
223
|
continue
|
|
190
224
|
|
|
225
|
+
# Use permission-specific table for QD-PM module.
|
|
226
|
+
# Renderer also detects via module (primary), category, or ID prefix for consistency.
|
|
227
|
+
if module == "QD-PM":
|
|
228
|
+
lines.extend(_format_permission_table(defects))
|
|
229
|
+
lines.append("")
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Standard defect table for other modules
|
|
191
233
|
lines.append("| ID | Name | Severity | Description | Location | Impact | Fix |")
|
|
192
234
|
lines.append("|---|---|---|---|---|---|---|")
|
|
193
235
|
for defect in defects:
|
|
@@ -33,6 +33,7 @@ _MODULE_LABELS: dict[str, str] = {
|
|
|
33
33
|
"QDT": "Tools",
|
|
34
34
|
"QDP": "Prompts",
|
|
35
35
|
"CROSS": "Cross",
|
|
36
|
+
"QD-PM": "Permissions",
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
#: Map module names to singular type labels for panel titles.
|
|
@@ -40,6 +41,7 @@ _MODULE_TYPE_LABELS: dict[str, str] = {
|
|
|
40
41
|
"QDS": "Skill",
|
|
41
42
|
"QDT": "Tool",
|
|
42
43
|
"QDP": "Prompt",
|
|
44
|
+
"QD-PM": "Permission",
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
|
|
@@ -147,20 +149,23 @@ class DefectRenderer:
|
|
|
147
149
|
|
|
148
150
|
def _render_artifact_groups(self, results: list[dict[str, Any]]) -> None:
|
|
149
151
|
for result in results:
|
|
152
|
+
module = result.get("module", "")
|
|
150
153
|
title = resolve_artifact_title(result)
|
|
151
154
|
if title is None:
|
|
152
155
|
continue
|
|
153
156
|
defects = result.get("defects", [])
|
|
154
157
|
if not defects:
|
|
155
158
|
continue
|
|
156
|
-
self._render_group(title, defects)
|
|
159
|
+
self._render_group(title, defects, module=module)
|
|
157
160
|
self.console.print()
|
|
158
161
|
|
|
159
|
-
def _render_group(
|
|
162
|
+
def _render_group(
|
|
163
|
+
self, label: str, defects: list[dict[str, Any]], *, module: str = ""
|
|
164
|
+
) -> None:
|
|
160
165
|
lines: list[str] = []
|
|
161
166
|
shown = defects[:MAX_DEFECTS_PER_GROUP]
|
|
162
167
|
for defect in shown:
|
|
163
|
-
lines.append(self._format_defect(defect))
|
|
168
|
+
lines.append(self._format_defect(defect, module=module))
|
|
164
169
|
|
|
165
170
|
hidden = len(defects) - len(shown)
|
|
166
171
|
if hidden > 0:
|
|
@@ -169,9 +174,27 @@ class DefectRenderer:
|
|
|
169
174
|
body = "\n".join(lines)
|
|
170
175
|
self.console.print(Panel(body, title=label, border_style="cyan"))
|
|
171
176
|
|
|
172
|
-
def _format_defect(self, defect: dict[str, Any]) -> str:
|
|
173
|
-
|
|
174
|
-
|
|
177
|
+
def _format_defect(self, defect: dict[str, Any], *, module: str = "") -> str:
|
|
178
|
+
"""Format a defect for terminal display.
|
|
179
|
+
|
|
180
|
+
Dispatches to specialized formatter for permission defects.
|
|
181
|
+
|
|
182
|
+
Detection priority: module == "QD-PM" > category == "permission" > ID prefix.
|
|
183
|
+
This is more permissive than markdown_reporter's module-only detection.
|
|
184
|
+
Rationale: terminal output is per-defect and can adapt based on defect
|
|
185
|
+
content, while markdown uses module-level table structure. The primary
|
|
186
|
+
path (module == "QD-PM") is shared; fallbacks handle edge cases where
|
|
187
|
+
defect metadata is inconsistent. This is intentional design.
|
|
188
|
+
"""
|
|
189
|
+
# Detect permission defects via module, category, or ID prefix
|
|
190
|
+
category = defect.get("category") or ""
|
|
191
|
+
raw_defect_id = defect.get("id") or ""
|
|
192
|
+
if module == "QD-PM" or category == "permission" or raw_defect_id.startswith("QD-PM"):
|
|
193
|
+
return self._format_permission_defect(defect)
|
|
194
|
+
|
|
195
|
+
# Default formatting for other defect types
|
|
196
|
+
severity = defect.get("severity") or "NONE"
|
|
197
|
+
defect_id = raw_defect_id or "defect"
|
|
175
198
|
lines = [f"[bold red]✗ [{severity}] {defect_id}[/]"]
|
|
176
199
|
if defect.get("location"):
|
|
177
200
|
lines.append(f" [dim]Location[/] : {defect['location']}")
|
|
@@ -181,6 +204,51 @@ class DefectRenderer:
|
|
|
181
204
|
lines.append(f" [dim]Fix[/] : {defect['fix_suggestion']}")
|
|
182
205
|
return "\n".join(lines)
|
|
183
206
|
|
|
207
|
+
def _format_permission_defect(self, defect: dict[str, Any]) -> str:
|
|
208
|
+
"""Format a QD-PM permission defect with permission-specific fields.
|
|
209
|
+
|
|
210
|
+
Shows: name, description, action, permission_side, duty_side from details dict.
|
|
211
|
+
|
|
212
|
+
Note: Missing fields are silently omitted (unlike markdown which uses em dash).
|
|
213
|
+
This is intentional for terminal output compactness.
|
|
214
|
+
"""
|
|
215
|
+
severity = defect.get("severity") or "NONE"
|
|
216
|
+
defect_id = defect.get("id") or "unknown-permission-defect"
|
|
217
|
+
lines = [f"[bold red]✗ [{severity}] {defect_id}[/]"]
|
|
218
|
+
|
|
219
|
+
# Name field (for consistency with markdown table)
|
|
220
|
+
if defect.get("name"):
|
|
221
|
+
lines.append(f" [dim]Name[/] : {defect['name']}")
|
|
222
|
+
|
|
223
|
+
# Description field (standard field)
|
|
224
|
+
if defect.get("description"):
|
|
225
|
+
lines.append(f" [dim]Desc[/] : {defect['description']}")
|
|
226
|
+
|
|
227
|
+
# Standard fields
|
|
228
|
+
if defect.get("location"):
|
|
229
|
+
lines.append(f" [dim]Location[/] : {defect['location']}")
|
|
230
|
+
if defect.get("impact"):
|
|
231
|
+
lines.append(f" [dim]Impact[/] : {defect['impact']}")
|
|
232
|
+
|
|
233
|
+
# Permission-specific fields from details dict
|
|
234
|
+
details = defect.get("details", {})
|
|
235
|
+
if isinstance(details, dict):
|
|
236
|
+
action = details.get("action")
|
|
237
|
+
if action:
|
|
238
|
+
lines.append(f" [dim]Action[/] : {action}")
|
|
239
|
+
permission_side = details.get("permission_side")
|
|
240
|
+
if permission_side:
|
|
241
|
+
lines.append(f" [dim]Permission[/]: {permission_side}")
|
|
242
|
+
duty_side = details.get("duty_side")
|
|
243
|
+
if duty_side:
|
|
244
|
+
lines.append(f" [dim]Duty[/] : {duty_side}")
|
|
245
|
+
|
|
246
|
+
# Fix suggestion
|
|
247
|
+
if defect.get("fix_suggestion"):
|
|
248
|
+
lines.append(f" [dim]Fix[/] : {defect['fix_suggestion']}")
|
|
249
|
+
|
|
250
|
+
return "\n".join(lines)
|
|
251
|
+
|
|
184
252
|
# ------------------------------------------------------------------
|
|
185
253
|
# Footer
|
|
186
254
|
# ------------------------------------------------------------------
|
|
@@ -1,47 +1,85 @@
|
|
|
1
1
|
"""Unit tests for LLM config resolution."""
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from unittest import mock
|
|
4
3
|
|
|
5
4
|
import yaml
|
|
6
5
|
|
|
7
6
|
from sanityops_cli.defect_checker.llm_config import resolve_llm_config
|
|
8
7
|
|
|
8
|
+
LLM_ENV_NAMES = (
|
|
9
|
+
"LLM_PROVIDER",
|
|
10
|
+
"LLM_LLM_PROVIDER",
|
|
11
|
+
"LLM_API_KEY",
|
|
12
|
+
"LLM_MODEL_ID",
|
|
13
|
+
"LLM_BASE_URL",
|
|
14
|
+
)
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
|
|
17
|
+
def _set_llm_env(monkeypatch, tmp_path: Path, **values: str) -> None:
|
|
18
|
+
"""Point the LLM_* environment variables at `values`, clearing the rest.
|
|
19
|
+
|
|
20
|
+
Also chdirs into tmp_path so ProviderConfig's `.env` lookup (env_file)
|
|
21
|
+
cannot leak values from the repository into the test.
|
|
22
|
+
"""
|
|
23
|
+
monkeypatch.chdir(tmp_path)
|
|
24
|
+
for name in LLM_ENV_NAMES:
|
|
25
|
+
monkeypatch.delenv(name, raising=False)
|
|
26
|
+
for name, value in values.items():
|
|
27
|
+
monkeypatch.setenv(name, value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestResolveLlmConfigFromEnvironment:
|
|
31
|
+
"""The env fallback must read the documented LLM_* variable names.
|
|
32
|
+
|
|
33
|
+
Regression: resolve_llm_config read ``ProviderConfig.LLM_PROVIDER``, the
|
|
34
|
+
deprecated constructor alias, instead of ``ProviderConfig.PROVIDER``. The
|
|
35
|
+
settings model sets ``env_prefix="LLM_"``, so that alias listens on
|
|
36
|
+
``LLM_LLM_PROVIDER``; the documented ``LLM_PROVIDER`` never reached the
|
|
37
|
+
defect-check SDK, which refused the run with LLM_CONFIGURATION_MISSING and
|
|
38
|
+
left the report at zero defects with a PASS gate.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def test_returns_four_expected_keys(self, tmp_path: Path, monkeypatch):
|
|
42
|
+
_set_llm_env(monkeypatch, tmp_path)
|
|
12
43
|
cfg = resolve_llm_config()
|
|
13
44
|
assert set(cfg.keys()) == {"llm_provider", "llm_api_key", "llm_model_id", "llm_base_url"}
|
|
14
45
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
assert cfg == {
|
|
46
|
+
def test_reads_documented_env_var_names(self, tmp_path: Path, monkeypatch):
|
|
47
|
+
"""LLM_PROVIDER and friends resolve into the provider the SDK receives."""
|
|
48
|
+
_set_llm_env(
|
|
49
|
+
monkeypatch,
|
|
50
|
+
tmp_path,
|
|
51
|
+
LLM_PROVIDER="anthropic",
|
|
52
|
+
LLM_API_KEY="sk-test",
|
|
53
|
+
LLM_MODEL_ID="claude-sonnet-4-20250514",
|
|
54
|
+
LLM_BASE_URL="https://api.example.com",
|
|
55
|
+
)
|
|
56
|
+
assert resolve_llm_config() == {
|
|
27
57
|
"llm_provider": "anthropic",
|
|
28
58
|
"llm_api_key": "sk-test",
|
|
29
59
|
"llm_model_id": "claude-sonnet-4-20250514",
|
|
30
60
|
"llm_base_url": "https://api.example.com",
|
|
31
61
|
}
|
|
32
62
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
def test_deprecated_double_prefixed_provider_name_still_resolves(self, tmp_path: Path, monkeypatch):
|
|
64
|
+
"""LLM_LLM_PROVIDER is the deprecated alias' own env name; keep it working."""
|
|
65
|
+
_set_llm_env(
|
|
66
|
+
monkeypatch,
|
|
67
|
+
tmp_path,
|
|
68
|
+
LLM_LLM_PROVIDER="openai",
|
|
69
|
+
LLM_API_KEY="sk-test",
|
|
70
|
+
LLM_MODEL_ID="gpt-4o",
|
|
71
|
+
)
|
|
72
|
+
assert resolve_llm_config()["llm_provider"] == "openai"
|
|
73
|
+
|
|
74
|
+
def test_unset_base_url_becomes_empty_string(self, tmp_path: Path, monkeypatch):
|
|
75
|
+
_set_llm_env(
|
|
76
|
+
monkeypatch,
|
|
77
|
+
tmp_path,
|
|
78
|
+
LLM_PROVIDER="openai",
|
|
79
|
+
LLM_API_KEY="k",
|
|
80
|
+
LLM_MODEL_ID="gpt-4o",
|
|
81
|
+
)
|
|
82
|
+
assert resolve_llm_config()["llm_base_url"] == ""
|
|
45
83
|
|
|
46
84
|
|
|
47
85
|
class TestResolveLlmConfigFromConfigFile:
|
|
@@ -103,7 +141,7 @@ class TestResolveLlmConfigFromConfigFile:
|
|
|
103
141
|
|
|
104
142
|
def test_model_section_absent_falls_back_to_env_vars(self, tmp_path: Path, monkeypatch):
|
|
105
143
|
"""Should fall back to ProviderConfig when model section absent."""
|
|
106
|
-
monkeypatch
|
|
144
|
+
_set_llm_env(monkeypatch, tmp_path)
|
|
107
145
|
config_dir = tmp_path / ".sanityops"
|
|
108
146
|
config_dir.mkdir()
|
|
109
147
|
config_file = config_dir / "inspect_config.yaml"
|
|
@@ -117,17 +155,15 @@ class TestResolveLlmConfigFromConfigFile:
|
|
|
117
155
|
with open(config_file, "w") as f:
|
|
118
156
|
yaml.dump(config_content, f)
|
|
119
157
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
)
|
|
129
|
-
cfg = resolve_llm_config(str(config_file))
|
|
130
|
-
assert cfg == {
|
|
158
|
+
_set_llm_env(
|
|
159
|
+
monkeypatch,
|
|
160
|
+
tmp_path,
|
|
161
|
+
LLM_PROVIDER="env-provider",
|
|
162
|
+
LLM_API_KEY="env-key",
|
|
163
|
+
LLM_MODEL_ID="env-model",
|
|
164
|
+
LLM_BASE_URL="https://env.url",
|
|
165
|
+
)
|
|
166
|
+
assert resolve_llm_config(str(config_file)) == {
|
|
131
167
|
"llm_provider": "env-provider",
|
|
132
168
|
"llm_api_key": "env-key",
|
|
133
169
|
"llm_model_id": "env-model",
|
|
@@ -136,19 +172,14 @@ class TestResolveLlmConfigFromConfigFile:
|
|
|
136
172
|
|
|
137
173
|
def test_config_file_missing_falls_back_to_env_vars(self, tmp_path: Path, monkeypatch):
|
|
138
174
|
"""Should fall back to ProviderConfig when config file doesn't exist."""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"sanityops_cli.defect_checker.llm_config.ProviderConfig",
|
|
148
|
-
return_value=fake,
|
|
149
|
-
):
|
|
150
|
-
cfg = resolve_llm_config("/nonexistent/config.yaml")
|
|
151
|
-
assert cfg == {
|
|
175
|
+
_set_llm_env(
|
|
176
|
+
monkeypatch,
|
|
177
|
+
tmp_path,
|
|
178
|
+
LLM_PROVIDER="fallback-provider",
|
|
179
|
+
LLM_API_KEY="fallback-key",
|
|
180
|
+
LLM_MODEL_ID="fallback-model",
|
|
181
|
+
)
|
|
182
|
+
assert resolve_llm_config("/nonexistent/config.yaml") == {
|
|
152
183
|
"llm_provider": "fallback-provider",
|
|
153
184
|
"llm_api_key": "fallback-key",
|
|
154
185
|
"llm_model_id": "fallback-model",
|
|
@@ -206,20 +237,22 @@ class TestResolveLlmConfigFromConfigFile:
|
|
|
206
237
|
with open(config_file, "w") as f:
|
|
207
238
|
yaml.dump(config_content, f)
|
|
208
239
|
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
"
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
240
|
+
# Env vars that must be ignored in favour of the config file's model: section
|
|
241
|
+
_set_llm_env(
|
|
242
|
+
monkeypatch,
|
|
243
|
+
tmp_path,
|
|
244
|
+
LLM_PROVIDER="env-provider-should-be-ignored",
|
|
245
|
+
LLM_API_KEY="env-key-should-be-ignored",
|
|
246
|
+
LLM_MODEL_ID="env-model-should-be-ignored",
|
|
247
|
+
LLM_BASE_URL="https://env.url.ignored",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
cfg = resolve_llm_config(str(config_file))
|
|
220
251
|
|
|
221
252
|
# Config values win
|
|
222
|
-
assert cfg
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
253
|
+
assert cfg == {
|
|
254
|
+
"llm_provider": "config-provider",
|
|
255
|
+
"llm_api_key": "config-key",
|
|
256
|
+
"llm_model_id": "config-model",
|
|
257
|
+
"llm_base_url": "",
|
|
258
|
+
}
|