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.
Files changed (58) hide show
  1. reporails_cli/.env.example +1 -0
  2. reporails_cli/__init__.py +24 -0
  3. reporails_cli/bundled/.semgrepignore +51 -0
  4. reporails_cli/bundled/__init__.py +31 -0
  5. reporails_cli/bundled/capability-patterns.yml +54 -0
  6. reporails_cli/bundled/levels.yml +99 -0
  7. reporails_cli/core/__init__.py +35 -0
  8. reporails_cli/core/agents.py +147 -0
  9. reporails_cli/core/applicability.py +150 -0
  10. reporails_cli/core/bootstrap.py +147 -0
  11. reporails_cli/core/cache.py +352 -0
  12. reporails_cli/core/capability.py +245 -0
  13. reporails_cli/core/discover.py +362 -0
  14. reporails_cli/core/engine.py +177 -0
  15. reporails_cli/core/init.py +309 -0
  16. reporails_cli/core/levels.py +177 -0
  17. reporails_cli/core/models.py +329 -0
  18. reporails_cli/core/opengrep/__init__.py +34 -0
  19. reporails_cli/core/opengrep/runner.py +203 -0
  20. reporails_cli/core/opengrep/semgrepignore.py +39 -0
  21. reporails_cli/core/opengrep/templates.py +138 -0
  22. reporails_cli/core/registry.py +155 -0
  23. reporails_cli/core/sarif.py +181 -0
  24. reporails_cli/core/scorer.py +178 -0
  25. reporails_cli/core/semantic.py +193 -0
  26. reporails_cli/core/utils.py +139 -0
  27. reporails_cli/formatters/__init__.py +19 -0
  28. reporails_cli/formatters/json.py +137 -0
  29. reporails_cli/formatters/mcp.py +68 -0
  30. reporails_cli/formatters/text/__init__.py +32 -0
  31. reporails_cli/formatters/text/box.py +89 -0
  32. reporails_cli/formatters/text/chars.py +42 -0
  33. reporails_cli/formatters/text/compact.py +119 -0
  34. reporails_cli/formatters/text/components.py +117 -0
  35. reporails_cli/formatters/text/full.py +135 -0
  36. reporails_cli/formatters/text/rules.py +50 -0
  37. reporails_cli/formatters/text/violations.py +92 -0
  38. reporails_cli/interfaces/__init__.py +1 -0
  39. reporails_cli/interfaces/cli/__init__.py +7 -0
  40. reporails_cli/interfaces/cli/main.py +352 -0
  41. reporails_cli/interfaces/mcp/__init__.py +5 -0
  42. reporails_cli/interfaces/mcp/server.py +194 -0
  43. reporails_cli/interfaces/mcp/tools.py +136 -0
  44. reporails_cli/py.typed +0 -0
  45. reporails_cli/templates/__init__.py +65 -0
  46. reporails_cli/templates/cli_box.txt +10 -0
  47. reporails_cli/templates/cli_cta.txt +4 -0
  48. reporails_cli/templates/cli_delta.txt +1 -0
  49. reporails_cli/templates/cli_file_header.txt +1 -0
  50. reporails_cli/templates/cli_legend.txt +1 -0
  51. reporails_cli/templates/cli_pending.txt +3 -0
  52. reporails_cli/templates/cli_violation.txt +1 -0
  53. reporails_cli/templates/cli_working.txt +2 -0
  54. reporails_cli-0.0.1.dist-info/METADATA +108 -0
  55. reporails_cli-0.0.1.dist-info/RECORD +58 -0
  56. reporails_cli-0.0.1.dist-info/WHEEL +4 -0
  57. reporails_cli-0.0.1.dist-info/entry_points.txt +3 -0
  58. 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)."""
@@ -0,0 +1,7 @@
1
+ """CLI interface for reporails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from reporails_cli.interfaces.cli.main import app
6
+
7
+ __all__ = ["app"]