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