reporails-cli 0.0.1__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.
- reporails_cli/.env.example +1 -0
- reporails_cli/__init__.py +24 -0
- reporails_cli/bundled/.semgrepignore +51 -0
- reporails_cli/bundled/__init__.py +31 -0
- reporails_cli/bundled/capability-patterns.yml +54 -0
- reporails_cli/bundled/levels.yml +99 -0
- reporails_cli/core/__init__.py +35 -0
- reporails_cli/core/agents.py +147 -0
- reporails_cli/core/applicability.py +150 -0
- reporails_cli/core/bootstrap.py +147 -0
- reporails_cli/core/cache.py +352 -0
- reporails_cli/core/capability.py +245 -0
- reporails_cli/core/discover.py +362 -0
- reporails_cli/core/engine.py +177 -0
- reporails_cli/core/init.py +309 -0
- reporails_cli/core/levels.py +177 -0
- reporails_cli/core/models.py +329 -0
- reporails_cli/core/opengrep/__init__.py +34 -0
- reporails_cli/core/opengrep/runner.py +203 -0
- reporails_cli/core/opengrep/semgrepignore.py +39 -0
- reporails_cli/core/opengrep/templates.py +138 -0
- reporails_cli/core/registry.py +155 -0
- reporails_cli/core/sarif.py +181 -0
- reporails_cli/core/scorer.py +178 -0
- reporails_cli/core/semantic.py +193 -0
- reporails_cli/core/utils.py +139 -0
- reporails_cli/formatters/__init__.py +19 -0
- reporails_cli/formatters/json.py +137 -0
- reporails_cli/formatters/mcp.py +68 -0
- reporails_cli/formatters/text/__init__.py +32 -0
- reporails_cli/formatters/text/box.py +89 -0
- reporails_cli/formatters/text/chars.py +42 -0
- reporails_cli/formatters/text/compact.py +119 -0
- reporails_cli/formatters/text/components.py +117 -0
- reporails_cli/formatters/text/full.py +135 -0
- reporails_cli/formatters/text/rules.py +50 -0
- reporails_cli/formatters/text/violations.py +92 -0
- reporails_cli/interfaces/__init__.py +1 -0
- reporails_cli/interfaces/cli/__init__.py +7 -0
- reporails_cli/interfaces/cli/main.py +352 -0
- reporails_cli/interfaces/mcp/__init__.py +5 -0
- reporails_cli/interfaces/mcp/server.py +194 -0
- reporails_cli/interfaces/mcp/tools.py +136 -0
- reporails_cli/py.typed +0 -0
- reporails_cli/templates/__init__.py +65 -0
- reporails_cli/templates/cli_box.txt +10 -0
- reporails_cli/templates/cli_cta.txt +4 -0
- reporails_cli/templates/cli_delta.txt +1 -0
- reporails_cli/templates/cli_file_header.txt +1 -0
- reporails_cli/templates/cli_legend.txt +1 -0
- reporails_cli/templates/cli_pending.txt +3 -0
- reporails_cli/templates/cli_violation.txt +1 -0
- reporails_cli/templates/cli_working.txt +2 -0
- reporails_cli-0.0.1.dist-info/METADATA +108 -0
- reporails_cli-0.0.1.dist-info/RECORD +58 -0
- reporails_cli-0.0.1.dist-info/WHEEL +4 -0
- reporails_cli-0.0.1.dist-info/entry_points.txt +3 -0
- reporails_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Character sets for terminal output.
|
|
2
|
+
|
|
3
|
+
Provides Unicode and ASCII character sets for box drawing and icons.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# ASCII mode: set AILS_ASCII=1 or pass ascii=True to format functions
|
|
11
|
+
ASCII_MODE = os.environ.get("AILS_ASCII", "").lower() in ("1", "true", "yes")
|
|
12
|
+
|
|
13
|
+
# Character sets for box drawing
|
|
14
|
+
UNICODE_CHARS = {
|
|
15
|
+
"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝",
|
|
16
|
+
"h": "═", "v": "║",
|
|
17
|
+
"filled": "▓", "empty": "░",
|
|
18
|
+
"check": "✓", "crit": "▲", "high": "!", "med": "○", "low": "·",
|
|
19
|
+
"up": "↑", "down": "↓", "sep": "─",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ASCII_CHARS = {
|
|
23
|
+
"tl": "+", "tr": "+", "bl": "+", "br": "+",
|
|
24
|
+
"h": "-", "v": "|",
|
|
25
|
+
"filled": "#", "empty": ".",
|
|
26
|
+
"check": "*", "crit": "!", "high": "!", "med": "o", "low": "-",
|
|
27
|
+
"up": "^", "down": "v", "sep": "-",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_chars(ascii_mode: bool | None = None) -> dict[str, str]:
|
|
32
|
+
"""Get character set based on mode.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
ascii_mode: Force ASCII mode. If None, uses AILS_ASCII env var.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Character set dictionary
|
|
39
|
+
"""
|
|
40
|
+
if ascii_mode is None:
|
|
41
|
+
ascii_mode = ASCII_MODE
|
|
42
|
+
return ASCII_CHARS if ascii_mode else UNICODE_CHARS
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Compact terminal output formatter.
|
|
2
|
+
|
|
3
|
+
Provides minimal output for non-TTY contexts and quick checks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from reporails_cli.core.models import ScanDelta, ValidationResult
|
|
11
|
+
from reporails_cli.core.scorer import LEVEL_LABELS
|
|
12
|
+
from reporails_cli.formatters import json as json_formatter
|
|
13
|
+
from reporails_cli.formatters.text.chars import get_chars
|
|
14
|
+
from reporails_cli.formatters.text.components import (
|
|
15
|
+
format_legend,
|
|
16
|
+
format_level_delta,
|
|
17
|
+
format_score_delta,
|
|
18
|
+
format_violations_delta,
|
|
19
|
+
get_severity_icons,
|
|
20
|
+
normalize_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_compact(
|
|
25
|
+
result: ValidationResult,
|
|
26
|
+
ascii_mode: bool | None = None,
|
|
27
|
+
show_legend: bool = True,
|
|
28
|
+
delta: ScanDelta | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Format validation result in compact form for Claude Code / non-TTY."""
|
|
31
|
+
data = json_formatter.format_result(result, delta)
|
|
32
|
+
chars = get_chars(ascii_mode)
|
|
33
|
+
lines = []
|
|
34
|
+
|
|
35
|
+
score = data.get("score", 0.0)
|
|
36
|
+
level = data.get("level", "L1")
|
|
37
|
+
level_label = LEVEL_LABELS.get(result.level, "Unknown")
|
|
38
|
+
violations = data.get("violations", [])
|
|
39
|
+
is_partial = data.get("is_partial", True)
|
|
40
|
+
|
|
41
|
+
# Group and dedupe violations
|
|
42
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
43
|
+
for v in violations:
|
|
44
|
+
location = v.get("location", "")
|
|
45
|
+
file_path = location.rsplit(":", 1)[0] if ":" in location else location
|
|
46
|
+
if file_path not in grouped:
|
|
47
|
+
grouped[file_path] = []
|
|
48
|
+
grouped[file_path].append(v)
|
|
49
|
+
|
|
50
|
+
deduped_count = 0
|
|
51
|
+
deduped_grouped: dict[str, list[dict[str, Any]]] = {}
|
|
52
|
+
for file_path, file_violations in grouped.items():
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
unique = [v for v in file_violations if not (v.get("rule_id", "") in seen or seen.add(v.get("rule_id", "")))] # type: ignore[func-returns-value]
|
|
55
|
+
deduped_grouped[file_path] = unique
|
|
56
|
+
deduped_count += len(unique)
|
|
57
|
+
|
|
58
|
+
# Header line with delta indicators
|
|
59
|
+
score_delta_str = format_score_delta(delta, ascii_mode)
|
|
60
|
+
level_delta_str = format_level_delta(delta, ascii_mode)
|
|
61
|
+
violations_delta_str = format_violations_delta(delta, ascii_mode) if deduped_count > 0 else ""
|
|
62
|
+
partial_marker = " (partial)" if is_partial else ""
|
|
63
|
+
if deduped_count > 0:
|
|
64
|
+
lines.append(f"Score: {score:.1f}/10{score_delta_str} ({level_label} ({level}){level_delta_str}){partial_marker} - {deduped_count} violations{violations_delta_str}")
|
|
65
|
+
else:
|
|
66
|
+
lines.append(f"Score: {score:.1f}/10{score_delta_str} ({level_label} ({level}){level_delta_str}){partial_marker} {chars['check']} clean")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
severity_icons = get_severity_icons(chars)
|
|
72
|
+
|
|
73
|
+
for file_path, unique in deduped_grouped.items():
|
|
74
|
+
display_path = normalize_path(file_path)
|
|
75
|
+
lines.append(f"{display_path}:")
|
|
76
|
+
for v in unique:
|
|
77
|
+
sev = v.get("severity", "medium")
|
|
78
|
+
icon = severity_icons.get(sev, "?")
|
|
79
|
+
rule_id = v.get("rule_id", "?")
|
|
80
|
+
check_id = v.get("check_id")
|
|
81
|
+
display_id = f"{rule_id}.{check_id}" if check_id else rule_id
|
|
82
|
+
msg = v.get("message", "")
|
|
83
|
+
location = v.get("location", "")
|
|
84
|
+
line_num = location.rsplit(":", 1)[-1] if ":" in location else "?"
|
|
85
|
+
if len(msg) > 45:
|
|
86
|
+
msg = msg[:42] + "..."
|
|
87
|
+
lines.append(f" {icon} {display_id}:{line_num} {msg}")
|
|
88
|
+
lines.append("")
|
|
89
|
+
|
|
90
|
+
# Pending semantic
|
|
91
|
+
if result.pending_semantic and result.is_partial:
|
|
92
|
+
ps = result.pending_semantic
|
|
93
|
+
lines.append(f"Pending: {ps.rule_count} semantic rules ({', '.join(ps.rules)})")
|
|
94
|
+
lines.append("")
|
|
95
|
+
|
|
96
|
+
# Friction
|
|
97
|
+
friction = data.get("friction", "none")
|
|
98
|
+
friction_level = friction if isinstance(friction, str) else friction.get("level", "none")
|
|
99
|
+
if friction_level != "none":
|
|
100
|
+
lines.append(f"Friction: {friction_level.title()}")
|
|
101
|
+
lines.append("")
|
|
102
|
+
|
|
103
|
+
# Legend footer
|
|
104
|
+
if deduped_count > 0 and show_legend:
|
|
105
|
+
lines.append(format_legend(ascii_mode))
|
|
106
|
+
|
|
107
|
+
return "\n".join(lines).rstrip()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_score(result: ValidationResult, ascii_mode: bool | None = None) -> str:
|
|
111
|
+
"""Format quick score summary for terminal."""
|
|
112
|
+
level_label = LEVEL_LABELS.get(result.level, "Unknown")
|
|
113
|
+
violation_count = len(result.violations)
|
|
114
|
+
partial = " (partial)" if result.is_partial else ""
|
|
115
|
+
|
|
116
|
+
if violation_count == 0:
|
|
117
|
+
return f"ails: {result.score:.1f}/10 {level_label} ({result.level.value}){partial}"
|
|
118
|
+
else:
|
|
119
|
+
return f"ails: {result.score:.1f}/10 {level_label} ({result.level.value}){partial} - {violation_count} violations"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Reusable components for terminal output.
|
|
2
|
+
|
|
3
|
+
Small helper functions used by both full and compact formatters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from reporails_cli.core.models import ScanDelta
|
|
12
|
+
from reporails_cli.formatters.text.chars import get_chars
|
|
13
|
+
from reporails_cli.templates import render
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_legend(ascii_mode: bool | None = None) -> str:
|
|
17
|
+
"""Format severity legend for display."""
|
|
18
|
+
chars = get_chars(ascii_mode)
|
|
19
|
+
return render("cli_legend.txt",
|
|
20
|
+
crit=chars["crit"],
|
|
21
|
+
high=chars["high"],
|
|
22
|
+
med=chars["med"],
|
|
23
|
+
low=chars["low"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_path(file_path: str, max_len: int = 50) -> str:
|
|
28
|
+
"""Normalize file path for display.
|
|
29
|
+
|
|
30
|
+
Converts absolute paths to relative and truncates long paths.
|
|
31
|
+
"""
|
|
32
|
+
if file_path.startswith("/"):
|
|
33
|
+
with contextlib.suppress(ValueError):
|
|
34
|
+
file_path = os.path.relpath(file_path)
|
|
35
|
+
|
|
36
|
+
if len(file_path) <= max_len:
|
|
37
|
+
return file_path
|
|
38
|
+
|
|
39
|
+
parts = file_path.split("/")
|
|
40
|
+
if len(parts) > 3:
|
|
41
|
+
return ".../" + "/".join(parts[-3:])
|
|
42
|
+
return file_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_score_bar(score: float, ascii_mode: bool | None = None) -> str:
|
|
46
|
+
"""Build visual score bar for 0-10 scale."""
|
|
47
|
+
chars = get_chars(ascii_mode)
|
|
48
|
+
bar_width = 50
|
|
49
|
+
filled = int((score / 10) * bar_width)
|
|
50
|
+
filled = min(filled, bar_width)
|
|
51
|
+
return chars["filled"] * filled + chars["empty"] * (bar_width - filled)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_score_delta(delta: ScanDelta | None, ascii_mode: bool | None = None) -> str:
|
|
55
|
+
"""Format score delta indicator."""
|
|
56
|
+
if delta is None or delta.score_delta is None:
|
|
57
|
+
return ""
|
|
58
|
+
chars = get_chars(ascii_mode)
|
|
59
|
+
if delta.score_delta > 0:
|
|
60
|
+
return f" {chars['up']} +{delta.score_delta:.1f}"
|
|
61
|
+
else:
|
|
62
|
+
return f" {chars['down']} {delta.score_delta:.1f}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_level_delta(delta: ScanDelta | None, ascii_mode: bool | None = None) -> str:
|
|
66
|
+
"""Format level delta indicator."""
|
|
67
|
+
if delta is None or delta.level_previous is None:
|
|
68
|
+
return ""
|
|
69
|
+
chars = get_chars(ascii_mode)
|
|
70
|
+
if delta.level_improved:
|
|
71
|
+
return f" {chars['up']} from {delta.level_previous}"
|
|
72
|
+
else:
|
|
73
|
+
return f" {chars['down']} from {delta.level_previous}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_violations_delta(delta: ScanDelta | None, ascii_mode: bool | None = None) -> str:
|
|
77
|
+
"""Format violations delta indicator."""
|
|
78
|
+
if delta is None or delta.violations_delta is None:
|
|
79
|
+
return ""
|
|
80
|
+
chars = get_chars(ascii_mode)
|
|
81
|
+
if delta.violations_delta < 0:
|
|
82
|
+
# Decreased = good
|
|
83
|
+
return f" {chars['down']} {delta.violations_delta}"
|
|
84
|
+
else:
|
|
85
|
+
# Increased = bad
|
|
86
|
+
return f" {chars['up']} +{delta.violations_delta}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pad_line(content: str, width: int, v_char: str) -> str:
|
|
90
|
+
"""Pad content to fit box width.
|
|
91
|
+
|
|
92
|
+
Content goes between vertical bars with padding.
|
|
93
|
+
"""
|
|
94
|
+
inner = f" {content}"
|
|
95
|
+
if len(inner) > width - 3:
|
|
96
|
+
inner = inner[: width - 6] + "..."
|
|
97
|
+
return f"{v_char}{inner.ljust(width)}{v_char}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_severity_label(severity: str, chars: dict[str, str]) -> str:
|
|
101
|
+
"""Get formatted severity label with icon."""
|
|
102
|
+
return {
|
|
103
|
+
"critical": f"{chars['crit']} CRIT",
|
|
104
|
+
"high": f"{chars['high']} HIGH",
|
|
105
|
+
"medium": f"{chars['med']} MED",
|
|
106
|
+
"low": f"{chars['low']} LOW",
|
|
107
|
+
}.get(severity, "???")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_severity_icons(chars: dict[str, str]) -> dict[str, str]:
|
|
111
|
+
"""Get severity icon mapping."""
|
|
112
|
+
return {
|
|
113
|
+
"critical": chars["crit"],
|
|
114
|
+
"high": chars["high"],
|
|
115
|
+
"medium": chars["med"],
|
|
116
|
+
"low": chars["low"],
|
|
117
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Full terminal output formatter.
|
|
2
|
+
|
|
3
|
+
Provides rich, detailed output for interactive terminal use.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from reporails_cli.core.models import ScanDelta, ValidationResult
|
|
11
|
+
from reporails_cli.formatters import json as json_formatter
|
|
12
|
+
from reporails_cli.formatters.text.box import format_assessment_box
|
|
13
|
+
from reporails_cli.formatters.text.chars import get_chars
|
|
14
|
+
from reporails_cli.formatters.text.components import format_legend
|
|
15
|
+
from reporails_cli.formatters.text.violations import format_violations_section
|
|
16
|
+
from reporails_cli.templates import render
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _format_working_section(
|
|
20
|
+
violations: list[dict[str, Any]],
|
|
21
|
+
rules_passed: int,
|
|
22
|
+
ascii_mode: bool | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Format 'What's working well' section."""
|
|
25
|
+
if rules_passed <= 0:
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
chars = get_chars(ascii_mode)
|
|
29
|
+
|
|
30
|
+
# Find categories with violations
|
|
31
|
+
violation_categories: set[str] = set()
|
|
32
|
+
for v in violations:
|
|
33
|
+
rule_id = v.get("rule_id", "")
|
|
34
|
+
if rule_id:
|
|
35
|
+
violation_categories.add(rule_id[0])
|
|
36
|
+
|
|
37
|
+
all_categories = {
|
|
38
|
+
"S": "Structure", "C": "Content", "E": "Efficiency",
|
|
39
|
+
"M": "Maintenance", "G": "Governance",
|
|
40
|
+
}
|
|
41
|
+
passing_categories = [
|
|
42
|
+
name for code, name in all_categories.items() if code not in violation_categories
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if not passing_categories:
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
items = "\n".join(f" {chars['check']} {cat}" for cat in passing_categories)
|
|
49
|
+
return render("cli_working.txt", items=items)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_pending_section(
|
|
53
|
+
result: ValidationResult,
|
|
54
|
+
quiet_semantic: bool = False,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Format pending semantic evaluation section."""
|
|
57
|
+
if quiet_semantic or not result.pending_semantic:
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
ps = result.pending_semantic
|
|
61
|
+
return render("cli_pending.txt",
|
|
62
|
+
rule_count=ps.rule_count,
|
|
63
|
+
file_count=ps.file_count,
|
|
64
|
+
rule_list=", ".join(ps.rules),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_cta(
|
|
69
|
+
result: ValidationResult,
|
|
70
|
+
ascii_mode: bool | None = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Format MCP call-to-action for partial evaluation."""
|
|
73
|
+
if not result.is_partial:
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
chars = get_chars(ascii_mode)
|
|
77
|
+
separator = chars["sep"] * 64
|
|
78
|
+
return render("cli_cta.txt", separator=separator)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_result(
|
|
82
|
+
result: ValidationResult,
|
|
83
|
+
ascii_mode: bool | None = None,
|
|
84
|
+
quiet_semantic: bool = False,
|
|
85
|
+
show_legend: bool = True,
|
|
86
|
+
delta: ScanDelta | None = None,
|
|
87
|
+
show_mcp_cta: bool = True,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Format validation result for terminal output."""
|
|
90
|
+
data = json_formatter.format_result(result, delta)
|
|
91
|
+
|
|
92
|
+
summary_info = data.get("summary", {})
|
|
93
|
+
rules_passed = summary_info.get("rules_passed", 0)
|
|
94
|
+
violations = data.get("violations", [])
|
|
95
|
+
friction = data.get("friction", {})
|
|
96
|
+
|
|
97
|
+
sections = []
|
|
98
|
+
|
|
99
|
+
# Assessment box
|
|
100
|
+
sections.append(format_assessment_box(data, ascii_mode, delta))
|
|
101
|
+
sections.append("")
|
|
102
|
+
|
|
103
|
+
# What's working well
|
|
104
|
+
working = _format_working_section(violations, rules_passed, ascii_mode)
|
|
105
|
+
if working:
|
|
106
|
+
sections.append(working)
|
|
107
|
+
sections.append("")
|
|
108
|
+
|
|
109
|
+
# Violations
|
|
110
|
+
sections.append(format_violations_section(violations, ascii_mode))
|
|
111
|
+
|
|
112
|
+
# Pending semantic
|
|
113
|
+
pending = _format_pending_section(result, quiet_semantic)
|
|
114
|
+
if pending:
|
|
115
|
+
sections.append(pending)
|
|
116
|
+
sections.append("")
|
|
117
|
+
|
|
118
|
+
# Friction estimate
|
|
119
|
+
friction_level = friction if isinstance(friction, str) else friction.get("level", "none")
|
|
120
|
+
if friction_level != "none":
|
|
121
|
+
sections.append(f"Friction: {friction_level.title()}")
|
|
122
|
+
|
|
123
|
+
# MCP CTA (only if partial, not quiet, and CTA enabled)
|
|
124
|
+
if show_mcp_cta and result.is_partial and not quiet_semantic:
|
|
125
|
+
cta = _format_cta(result, ascii_mode)
|
|
126
|
+
if cta:
|
|
127
|
+
sections.append("")
|
|
128
|
+
sections.append(cta)
|
|
129
|
+
|
|
130
|
+
# Legend footer
|
|
131
|
+
if violations and show_legend:
|
|
132
|
+
sections.append("")
|
|
133
|
+
sections.append(format_legend(ascii_mode))
|
|
134
|
+
|
|
135
|
+
return "\n".join(sections)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Rule explanation formatting.
|
|
2
|
+
|
|
3
|
+
Handles formatting rule details for `ails explain` command.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_rule(rule_id: str, rule_data: dict[str, Any]) -> str:
|
|
12
|
+
"""Format rule explanation for terminal."""
|
|
13
|
+
lines = []
|
|
14
|
+
|
|
15
|
+
lines.append(f"Rule: {rule_id}")
|
|
16
|
+
lines.append("=" * 60)
|
|
17
|
+
lines.append("")
|
|
18
|
+
|
|
19
|
+
if rule_data.get("title"):
|
|
20
|
+
lines.append(f"Title: {rule_data['title']}")
|
|
21
|
+
if rule_data.get("category"):
|
|
22
|
+
lines.append(f"Category: {rule_data['category']}")
|
|
23
|
+
if rule_data.get("type"):
|
|
24
|
+
lines.append(f"Type: {rule_data['type']}")
|
|
25
|
+
if rule_data.get("level"):
|
|
26
|
+
lines.append(f"Required Level: {rule_data['level']}")
|
|
27
|
+
|
|
28
|
+
lines.append("")
|
|
29
|
+
|
|
30
|
+
if rule_data.get("description"):
|
|
31
|
+
lines.append("Description:")
|
|
32
|
+
lines.append(f" {rule_data['description']}")
|
|
33
|
+
lines.append("")
|
|
34
|
+
|
|
35
|
+
# Support both checks and legacy antipatterns
|
|
36
|
+
checks = rule_data.get("checks", rule_data.get("antipatterns", []))
|
|
37
|
+
if checks:
|
|
38
|
+
lines.append("Checks:")
|
|
39
|
+
for check in checks:
|
|
40
|
+
lines.append(f" - {check.get('id', '?')}: {check.get('name', 'Unknown')}")
|
|
41
|
+
lines.append(f" Severity: {check.get('severity', 'medium')}")
|
|
42
|
+
lines.append("")
|
|
43
|
+
|
|
44
|
+
see_also = rule_data.get("see_also", [])
|
|
45
|
+
if see_also:
|
|
46
|
+
lines.append("See Also:")
|
|
47
|
+
for ref in see_also:
|
|
48
|
+
lines.append(f" - {ref}")
|
|
49
|
+
|
|
50
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Violations section formatting.
|
|
2
|
+
|
|
3
|
+
Handles grouping, sorting, and rendering of violations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from reporails_cli.formatters.text.chars import get_chars
|
|
11
|
+
from reporails_cli.formatters.text.components import get_severity_label, normalize_path
|
|
12
|
+
from reporails_cli.templates import render
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_violations_section(
|
|
16
|
+
violations: list[dict[str, Any]],
|
|
17
|
+
ascii_mode: bool | None = None,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""Format violations section grouped by file."""
|
|
20
|
+
if not violations:
|
|
21
|
+
return "No violations found."
|
|
22
|
+
|
|
23
|
+
chars = get_chars(ascii_mode)
|
|
24
|
+
lines = ["Violations:", "-" * 60]
|
|
25
|
+
|
|
26
|
+
# Group by file
|
|
27
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
28
|
+
for v in violations:
|
|
29
|
+
location = v.get("location", "")
|
|
30
|
+
file_path = location.rsplit(":", 1)[0] if ":" in location else location
|
|
31
|
+
if file_path not in grouped:
|
|
32
|
+
grouped[file_path] = []
|
|
33
|
+
grouped[file_path].append(v)
|
|
34
|
+
|
|
35
|
+
# Sort files by worst severity
|
|
36
|
+
def file_sort_key(item: tuple[str, list[dict[str, Any]]]) -> tuple[int, int]:
|
|
37
|
+
file_violations = item[1]
|
|
38
|
+
severity_weights = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
39
|
+
worst_severity = min(
|
|
40
|
+
severity_weights.get(v.get("severity", "low"), 3) for v in file_violations
|
|
41
|
+
)
|
|
42
|
+
return (worst_severity, -len(file_violations))
|
|
43
|
+
|
|
44
|
+
sorted_files = sorted(grouped.items(), key=file_sort_key)
|
|
45
|
+
severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
46
|
+
|
|
47
|
+
for file_path, file_violations in sorted_files:
|
|
48
|
+
display_path = normalize_path(file_path)
|
|
49
|
+
|
|
50
|
+
# Deduplicate by rule_id within file
|
|
51
|
+
seen_rules: set[str] = set()
|
|
52
|
+
unique_violations: list[dict[str, Any]] = []
|
|
53
|
+
for v in file_violations:
|
|
54
|
+
rule_id = v.get("rule_id", "")
|
|
55
|
+
if rule_id not in seen_rules:
|
|
56
|
+
seen_rules.add(rule_id)
|
|
57
|
+
unique_violations.append(v)
|
|
58
|
+
|
|
59
|
+
issue_word = "issue" if len(unique_violations) == 1 else "issues"
|
|
60
|
+
lines.append(render("cli_file_header.txt",
|
|
61
|
+
filepath=display_path,
|
|
62
|
+
count=len(unique_violations),
|
|
63
|
+
issue_word=issue_word,
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
sorted_violations = sorted(
|
|
67
|
+
unique_violations,
|
|
68
|
+
key=lambda v: (severity_order.get(v.get("severity", ""), 9), v.get("location", "")),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
for v in sorted_violations:
|
|
72
|
+
severity_label = get_severity_label(v.get("severity", ""), chars)
|
|
73
|
+
location = v.get("location", "")
|
|
74
|
+
line_num = location.rsplit(":", 1)[-1] if ":" in location else "?"
|
|
75
|
+
msg = v.get("message", "")
|
|
76
|
+
max_msg_len = 48
|
|
77
|
+
if len(msg) > max_msg_len:
|
|
78
|
+
msg = msg[: max_msg_len - 3] + "..."
|
|
79
|
+
rule_id = v.get("rule_id", "")
|
|
80
|
+
check_id = v.get("check_id")
|
|
81
|
+
display_id = f"{rule_id}.{check_id}" if check_id else rule_id
|
|
82
|
+
|
|
83
|
+
lines.append(render("cli_violation.txt",
|
|
84
|
+
icon=severity_label,
|
|
85
|
+
rule_id=display_id,
|
|
86
|
+
line=line_num,
|
|
87
|
+
message=msg,
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interface adapters for reporails (CLI, MCP)."""
|