Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,23 +120,6 @@ sanityops-cli inspect --config path/to/config.yaml

`inspect` exits `0` when the run completes — including when defects are found — and non-zero on failure. CI pipelines should therefore gate on the generated report (`.sanityops/results/inspect-*.md`), not on the exit code.

### 4. Generate Repairs (Optional)

After inspection, generate repaired artifacts from the report:

```bash
# Use the latest inspection report
sanityops-cli inspect repair

# Use a specific report
sanityops-cli inspect repair --report .sanityops/results/inspect-20250115-120000.md

# Specify timeout and token budget for large projects
sanityops-cli inspect repair --timeout 3600 --token-budget 500000
```

Repairs are written to `.sanityops/repairs/repair-<timestamp>.md` without modifying source files.

## Data Handling

What leaves your machine, and when:
Expand Down
43 changes: 36 additions & 7 deletions src/sanityops_cli/agents/scanner_agent/models/finding.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ class FindingType(StrEnum):

class Section(BaseModel):
"""A markdown section within a skill file."""

title: str = Field(description="Section heading text")
content: str = Field(description="Section body content")


class ToolSchema(BaseModel):
name: str
description: str = ""
parameters: dict = Field(default_factory=dict,description="Tool parameters, JSON Schema (Draft-07) format")
parameters: dict = Field(
default_factory=dict, description="Tool parameters, JSON Schema (Draft-07) format"
)


class SkillContent(BaseModel):
Expand All @@ -54,7 +57,11 @@ class SkillContent(BaseModel):
#: Frontmatter fields that must be positive integers (QDS-0.4). LLM-based
#: extraction can stringify numeric values, so coerce them back to int.
_POSITIVE_INT_FIELDS: tuple[str, ...] = (
"max_items", "max_chars", "timeout_seconds", "max_tool_calls", "max_retries",
"max_items",
"max_chars",
"timeout_seconds",
"max_tool_calls",
"max_retries",
)

@model_validator(mode="after")
Expand Down Expand Up @@ -100,23 +107,45 @@ def to_markdown(self) -> str:
if not parts:
return ""
parts.append(
"\n\n".join(
f"## {section.title}\n\n{section.content}"
for section in self.sections
)
"\n\n".join(f"## {section.title}\n\n{section.content}" for section in self.sections)
)
return "\n".join(parts)


class ToolContent(BaseModel):
"""Extracted content from a tool file."""

name: str = Field(description="Tool name from definition")
description: str = Field(description="Tool description")
parameters: dict[str, Any] = Field(default_factory=dict, description="JSON Schema of parameters")
parameters: dict[str, Any] = Field(
default_factory=dict, description="JSON Schema of parameters"
)
#: pre-inspect-extract alignment: extraction fidelity labels. Default empty
#: when the analyzer does not provide them (backward compatible).
completeness: str = Field(
default="",
description=(
"Completeness level per pre-inspect-extract: full original / "
"assembled / incomplete / summary / not inspectable"
),
)
evidence: str = Field(
default="", description="Short verbatim excerpt from the source proving the extraction"
)


class PromptContent(BaseModel):
content: str
#: pre-inspect-extract alignment: extraction fidelity labels. Default empty
#: when the analyzer does not provide them (backward compatible).
completeness: str = Field(
default="",
description=(
"Completeness level per pre-inspect-extract: full original / "
"original · variable unexpanded / assembled / incomplete / summary / not inspectable"
),
)
evidence: str = Field(default="", description="Short verbatim excerpt from the source")


class Finding(BaseModel):
Expand Down
132 changes: 114 additions & 18 deletions src/sanityops_cli/agents/scanner_agent/prompts.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/sanityops_cli/commands/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ def _write_repairs_markdown(
# ============================================================================


@inspect_app.command("repair")
# @inspect_app.command("repair") # Disabled: repair command is closed
def inspect_repair(
ctx: typer.Context,
# B008: Typer requires function calls in argument defaults for its CLI option
Expand Down
30 changes: 3 additions & 27 deletions src/sanityops_cli/defect_checker/markdown_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,6 @@ def _esc_md_cell(text: str | None) -> str:
return text.strip()


def _format_score(score: dict[str, Any] | None) -> str:
"""Format score/grade/gate for a section header, or empty string."""
if score is None:
return ""
parts = []
if "total_score" in score:
max_score = score.get("max_score", 100.0)
parts.append(f"score {score['total_score']}/{max_score}")
if score.get("grade"):
parts.append(f"grade {score['grade']}")
if score.get("gate_result"):
parts.append(f"gate {score['gate_result']}")
return ", ".join(parts)


def _aggregate_cross_defects(cross_results: list[dict]) -> list[dict]:
"""Aggregate defects from all CROSS sub-results (PS/PT/ST) into one list.

Expand Down Expand Up @@ -335,14 +320,10 @@ def _build_report(
module = result.get("module", "")
label = _MODULE_LABELS.get(module, module)
result_status = result.get("status", "unknown")
score = result.get("score")
defects = _dedup_defects(result.get("defects", []))
error = result.get("error")

score_str = _format_score(score)
header = f"## {label} ({module}) — {result_status}"
if score_str:
header += f", {score_str}"
lines.append(header)
lines.append("")

Expand All @@ -369,20 +350,15 @@ def _build_report(
continue

# Standard defect table for other modules
lines.append("| ID | Name | Severity | Description | Location | Impact | Fix |")
lines.append("|---|---|---|---|---|---|---|")
lines.append("| ID | Name | Severity | Description | Location |")
lines.append("|---|---|---|---|---|")
for defect in defects:
defect_id = _esc_md_cell(defect.get("id"))
name = _esc_md_cell(defect.get("name"))
severity = _esc_md_cell(defect.get("severity"))
description = _esc_md_cell(defect.get("description"))
location = _esc_md_cell(defect.get("location"))
impact = _esc_md_cell(defect.get("impact"))
fix = _esc_md_cell(defect.get("fix_suggestion"))
lines.append(
f"| {defect_id} | {name} | {severity} | {description} |"
f" {location} | {impact} | {fix} |"
)
lines.append(f"| {defect_id} | {name} | {severity} | {description} | {location} |")
lines.append("")

# Errors section
Expand Down
16 changes: 3 additions & 13 deletions src/sanityops_cli/defect_checker/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,7 @@ def _render_artifact_groups(self, results: list[dict[str, Any]]) -> None:
self._render_group(title, defects, module=module)
self.console.print()

def _render_group(
self, label: str, defects: list[dict[str, Any]], *, module: str = ""
) -> None:
def _render_group(self, label: str, defects: list[dict[str, Any]], *, module: str = "") -> None:
lines: list[str] = []
shown = defects[:MAX_DEFECTS_PER_GROUP]
for defect in shown:
Expand Down Expand Up @@ -236,10 +234,7 @@ def _format_defect(self, defect: dict[str, Any], *, module: str = "") -> str:
lines = [f"[bold red]✗ [{severity}] {defect_id}[/]"]
if defect.get("location"):
lines.append(f" [dim]Location[/] : {defect['location']}")
if defect.get("impact"):
lines.append(f" [dim]Impact[/] : {defect['impact']}")
if defect.get("fix_suggestion"):
lines.append(f" [dim]Fix[/] : {defect['fix_suggestion']}")
# Removed: impact, fix_suggestion
return "\n".join(lines)

def _format_permission_defect(self, defect: dict[str, Any]) -> str:
Expand All @@ -265,8 +260,6 @@ def _format_permission_defect(self, defect: dict[str, Any]) -> str:
# Standard fields
if defect.get("location"):
lines.append(f" [dim]Location[/] : {defect['location']}")
if defect.get("impact"):
lines.append(f" [dim]Impact[/] : {defect['impact']}")

# Permission-specific fields from details dict
details = defect.get("details", {})
Expand All @@ -281,10 +274,7 @@ def _format_permission_defect(self, defect: dict[str, Any]) -> str:
if duty_side:
lines.append(f" [dim]Duty[/] : {duty_side}")

# Fix suggestion
if defect.get("fix_suggestion"):
lines.append(f" [dim]Fix[/] : {defect['fix_suggestion']}")

# Removed: impact, fix_suggestion
return "\n".join(lines)

# ------------------------------------------------------------------
Expand Down
5 changes: 2 additions & 3 deletions src/sanityops_cli/help_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
GETTING_STARTED_TEXT = """\
1. Run 'sanityops-cli init' to create the default configuration
2. Edit .sanityops/inspect_config.yaml to configure your project
3. Run 'sanityops-cli inspect' to start the inspection
4. Run 'sanityops-cli inspect repair' to generate fixes (optional)
3. Run 'sanityops-cli inspect' to start the inspection\
"""

ADVANCED_USAGE_TEXT = """\
Expand All @@ -25,7 +24,7 @@ def get_getting_started_panel() -> Panel:
"""Create the Getting Started panel for help output.

Returns:
A Rich Panel containing the 4-step Getting Started workflow.
A Rich Panel containing the 3-step Getting Started workflow.
"""
return Panel(
Text(GETTING_STARTED_TEXT, justify="left"),
Expand Down
17 changes: 8 additions & 9 deletions tests/commands/test_inspect_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ class TestFindLatestReport:
def test_returns_none_when_dir_not_exists(self, tmp_path: Path):
"""Returns None when results directory does not exist."""
from sanityops_cli.commands.inspect import _find_latest_report

result = _find_latest_report(tmp_path / "nonexistent")
assert result is None

def test_returns_none_when_no_reports(self, tmp_path: Path):
"""Returns None when no inspect reports exist."""
from sanityops_cli.commands.inspect import _find_latest_report

(tmp_path / "results").mkdir()
result = _find_latest_report(tmp_path / "results")
assert result is None
Expand Down Expand Up @@ -127,18 +129,15 @@ def test_creates_output_directory(self, tmp_path: Path):


class TestInspectRepairCommand:
"""Integration tests for inspect repair command."""
"""The repair command is disabled (closed); its helpers remain for reference."""

def test_repair_command_shows_help(self):
"""Repair command shows help without error."""
def test_repair_command_not_registered(self):
"""The disabled repair command is no longer registered on the CLI."""
result = runner.invoke(inspect_app, ["repair", "--help"])
assert result.exit_code == 0
assert "repair" in result.output.lower()
assert result.exit_code != 0

def test_repair_command_requires_config_or_report(self, tmp_path: Path, monkeypatch):
"""Repair command fails gracefully when no config exists."""
# Change to temp directory with no config
def test_repair_command_rejected_without_help(self, tmp_path: Path, monkeypatch):
"""Invoking the disabled repair command fails with a usage error."""
monkeypatch.chdir(tmp_path)
result = runner.invoke(inspect_app, ["repair"])
# Should fail because no config exists
assert result.exit_code != 0
4 changes: 1 addition & 3 deletions tests/test_help_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,10 @@ def _run_entry_point(*args: str) -> tuple[int, str]:


def test_getting_started_text_content():
"""Verify the Getting Started text contains all 4 steps."""
"""Verify the Getting Started text contains the inspection steps."""
assert "sanityops-cli init" in GETTING_STARTED_TEXT
assert "inspect_config.yaml" in GETTING_STARTED_TEXT
assert "sanityops-cli inspect" in GETTING_STARTED_TEXT
assert "sanityops-cli inspect repair" in GETTING_STARTED_TEXT
assert "(optional)" in GETTING_STARTED_TEXT


def test_getting_started_panel_returns_panel():
Expand Down
Loading
Loading