sanityops-cli 0.1.6__tar.gz → 0.1.7__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.7}/PKG-INFO +2 -2
  2. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/pyproject.toml +2 -2
  3. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/__init__.py +1 -1
  4. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/defect_checker/markdown_reporter.py +42 -0
  5. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/defect_checker/renderer.py +74 -6
  6. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/defect_checker/test_markdown_reporter.py +226 -0
  7. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/defect_checker/test_renderer.py +296 -0
  8. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  9. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  10. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/dependabot.yml +0 -0
  13. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/scripts/pr_review_bot.py +0 -0
  14. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/workflows/ai-review.yml +0 -0
  15. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/workflows/ci.yml +0 -0
  16. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.github/workflows/release.yml +0 -0
  17. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/.gitignore +0 -0
  18. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/CHANGELOG.md +0 -0
  19. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/CODE_OF_CONDUCT.md +0 -0
  20. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/CONTRIBUTING.md +0 -0
  21. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/LICENSE +0 -0
  22. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/NOTICE +0 -0
  23. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/README.md +0 -0
  24. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/SECURITY.md +0 -0
  25. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-08-13-model-config-comments-in-init-template.md +0 -0
  26. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-08-14-loading-animations.md +0 -0
  27. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-08-14-skills-files-banner-readme.md +0 -0
  28. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-08-28-copyright-compliance.md +0 -0
  29. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-09-08-config-command.md +0 -0
  30. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-09-20-cross-artifact-rendering.md +0 -0
  31. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-09-20-help-panel-migration.md +0 -0
  32. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/plans/2026-09-20-repair-feature.md +0 -0
  33. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-08-13-model-config-comments-in-init-template-design.md +0 -0
  34. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-08-14-loading-animations-design.md +0 -0
  35. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-08-14-skills-files-banner-readme-design.md +0 -0
  36. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-08-28-copyright-compliance-design.md +0 -0
  37. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-09-08-config-command-design.md +0 -0
  38. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-09-20-cross-artifact-rendering-design.md +0 -0
  39. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-09-20-help-panel-migration-design.md +0 -0
  40. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/docs/superpowers/specs/2026-09-20-repair-feature-design.md +0 -0
  41. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/entry.py +0 -0
  42. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/release.sh +0 -0
  43. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/scripts/generate-checksums.ps1 +0 -0
  44. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/scripts/install.ps1 +0 -0
  45. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/scripts/install.sh +0 -0
  46. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/__init__.py +0 -0
  47. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/repair_agent/__init__.py +0 -0
  48. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/repair_agent/agent.py +0 -0
  49. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/repair_agent/prompts.py +0 -0
  50. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/repair_agent/tools/__init__.py +0 -0
  51. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +0 -0
  52. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/agent.py +0 -0
  53. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +0 -0
  54. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/models/finding.py +0 -0
  55. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/prompts.py +0 -0
  56. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/tools/grep_tool.py +0 -0
  57. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +0 -0
  58. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +0 -0
  59. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +0 -0
  60. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/api/__init__.py +0 -0
  61. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/api/client.py +0 -0
  62. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/commands/__init__.py +0 -0
  63. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/commands/config.py +0 -0
  64. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/commands/init.py +0 -0
  65. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/commands/inspect.py +0 -0
  66. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/constants/__init__.py +0 -0
  67. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/constants/config_defaults.py +0 -0
  68. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/constants/exit_codes.py +0 -0
  69. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/defect_checker/__init__.py +0 -0
  70. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/defect_checker/checker.py +0 -0
  71. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/defect_checker/llm_config.py +0 -0
  72. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/exceptions/__init__.py +0 -0
  73. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/exceptions/api_exceptions.py +0 -0
  74. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/exceptions/base_exceptions.py +0 -0
  75. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/help_panel.py +0 -0
  76. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/logging/__init__.py +0 -0
  77. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/logging/logger.py +0 -0
  78. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/main.py +0 -0
  79. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/progress/__init__.py +0 -0
  80. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/progress/tracker.py +0 -0
  81. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/renderers/__init__.py +0 -0
  82. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +0 -0
  83. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/templates/__init__.py +0 -0
  84. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/templates/inspect_config.yaml +0 -0
  85. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/utils/__init__.py +0 -0
  86. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/utils/artifact_packer.py +0 -0
  87. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/utils/config_loader.py +0 -0
  88. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/utils/config_resolver.py +0 -0
  89. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/src/sanityops_cli/utils/validators.py +0 -0
  90. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/__init__.py +0 -0
  91. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/commands/__init__.py +0 -0
  92. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/commands/test_config.py +0 -0
  93. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/commands/test_init.py +0 -0
  94. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/commands/test_inspect_repair.py +0 -0
  95. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/test_help_panel.py +0 -0
  96. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/__init__.py +0 -0
  97. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/__init__.py +0 -0
  98. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/repair_agent/__init__.py +0 -0
  99. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/repair_agent/test_agent.py +0 -0
  100. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/repair_agent/test_store_repairs_tool.py +0 -0
  101. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/__init__.py +0 -0
  102. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/models/__init__.py +0 -0
  103. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/models/test_finding.py +0 -0
  104. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/test_agent.py +0 -0
  105. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/test_deterministic_capture.py +0 -0
  106. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/test_progress_hook.py +0 -0
  107. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/agents/scanner_agent/test_prompts.py +0 -0
  108. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/api/__init__.py +0 -0
  109. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/api/test_client.py +0 -0
  110. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/commands/test_inspect.py +0 -0
  111. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/commands/test_version.py +0 -0
  112. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/defect_checker/__init__.py +0 -0
  113. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/defect_checker/test_checker.py +0 -0
  114. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/defect_checker/test_llm_config.py +0 -0
  115. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/logging/__init__.py +0 -0
  116. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/logging/test_logger.py +0 -0
  117. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/progress/__init__.py +0 -0
  118. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/progress/test_tracker.py +0 -0
  119. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/utils/test_artifact_packer.py +0 -0
  120. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/tests/unit/utils/test_config_loader.py +0 -0
  121. {sanityops_cli-0.1.6 → sanityops_cli-0.1.7}/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.7
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.7"
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.7"
@@ -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
  # ------------------------------------------------------------------
@@ -319,3 +319,229 @@ class TestCrossArtifactReport:
319
319
  assert content.count("## Cross (CROSS)") == 1
320
320
  assert "QD-PS-1" in content
321
321
  assert "QD-PT-1" in content
322
+
323
+
324
+ class TestPermissionReport:
325
+ """Tests for QD-PM permission defect markdown reporting."""
326
+
327
+ def _permission_defect(
328
+ self,
329
+ defect_id="QD-PM-1.1",
330
+ severity="P0",
331
+ name="Permission overflow",
332
+ action="read",
333
+ permission_side="skill -> tool -> params",
334
+ duty_side="skill -> para.3",
335
+ fix="Narrow scope",
336
+ ):
337
+ """Create a permission defect dict for testing."""
338
+ return {
339
+ "id": defect_id,
340
+ "name": name,
341
+ "severity": severity,
342
+ "category": "permission",
343
+ "description": "Permission exceeds duty boundary",
344
+ "fix_suggestion": fix,
345
+ "details": {
346
+ "action": action,
347
+ "permission_side": permission_side,
348
+ "duty_side": duty_side,
349
+ },
350
+ }
351
+
352
+ def test_permission_module_uses_extended_table(self, tmp_path):
353
+ """QD-PM defects use extended table with permission columns."""
354
+ response = _response(
355
+ results=[
356
+ {
357
+ "module": "QD-PM",
358
+ "status": "completed",
359
+ "defects": [self._permission_defect()],
360
+ }
361
+ ]
362
+ )
363
+ path = save_markdown_report(response, tmp_path)
364
+ content = path.read_text(encoding="utf-8")
365
+ assert "## Permissions (QD-PM)" in content
366
+ # Extended table header
367
+ assert "| ID | Name | Severity | Description | Action | Permission Side | Duty Side | Fix |" in content
368
+ # Data row with permission fields
369
+ assert "QD-PM-1.1" in content
370
+ assert "read" in content
371
+ # Note: `->` is HTML-escaped to `->` by _esc_md_cell
372
+ assert "skill -> tool -> params" in content
373
+
374
+ def test_permission_defect_without_details(self, tmp_path):
375
+ """Permission defects without details field render with dashes."""
376
+ response = _response(
377
+ results=[
378
+ {
379
+ "module": "QD-PM",
380
+ "status": "completed",
381
+ "defects": [
382
+ {
383
+ "id": "QD-PM-2.1",
384
+ "name": "Permission issue",
385
+ "severity": "P1",
386
+ "category": "permission",
387
+ "fix_suggestion": "Fix it",
388
+ # No details dict
389
+ }
390
+ ],
391
+ }
392
+ ]
393
+ )
394
+ path = save_markdown_report(response, tmp_path)
395
+ content = path.read_text(encoding="utf-8")
396
+ assert "QD-PM-2.1" in content
397
+ # Should still have extended table header
398
+ assert "| Action | Permission Side | Duty Side |" in content
399
+ # Missing fields should show dashes
400
+ assert "—" in content
401
+
402
+ def test_permission_defect_with_empty_details(self, tmp_path):
403
+ """Permission defects with empty details dict render gracefully."""
404
+ response = _response(
405
+ results=[
406
+ {
407
+ "module": "QD-PM",
408
+ "status": "completed",
409
+ "defects": [
410
+ {
411
+ "id": "QD-PM-3.1",
412
+ "name": "Permission issue",
413
+ "severity": "P1",
414
+ "category": "permission",
415
+ "fix_suggestion": "Fix it",
416
+ "details": {}, # Empty dict
417
+ }
418
+ ],
419
+ }
420
+ ]
421
+ )
422
+ path = save_markdown_report(response, tmp_path)
423
+ content = path.read_text(encoding="utf-8")
424
+ assert "QD-PM-3.1" in content
425
+ # Missing fields should show dashes
426
+ assert "—" in content
427
+
428
+ def test_permission_table_multiple_defects(self, tmp_path):
429
+ """Permission table handles multiple defects correctly."""
430
+ response = _response(
431
+ results=[
432
+ {
433
+ "module": "QD-PM",
434
+ "status": "completed",
435
+ "defects": [
436
+ self._permission_defect(
437
+ defect_id="QD-PM-1.1",
438
+ action="read",
439
+ permission_side="skill -> tool -> params.read",
440
+ ),
441
+ self._permission_defect(
442
+ defect_id="QD-PM-1.2",
443
+ action="write",
444
+ permission_side="skill -> tool -> params.write",
445
+ ),
446
+ ],
447
+ }
448
+ ]
449
+ )
450
+ path = save_markdown_report(response, tmp_path)
451
+ content = path.read_text(encoding="utf-8")
452
+ assert "QD-PM-1.1" in content
453
+ assert "QD-PM-1.2" in content
454
+ assert "read" in content
455
+ assert "write" in content
456
+
457
+ def test_permission_alongside_other_modules(self, tmp_path):
458
+ """Permission module coexists with other modules in report."""
459
+ response = _response(
460
+ results=[
461
+ {
462
+ "module": "QDS",
463
+ "status": "completed",
464
+ "defects": [_defect(defect_id="QDS-1", severity="P1")],
465
+ },
466
+ {
467
+ "module": "QD-PM",
468
+ "status": "completed",
469
+ "defects": [self._permission_defect()],
470
+ },
471
+ ]
472
+ )
473
+ path = save_markdown_report(response, tmp_path)
474
+ content = path.read_text(encoding="utf-8")
475
+ # Both modules should appear
476
+ assert "## Skills (QDS)" in content
477
+ assert "## Permissions (QD-PM)" in content
478
+ # QDS should use standard table
479
+ assert "| Description | Location | Impact |" in content
480
+ # QD-PM should use extended table
481
+ assert "| Action | Permission Side | Duty Side |" in content
482
+
483
+
484
+ class TestFormatPermissionTable:
485
+ """Tests for _format_permission_table helper function."""
486
+
487
+ def test_formats_single_defect(self):
488
+ """Formats a single permission defect correctly."""
489
+ from sanityops_cli.defect_checker.markdown_reporter import _format_permission_table
490
+
491
+ defects = [
492
+ {
493
+ "id": "QD-PM-1.1",
494
+ "name": "Permission overflow",
495
+ "severity": "P0",
496
+ "fix_suggestion": "Narrow scope",
497
+ "details": {
498
+ "action": "read",
499
+ "permission_side": "skill -> tool -> params",
500
+ "duty_side": "skill -> para.3",
501
+ },
502
+ }
503
+ ]
504
+ lines = _format_permission_table(defects)
505
+ assert len(lines) == 3 # Header + separator + 1 data row
506
+ assert "QD-PM-1.1" in lines[2]
507
+ assert "read" in lines[2]
508
+
509
+ def test_formats_multiple_defects(self):
510
+ """Formats multiple permission defects correctly."""
511
+ from sanityops_cli.defect_checker.markdown_reporter import _format_permission_table
512
+
513
+ defects = [
514
+ {
515
+ "id": "QD-PM-1.1",
516
+ "name": "First",
517
+ "severity": "P0",
518
+ "details": {"action": "read"},
519
+ },
520
+ {
521
+ "id": "QD-PM-1.2",
522
+ "name": "Second",
523
+ "severity": "P1",
524
+ "details": {"action": "write"},
525
+ },
526
+ ]
527
+ lines = _format_permission_table(defects)
528
+ assert len(lines) == 4 # Header + separator + 2 data rows
529
+ assert "read" in lines[2]
530
+ assert "write" in lines[3]
531
+
532
+ def test_handles_missing_details(self):
533
+ """Handles defects without details dict."""
534
+ from sanityops_cli.defect_checker.markdown_reporter import _format_permission_table
535
+
536
+ defects = [
537
+ {
538
+ "id": "QD-PM-1.1",
539
+ "name": "No details",
540
+ "severity": "P1",
541
+ "fix_suggestion": "Fix it",
542
+ # No details
543
+ }
544
+ ]
545
+ lines = _format_permission_table(defects)
546
+ assert len(lines) == 3
547
+ assert "—" in lines[2] # Missing fields show dash
@@ -308,3 +308,299 @@ class TestResolveArtifactTitle:
308
308
 
309
309
  result = {"artifacts": [{"id": "skill-1", "name": "morning-report"}]}
310
310
  assert resolve_artifact_title(result) is None
311
+
312
+
313
+ class TestPermissionDefects:
314
+ """Tests for QD-PM permission defect rendering."""
315
+
316
+ def _permission_defect(
317
+ self,
318
+ defect_id="QD-PM-1.1",
319
+ severity="P0",
320
+ name="Permission overflow",
321
+ location="skill.md:15",
322
+ impact="Excessive read access",
323
+ fix="Narrow permission scope",
324
+ action="read",
325
+ permission_side="skill -> tool -> params",
326
+ duty_side="skill -> para.3 -> stmt.1",
327
+ ):
328
+ """Create a permission defect dict for testing."""
329
+ return {
330
+ "id": defect_id,
331
+ "name": name,
332
+ "severity": severity,
333
+ "category": "permission",
334
+ "description": "Permission exceeds duty boundary",
335
+ "location": location,
336
+ "impact": impact,
337
+ "fix_suggestion": fix,
338
+ "details": {
339
+ "action": action,
340
+ "permission_side": permission_side,
341
+ "duty_side": duty_side,
342
+ },
343
+ }
344
+
345
+ def test_qdpm_module_rendered(self):
346
+ """QD-PM module is rendered with 'Permissions' label."""
347
+ console = Console(record=True, width=100)
348
+ result = _result(
349
+ [
350
+ {
351
+ "module": "QD-PM",
352
+ "status": "completed",
353
+ "artifacts": [],
354
+ "defects": [self._permission_defect()],
355
+ }
356
+ ]
357
+ )
358
+ DefectRenderer(console).render(result)
359
+ out = console.export_text()
360
+ assert "Permissions" in out
361
+ assert "QD-PM-1.1" in out
362
+
363
+ def test_permission_defect_shows_action_field(self):
364
+ """Permission defects show action field in terminal."""
365
+ console = Console(record=True, width=100)
366
+ result = _result(
367
+ [
368
+ {
369
+ "module": "QD-PM",
370
+ "status": "completed",
371
+ "artifacts": [],
372
+ "defects": [self._permission_defect(action="write")],
373
+ }
374
+ ]
375
+ )
376
+ DefectRenderer(console).render(result)
377
+ out = console.export_text()
378
+ assert "Action" in out
379
+ assert "write" in out
380
+
381
+ def test_permission_defect_shows_permission_side(self):
382
+ """Permission defects show permission_side field."""
383
+ console = Console(record=True, width=120)
384
+ result = _result(
385
+ [
386
+ {
387
+ "module": "QD-PM",
388
+ "status": "completed",
389
+ "artifacts": [],
390
+ "defects": [self._permission_defect(permission_side="skill -> tool -> file")],
391
+ }
392
+ ]
393
+ )
394
+ DefectRenderer(console).render(result)
395
+ out = console.export_text()
396
+ assert "Permission" in out and "skill -> tool -> file" in out
397
+
398
+ def test_permission_defect_shows_duty_side(self):
399
+ """Permission defects show duty_side field."""
400
+ console = Console(record=True, width=120)
401
+ result = _result(
402
+ [
403
+ {
404
+ "module": "QD-PM",
405
+ "status": "completed",
406
+ "artifacts": [],
407
+ "defects": [self._permission_defect(duty_side="skill -> para.5")],
408
+ }
409
+ ]
410
+ )
411
+ DefectRenderer(console).render(result)
412
+ out = console.export_text()
413
+ assert "Duty" in out and "skill -> para.5" in out
414
+
415
+ def test_permission_defect_without_details(self):
416
+ """Permission defects without details field still render."""
417
+ console = Console(record=True, width=100)
418
+ defect = {
419
+ "id": "QD-PM-2.1",
420
+ "name": "Permission issue",
421
+ "severity": "P1",
422
+ "category": "permission",
423
+ "description": "desc",
424
+ "location": "skill.md:20",
425
+ "impact": "impact",
426
+ "fix_suggestion": "fix",
427
+ # No details dict
428
+ }
429
+ result = _result(
430
+ [
431
+ {
432
+ "module": "QD-PM",
433
+ "status": "completed",
434
+ "artifacts": [],
435
+ "defects": [defect],
436
+ }
437
+ ]
438
+ )
439
+ DefectRenderer(console).render(result)
440
+ out = console.export_text()
441
+ assert "QD-PM-2.1" in out
442
+ assert "Location" in out
443
+
444
+ def test_permission_defect_with_empty_details(self):
445
+ """Permission defects with empty details dict render gracefully."""
446
+ console = Console(record=True, width=100)
447
+ defect = {
448
+ "id": "QD-PM-3.1",
449
+ "name": "Permission issue",
450
+ "severity": "P1",
451
+ "category": "permission",
452
+ "description": "desc",
453
+ "location": "skill.md:30",
454
+ "details": {}, # Empty dict
455
+ }
456
+ result = _result(
457
+ [
458
+ {
459
+ "module": "QD-PM",
460
+ "status": "completed",
461
+ "artifacts": [],
462
+ "defects": [defect],
463
+ }
464
+ ]
465
+ )
466
+ DefectRenderer(console).render(result)
467
+ out = console.export_text()
468
+ assert "QD-PM-3.1" in out
469
+ # Should not crash, should show standard fields
470
+
471
+ def test_permission_defect_detected_by_id_prefix(self):
472
+ """Permission defect detected by QD-PM ID prefix even with different module.
473
+
474
+ This tests the ID prefix fallback detection path.
475
+ """
476
+ console = Console(record=True, width=100)
477
+ defect = {
478
+ "id": "QD-PM-4.1", # ID starts with QD-PM
479
+ "name": "Permission issue",
480
+ "severity": "P1",
481
+ "category": "", # No category
482
+ "description": "desc",
483
+ "details": {"action": "read"},
484
+ }
485
+ result = _result(
486
+ [
487
+ {
488
+ "module": "QDS", # Different module - tests ID prefix detection
489
+ "status": "completed",
490
+ "artifacts": [],
491
+ "defects": [defect],
492
+ }
493
+ ]
494
+ )
495
+ DefectRenderer(console).render(result)
496
+ out = console.export_text()
497
+ # Should use permission format due to ID prefix
498
+ assert "Action" in out
499
+ assert "read" in out
500
+
501
+ def test_permission_defect_detected_by_category(self):
502
+ """Permission defect detected by category='permission' with non-QD-PM module and ID.
503
+
504
+ This tests the category-based fallback detection path.
505
+ """
506
+ console = Console(record=True, width=100)
507
+ defect = {
508
+ "id": "MISC-1", # Not QD-PM prefix
509
+ "name": "Permission issue",
510
+ "severity": "P1",
511
+ "category": "permission", # Category triggers detection
512
+ "description": "desc",
513
+ "details": {"action": "write"},
514
+ }
515
+ result = _result(
516
+ [
517
+ {
518
+ "module": "QDT", # Different module - tests category detection
519
+ "status": "completed",
520
+ "artifacts": [],
521
+ "defects": [defect],
522
+ }
523
+ ]
524
+ )
525
+ DefectRenderer(console).render(result)
526
+ out = console.export_text()
527
+ # Should use permission format due to category
528
+ assert "Action" in out
529
+ assert "write" in out
530
+
531
+ def test_permission_defect_with_none_id_no_crash(self):
532
+ """Renderer doesn't crash when defect id is None.
533
+
534
+ This tests the fix for potential AttributeError when id key exists
535
+ but value is None.
536
+ """
537
+ console = Console(record=True, width=100)
538
+ defect = {
539
+ "id": None, # Explicitly None
540
+ "name": "Permission issue",
541
+ "severity": "P1",
542
+ "category": "permission",
543
+ "details": {"action": "read"},
544
+ }
545
+ result = _result(
546
+ [
547
+ {
548
+ "module": "QD-PM",
549
+ "status": "completed",
550
+ "artifacts": [],
551
+ "defects": [defect],
552
+ }
553
+ ]
554
+ )
555
+ # Should not raise AttributeError
556
+ DefectRenderer(console).render(result)
557
+ out = console.export_text()
558
+ assert "Action" in out
559
+
560
+ def test_permission_defect_shows_name_field(self):
561
+ """Permission defects show name field for consistency with markdown."""
562
+ console = Console(record=True, width=100)
563
+ result = _result(
564
+ [
565
+ {
566
+ "module": "QD-PM",
567
+ "status": "completed",
568
+ "artifacts": [],
569
+ "defects": [self._permission_defect(name="Permission overflow")],
570
+ }
571
+ ]
572
+ )
573
+ DefectRenderer(console).render(result)
574
+ out = console.export_text()
575
+ assert "Name" in out
576
+ assert "Permission overflow" in out
577
+
578
+ def test_permission_format_used_when_module_is_qdpm(self):
579
+ """Permission format used when module is QD-PM, even with non-matching ID/category.
580
+
581
+ This tests the edge case where module == "QD-PM" but defect ID doesn't start
582
+ with QD-PM and category is empty - ensuring terminal and markdown stay consistent.
583
+ """
584
+ console = Console(record=True, width=100)
585
+ defect = {
586
+ "id": "OTHER-1", # Not QD-PM prefix
587
+ "name": "Edge case defect",
588
+ "severity": "P1",
589
+ "category": "", # Empty category
590
+ "details": {"action": "read"},
591
+ }
592
+ result = _result(
593
+ [
594
+ {
595
+ "module": "QD-PM", # Module is QD-PM
596
+ "status": "completed",
597
+ "artifacts": [],
598
+ "defects": [defect],
599
+ }
600
+ ]
601
+ )
602
+ DefectRenderer(console).render(result)
603
+ out = console.export_text()
604
+ # Should still use permission format due to module == "QD-PM"
605
+ assert "Action" in out
606
+ assert "read" in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes