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.
Files changed (121) hide show
  1. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/PKG-INFO +2 -2
  2. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/pyproject.toml +2 -2
  3. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/__init__.py +1 -1
  4. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/llm_config.py +5 -2
  5. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/markdown_reporter.py +42 -0
  6. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/renderer.py +74 -6
  7. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_llm_config.py +100 -67
  8. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_markdown_reporter.py +226 -0
  9. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_renderer.py +296 -0
  10. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  13. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/dependabot.yml +0 -0
  15. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/scripts/pr_review_bot.py +0 -0
  16. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/workflows/ai-review.yml +0 -0
  17. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/workflows/ci.yml +0 -0
  18. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.github/workflows/release.yml +0 -0
  19. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/.gitignore +0 -0
  20. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/CHANGELOG.md +0 -0
  21. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/CODE_OF_CONDUCT.md +0 -0
  22. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/CONTRIBUTING.md +0 -0
  23. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/LICENSE +0 -0
  24. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/NOTICE +0 -0
  25. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/README.md +0 -0
  26. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/SECURITY.md +0 -0
  27. {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
  28. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-14-loading-animations.md +0 -0
  29. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-14-skills-files-banner-readme.md +0 -0
  30. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-08-28-copyright-compliance.md +0 -0
  31. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-08-config-command.md +0 -0
  32. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-20-cross-artifact-rendering.md +0 -0
  33. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-20-help-panel-migration.md +0 -0
  34. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/plans/2026-09-20-repair-feature.md +0 -0
  35. {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
  36. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-14-loading-animations-design.md +0 -0
  37. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-14-skills-files-banner-readme-design.md +0 -0
  38. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-08-28-copyright-compliance-design.md +0 -0
  39. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-08-config-command-design.md +0 -0
  40. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-20-cross-artifact-rendering-design.md +0 -0
  41. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-20-help-panel-migration-design.md +0 -0
  42. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/docs/superpowers/specs/2026-09-20-repair-feature-design.md +0 -0
  43. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/entry.py +0 -0
  44. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/release.sh +0 -0
  45. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/scripts/generate-checksums.ps1 +0 -0
  46. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/scripts/install.ps1 +0 -0
  47. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/scripts/install.sh +0 -0
  48. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/__init__.py +0 -0
  49. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/__init__.py +0 -0
  50. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/agent.py +0 -0
  51. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/prompts.py +0 -0
  52. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/tools/__init__.py +0 -0
  53. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +0 -0
  54. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/agent.py +0 -0
  55. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +0 -0
  56. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/models/finding.py +0 -0
  57. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/prompts.py +0 -0
  58. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/grep_tool.py +0 -0
  59. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +0 -0
  60. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +0 -0
  61. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +0 -0
  62. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/api/__init__.py +0 -0
  63. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/api/client.py +0 -0
  64. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/__init__.py +0 -0
  65. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/config.py +0 -0
  66. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/init.py +0 -0
  67. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/commands/inspect.py +0 -0
  68. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/constants/__init__.py +0 -0
  69. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/constants/config_defaults.py +0 -0
  70. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/constants/exit_codes.py +0 -0
  71. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/__init__.py +0 -0
  72. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/defect_checker/checker.py +0 -0
  73. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/exceptions/__init__.py +0 -0
  74. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/exceptions/api_exceptions.py +0 -0
  75. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/exceptions/base_exceptions.py +0 -0
  76. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/help_panel.py +0 -0
  77. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/logging/__init__.py +0 -0
  78. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/logging/logger.py +0 -0
  79. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/main.py +0 -0
  80. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/progress/__init__.py +0 -0
  81. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/progress/tracker.py +0 -0
  82. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/renderers/__init__.py +0 -0
  83. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +0 -0
  84. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/templates/__init__.py +0 -0
  85. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/templates/inspect_config.yaml +0 -0
  86. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/__init__.py +0 -0
  87. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/artifact_packer.py +0 -0
  88. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/config_loader.py +0 -0
  89. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/config_resolver.py +0 -0
  90. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/src/sanityops_cli/utils/validators.py +0 -0
  91. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/__init__.py +0 -0
  92. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/__init__.py +0 -0
  93. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/test_config.py +0 -0
  94. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/test_init.py +0 -0
  95. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/commands/test_inspect_repair.py +0 -0
  96. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/test_help_panel.py +0 -0
  97. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/__init__.py +0 -0
  98. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/__init__.py +0 -0
  99. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/repair_agent/__init__.py +0 -0
  100. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/repair_agent/test_agent.py +0 -0
  101. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/repair_agent/test_store_repairs_tool.py +0 -0
  102. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/__init__.py +0 -0
  103. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/models/__init__.py +0 -0
  104. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/models/test_finding.py +0 -0
  105. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_agent.py +0 -0
  106. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_deterministic_capture.py +0 -0
  107. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_progress_hook.py +0 -0
  108. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/agents/scanner_agent/test_prompts.py +0 -0
  109. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/api/__init__.py +0 -0
  110. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/api/test_client.py +0 -0
  111. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/commands/test_inspect.py +0 -0
  112. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/commands/test_version.py +0 -0
  113. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/__init__.py +0 -0
  114. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/defect_checker/test_checker.py +0 -0
  115. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/logging/__init__.py +0 -0
  116. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/logging/test_logger.py +0 -0
  117. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/progress/__init__.py +0 -0
  118. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/progress/test_tracker.py +0 -0
  119. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/utils/test_artifact_packer.py +0 -0
  120. {sanityops_cli-0.1.6 → sanityops_cli-0.1.8}/tests/unit/utils/test_config_loader.py +0 -0
  121. {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.6
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.6"
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",
@@ -13,4 +13,4 @@
13
13
  # limitations under the License.
14
14
  #
15
15
 
16
- __version__ = "0.1.6"
16
+ __version__ = "0.1.8"
@@ -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.LLM_PROVIDER,
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 "",
@@ -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(self, label: str, defects: list[dict[str, Any]]) -> None:
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
- severity = defect.get("severity", "NONE")
174
- defect_id = defect.get("id") or "defect"
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
- class TestResolveLlmConfig:
11
- def test_returns_four_expected_keys(self):
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 test_maps_provider_config_fields(self):
16
- fake = mock.Mock()
17
- fake.LLM_PROVIDER = "anthropic"
18
- fake.API_KEY = "sk-test"
19
- fake.MODEL_ID = "claude-sonnet-4-20250514"
20
- fake.BASE_URL = "https://api.example.com"
21
- with mock.patch(
22
- "sanityops_cli.defect_checker.llm_config.ProviderConfig",
23
- return_value=fake,
24
- ):
25
- cfg = resolve_llm_config()
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 test_none_base_url_becomes_empty_string(self):
34
- fake = mock.Mock()
35
- fake.LLM_PROVIDER = "openai"
36
- fake.API_KEY = "k"
37
- fake.MODEL_ID = "gpt-4o"
38
- fake.BASE_URL = None
39
- with mock.patch(
40
- "sanityops_cli.defect_checker.llm_config.ProviderConfig",
41
- return_value=fake,
42
- ):
43
- cfg = resolve_llm_config()
44
- assert cfg["llm_base_url"] == ""
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.chdir(tmp_path)
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
- fake = mock.Mock()
121
- fake.LLM_PROVIDER = "env-provider"
122
- fake.API_KEY = "env-key"
123
- fake.MODEL_ID = "env-model"
124
- fake.BASE_URL = "https://env.url"
125
- with mock.patch(
126
- "sanityops_cli.defect_checker.llm_config.ProviderConfig",
127
- return_value=fake,
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
- monkeypatch.chdir(tmp_path)
140
-
141
- fake = mock.Mock()
142
- fake.LLM_PROVIDER = "fallback-provider"
143
- fake.API_KEY = "fallback-key"
144
- fake.MODEL_ID = "fallback-model"
145
- fake.BASE_URL = None
146
- with mock.patch(
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
- # Set up env vars that should be ignored
210
- fake = mock.Mock()
211
- fake.LLM_PROVIDER = "env-provider-should-be-ignored"
212
- fake.API_KEY = "env-key-should-be-ignored"
213
- fake.MODEL_ID = "env-model-should-be-ignored"
214
- fake.BASE_URL = "https://env.url.ignored"
215
- with mock.patch(
216
- "sanityops_cli.defect_checker.llm_config.ProviderConfig",
217
- return_value=fake,
218
- ):
219
- cfg = resolve_llm_config(str(config_file))
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["llm_provider"] == "config-provider"
223
- assert cfg["llm_api_key"] == "config-key"
224
- assert cfg["llm_model_id"] == "config-model"
225
- assert cfg["llm_base_url"] == ""
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
+ }