agent-rules-kit 0.2.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.
- agent_rules_kit/__init__.py +3 -0
- agent_rules_kit/cli.py +345 -0
- agent_rules_kit/discovery.py +109 -0
- agent_rules_kit/findings.py +85 -0
- agent_rules_kit/governance.py +608 -0
- agent_rules_kit/init_plan.py +73 -0
- agent_rules_kit/init_write.py +143 -0
- agent_rules_kit/redaction.py +78 -0
- agent_rules_kit-0.2.1.dist-info/METADATA +613 -0
- agent_rules_kit-0.2.1.dist-info/RECORD +13 -0
- agent_rules_kit-0.2.1.dist-info/WHEEL +4 -0
- agent_rules_kit-0.2.1.dist-info/entry_points.txt +2 -0
- agent_rules_kit-0.2.1.dist-info/licenses/LICENSE +21 -0
agent_rules_kit/cli.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Command line entry point for agent-rules-kit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agent_rules_kit import __version__
|
|
12
|
+
from agent_rules_kit.discovery import InstructionFile, discover_instruction_files
|
|
13
|
+
from agent_rules_kit.findings import Finding
|
|
14
|
+
from agent_rules_kit.governance import find_governance_findings
|
|
15
|
+
from agent_rules_kit.init_plan import InitPlan, build_init_plan
|
|
16
|
+
from agent_rules_kit.init_write import InitWriteResult, write_init_files
|
|
17
|
+
from agent_rules_kit.redaction import redact_secret_like_values
|
|
18
|
+
|
|
19
|
+
OUTPUT_FORMATS = ("console", "json", "markdown")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
"""Build the command line parser."""
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="agent-rules-kit",
|
|
26
|
+
description="Diagnose baseline quality of AI agent instruction files in repositories.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--version",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="Print the package version and exit.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
35
|
+
|
|
36
|
+
check_parser = subparsers.add_parser(
|
|
37
|
+
"check",
|
|
38
|
+
help="Discover supported agent instruction files in a repository.",
|
|
39
|
+
)
|
|
40
|
+
check_parser.add_argument(
|
|
41
|
+
"repository",
|
|
42
|
+
nargs="?",
|
|
43
|
+
default=".",
|
|
44
|
+
help="Repository root to inspect. Defaults to the current directory.",
|
|
45
|
+
)
|
|
46
|
+
check_parser.add_argument(
|
|
47
|
+
"--format",
|
|
48
|
+
choices=OUTPUT_FORMATS,
|
|
49
|
+
default="console",
|
|
50
|
+
help="Output format. Defaults to console.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
init_parser = subparsers.add_parser(
|
|
54
|
+
"init",
|
|
55
|
+
help="Plan baseline agent instruction files without writing by default.",
|
|
56
|
+
)
|
|
57
|
+
init_parser.add_argument(
|
|
58
|
+
"repository",
|
|
59
|
+
nargs="?",
|
|
60
|
+
default=".",
|
|
61
|
+
help="Repository root to inspect. Defaults to the current directory.",
|
|
62
|
+
)
|
|
63
|
+
init_parser.add_argument(
|
|
64
|
+
"--dry-run",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Preview planned file changes without modifying files.",
|
|
67
|
+
)
|
|
68
|
+
init_parser.add_argument(
|
|
69
|
+
"--write",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="Write baseline files, backing up existing files first.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
78
|
+
"""Run the CLI."""
|
|
79
|
+
parser = build_parser()
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
|
|
82
|
+
if args.version:
|
|
83
|
+
print(f"agent-rules-kit {__version__}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
if args.command == "check":
|
|
87
|
+
return _run_check(Path(args.repository), output_format=args.format)
|
|
88
|
+
|
|
89
|
+
if args.command == "init":
|
|
90
|
+
return _run_init(
|
|
91
|
+
Path(args.repository),
|
|
92
|
+
dry_run=args.dry_run,
|
|
93
|
+
write=args.write,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
parser.print_help()
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_check(repository_root: Path, *, output_format: str = "console") -> int:
|
|
101
|
+
try:
|
|
102
|
+
instruction_files = discover_instruction_files(repository_root)
|
|
103
|
+
except ValueError as error:
|
|
104
|
+
payload = _build_check_error_payload(repository_root, error)
|
|
105
|
+
|
|
106
|
+
if output_format == "json":
|
|
107
|
+
_print_json(payload)
|
|
108
|
+
elif output_format == "markdown":
|
|
109
|
+
_print_markdown(payload)
|
|
110
|
+
else:
|
|
111
|
+
print(f"ERROR: {payload['error']['message']}", file=sys.stderr)
|
|
112
|
+
|
|
113
|
+
return 2
|
|
114
|
+
|
|
115
|
+
status = "ok" if instruction_files else "no_instruction_files"
|
|
116
|
+
findings = find_governance_findings(repository_root, instruction_files)
|
|
117
|
+
payload = _build_check_payload(
|
|
118
|
+
repository_root,
|
|
119
|
+
instruction_files,
|
|
120
|
+
findings=findings,
|
|
121
|
+
status=status,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if output_format == "json":
|
|
125
|
+
_print_json(payload)
|
|
126
|
+
elif output_format == "markdown":
|
|
127
|
+
_print_markdown(payload)
|
|
128
|
+
else:
|
|
129
|
+
return _print_console_check(repository_root, instruction_files, findings)
|
|
130
|
+
|
|
131
|
+
return 0 if instruction_files else 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _print_console_check(
|
|
135
|
+
repository_root: Path,
|
|
136
|
+
instruction_files: tuple[InstructionFile, ...],
|
|
137
|
+
findings: tuple[Finding, ...],
|
|
138
|
+
) -> int:
|
|
139
|
+
print(f"agent-rules-kit check: {repository_root}")
|
|
140
|
+
|
|
141
|
+
if not instruction_files:
|
|
142
|
+
print("No supported agent instruction files found.")
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
print(f"Found {len(instruction_files)} supported instruction file(s):")
|
|
146
|
+
for instruction_file in instruction_files:
|
|
147
|
+
print(f"- {instruction_file.path} [{instruction_file.kind.value}]")
|
|
148
|
+
|
|
149
|
+
if findings:
|
|
150
|
+
print("Findings:")
|
|
151
|
+
for finding in findings:
|
|
152
|
+
location = _format_finding_location(finding)
|
|
153
|
+
print(
|
|
154
|
+
f"- {finding.rule_id} [{finding.severity.value}] "
|
|
155
|
+
f"{location} - {redact_secret_like_values(finding.message)}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_init(repository_root: Path, *, dry_run: bool, write: bool) -> int:
|
|
162
|
+
if dry_run and write:
|
|
163
|
+
print("ERROR: init accepts only one mode: --dry-run or --write.", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
if not dry_run and not write:
|
|
167
|
+
print("ERROR: init currently requires --dry-run or --write.", file=sys.stderr)
|
|
168
|
+
return 2
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
if dry_run:
|
|
172
|
+
plan = build_init_plan(repository_root)
|
|
173
|
+
_print_init_dry_run(plan)
|
|
174
|
+
else:
|
|
175
|
+
result = write_init_files(repository_root)
|
|
176
|
+
_print_init_write(result)
|
|
177
|
+
except ValueError as error:
|
|
178
|
+
print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _print_init_dry_run(plan: InitPlan) -> None:
|
|
185
|
+
print(f"agent-rules-kit init: {redact_secret_like_values(plan.repository)}")
|
|
186
|
+
print("Mode: dry-run")
|
|
187
|
+
print("No files will be modified.")
|
|
188
|
+
print("Planned file actions:")
|
|
189
|
+
|
|
190
|
+
for file_item in plan.files:
|
|
191
|
+
path = redact_secret_like_values(file_item.path)
|
|
192
|
+
reason = redact_secret_like_values(file_item.reason)
|
|
193
|
+
print(f"- {path} [{file_item.action.value}] - {reason}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _print_init_write(result: InitWriteResult) -> None:
|
|
197
|
+
print(f"agent-rules-kit init: {redact_secret_like_values(result.repository)}")
|
|
198
|
+
print("Mode: write")
|
|
199
|
+
print("Files modified:")
|
|
200
|
+
|
|
201
|
+
for file_item in result.files:
|
|
202
|
+
path = redact_secret_like_values(file_item.path)
|
|
203
|
+
if file_item.backup_path is None:
|
|
204
|
+
print(f"- {path} [{file_item.action.value}]")
|
|
205
|
+
else:
|
|
206
|
+
backup_path = redact_secret_like_values(file_item.backup_path)
|
|
207
|
+
print(f"- {path} [{file_item.action.value}] - backup: {backup_path}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_check_payload(
|
|
211
|
+
repository_root: Path,
|
|
212
|
+
instruction_files: tuple[InstructionFile, ...],
|
|
213
|
+
*,
|
|
214
|
+
findings: tuple[Finding, ...],
|
|
215
|
+
status: str,
|
|
216
|
+
) -> dict[str, object]:
|
|
217
|
+
return {
|
|
218
|
+
"command": "check",
|
|
219
|
+
"status": status,
|
|
220
|
+
"repository": redact_secret_like_values(str(repository_root)),
|
|
221
|
+
"instruction_files": [
|
|
222
|
+
{
|
|
223
|
+
"path": redact_secret_like_values(instruction_file.path),
|
|
224
|
+
"kind": instruction_file.kind.value,
|
|
225
|
+
}
|
|
226
|
+
for instruction_file in instruction_files
|
|
227
|
+
],
|
|
228
|
+
"summary": {
|
|
229
|
+
"supported_instruction_file_count": len(instruction_files),
|
|
230
|
+
"finding_count": len(findings),
|
|
231
|
+
},
|
|
232
|
+
"findings": [_build_finding_payload(finding) for finding in findings],
|
|
233
|
+
"error": None,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _build_check_error_payload(
|
|
238
|
+
repository_root: Path,
|
|
239
|
+
error: ValueError,
|
|
240
|
+
) -> dict[str, object]:
|
|
241
|
+
return {
|
|
242
|
+
"command": "check",
|
|
243
|
+
"status": "error",
|
|
244
|
+
"repository": redact_secret_like_values(str(repository_root)),
|
|
245
|
+
"instruction_files": [],
|
|
246
|
+
"summary": {
|
|
247
|
+
"supported_instruction_file_count": 0,
|
|
248
|
+
"finding_count": 0,
|
|
249
|
+
},
|
|
250
|
+
"findings": [],
|
|
251
|
+
"error": {
|
|
252
|
+
"message": redact_secret_like_values(str(error)),
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _print_json(payload: dict[str, object]) -> None:
|
|
258
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _print_markdown(payload: dict[str, object]) -> None:
|
|
262
|
+
print("# agent-rules-kit check")
|
|
263
|
+
print()
|
|
264
|
+
print(f"- Repository: {_markdown_value(str(payload['repository']))}")
|
|
265
|
+
print(f"- Status: {_markdown_value(str(payload['status']))}")
|
|
266
|
+
print(
|
|
267
|
+
"- Supported instruction files: "
|
|
268
|
+
f"{payload['summary']['supported_instruction_file_count']}"
|
|
269
|
+
)
|
|
270
|
+
print(f"- Findings: {payload['summary']['finding_count']}")
|
|
271
|
+
|
|
272
|
+
error = payload["error"]
|
|
273
|
+
if error is not None:
|
|
274
|
+
print()
|
|
275
|
+
print(f"Error: {_markdown_value(str(error['message']))}")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
instruction_files = payload["instruction_files"]
|
|
279
|
+
if not instruction_files:
|
|
280
|
+
print()
|
|
281
|
+
print("No supported agent instruction files found.")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
print()
|
|
285
|
+
print("| Path | Kind |")
|
|
286
|
+
print("| --- | --- |")
|
|
287
|
+
for instruction_file in instruction_files:
|
|
288
|
+
path = _markdown_value(str(instruction_file["path"]))
|
|
289
|
+
kind = _markdown_value(str(instruction_file["kind"]))
|
|
290
|
+
print(f"| {path} | {kind} |")
|
|
291
|
+
|
|
292
|
+
findings = payload["findings"]
|
|
293
|
+
if findings:
|
|
294
|
+
print()
|
|
295
|
+
print("## Findings")
|
|
296
|
+
print()
|
|
297
|
+
print("| Rule | Severity | Location | Message |")
|
|
298
|
+
print("| --- | --- | --- | --- |")
|
|
299
|
+
for finding in findings:
|
|
300
|
+
rule_id = _markdown_value(str(finding["rule_id"]))
|
|
301
|
+
severity = _markdown_value(str(finding["severity"]))
|
|
302
|
+
location = _markdown_value(_format_finding_payload_location(finding))
|
|
303
|
+
message = _markdown_value(str(finding["message"]))
|
|
304
|
+
print(f"| {rule_id} | {severity} | {location} | {message} |")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _build_finding_payload(finding: Finding) -> dict[str, str | int]:
|
|
308
|
+
payload = finding.to_dict()
|
|
309
|
+
|
|
310
|
+
if "message" in payload:
|
|
311
|
+
payload["message"] = redact_secret_like_values(str(payload["message"]))
|
|
312
|
+
if "path" in payload:
|
|
313
|
+
payload["path"] = redact_secret_like_values(str(payload["path"]))
|
|
314
|
+
if "evidence" in payload:
|
|
315
|
+
payload["evidence"] = redact_secret_like_values(str(payload["evidence"]))
|
|
316
|
+
|
|
317
|
+
return payload
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _format_finding_location(finding: Finding) -> str:
|
|
321
|
+
if finding.path is None:
|
|
322
|
+
return "repository"
|
|
323
|
+
if finding.line is None:
|
|
324
|
+
return redact_secret_like_values(finding.path)
|
|
325
|
+
return f"{redact_secret_like_values(finding.path)}:{finding.line}"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _format_finding_payload_location(finding: dict[str, object]) -> str:
|
|
329
|
+
path_value = finding.get("path")
|
|
330
|
+
if path_value is None:
|
|
331
|
+
return "repository"
|
|
332
|
+
|
|
333
|
+
line_value = finding.get("line")
|
|
334
|
+
if line_value is None:
|
|
335
|
+
return str(path_value)
|
|
336
|
+
|
|
337
|
+
return f"{path_value}:{line_value}"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _markdown_value(value: str) -> str:
|
|
341
|
+
return redact_secret_like_values(value).replace("|", "\\|").replace("\n", " ")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Instruction file discovery for supported agent rule files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InstructionFileKind(StrEnum):
|
|
11
|
+
"""Supported instruction file family."""
|
|
12
|
+
|
|
13
|
+
AGENTS = "agents"
|
|
14
|
+
CLAUDE = "claude"
|
|
15
|
+
GEMINI = "gemini"
|
|
16
|
+
CURSOR_RULE = "cursor-rule"
|
|
17
|
+
COPILOT = "copilot"
|
|
18
|
+
GITHUB_INSTRUCTION = "github-instruction"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class InstructionFile:
|
|
23
|
+
"""A discovered instruction file."""
|
|
24
|
+
|
|
25
|
+
path: str
|
|
26
|
+
kind: InstructionFileKind
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def discover_instruction_files(root: Path | str) -> tuple[InstructionFile, ...]:
|
|
30
|
+
"""Discover supported instruction files below a repository root.
|
|
31
|
+
|
|
32
|
+
Discovery is intentionally limited to known instruction file locations. It
|
|
33
|
+
does not execute repository commands, call the network, or inspect file
|
|
34
|
+
contents.
|
|
35
|
+
"""
|
|
36
|
+
root_path = Path(root)
|
|
37
|
+
|
|
38
|
+
if not root_path.exists():
|
|
39
|
+
raise ValueError(f"repository root does not exist: {root_path}")
|
|
40
|
+
if not root_path.is_dir():
|
|
41
|
+
raise ValueError(f"repository root is not a directory: {root_path}")
|
|
42
|
+
|
|
43
|
+
discovered: list[InstructionFile] = []
|
|
44
|
+
|
|
45
|
+
for relative_path, kind in _exact_instruction_paths():
|
|
46
|
+
candidate = root_path / relative_path
|
|
47
|
+
if _is_supported_instruction_path(candidate):
|
|
48
|
+
discovered.append(InstructionFile(path=relative_path, kind=kind))
|
|
49
|
+
|
|
50
|
+
cursor_rules_dir = root_path / ".cursor" / "rules"
|
|
51
|
+
if _is_safe_instruction_directory(root_path, cursor_rules_dir):
|
|
52
|
+
for candidate in sorted(cursor_rules_dir.glob("*.mdc")):
|
|
53
|
+
if _is_supported_instruction_path(candidate):
|
|
54
|
+
discovered.append(
|
|
55
|
+
InstructionFile(
|
|
56
|
+
path=candidate.relative_to(root_path).as_posix(),
|
|
57
|
+
kind=InstructionFileKind.CURSOR_RULE,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
github_instructions_dir = root_path / ".github" / "instructions"
|
|
62
|
+
if _is_safe_instruction_directory(root_path, github_instructions_dir):
|
|
63
|
+
for candidate in sorted(github_instructions_dir.glob("*.md")):
|
|
64
|
+
if _is_supported_instruction_path(candidate):
|
|
65
|
+
discovered.append(
|
|
66
|
+
InstructionFile(
|
|
67
|
+
path=candidate.relative_to(root_path).as_posix(),
|
|
68
|
+
kind=InstructionFileKind.GITHUB_INSTRUCTION,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return tuple(discovered)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_supported_instruction_path(candidate: Path) -> bool:
|
|
76
|
+
return candidate.is_file() or candidate.is_symlink()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_safe_instruction_directory(root_path: Path, candidate: Path) -> bool:
|
|
80
|
+
return candidate.is_dir() and not _has_symlink_component(root_path, candidate)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _has_symlink_component(root_path: Path, candidate: Path) -> bool:
|
|
84
|
+
relative_path = candidate.relative_to(root_path)
|
|
85
|
+
current = root_path
|
|
86
|
+
|
|
87
|
+
for part in relative_path.parts:
|
|
88
|
+
current = current / part
|
|
89
|
+
if current.is_symlink():
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _exact_instruction_paths() -> tuple[tuple[str, InstructionFileKind], ...]:
|
|
96
|
+
return (
|
|
97
|
+
("AGENTS.md", InstructionFileKind.AGENTS),
|
|
98
|
+
("CLAUDE.md", InstructionFileKind.CLAUDE),
|
|
99
|
+
(".claude/CLAUDE.md", InstructionFileKind.CLAUDE),
|
|
100
|
+
("GEMINI.md", InstructionFileKind.GEMINI),
|
|
101
|
+
(".github/copilot-instructions.md", InstructionFileKind.COPILOT),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"InstructionFile",
|
|
107
|
+
"InstructionFileKind",
|
|
108
|
+
"discover_instruction_files",
|
|
109
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Finding model for diagnostic results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(StrEnum):
|
|
10
|
+
"""Diagnostic finding severity."""
|
|
11
|
+
|
|
12
|
+
INFO = "info"
|
|
13
|
+
WARNING = "warning"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Finding:
|
|
19
|
+
"""A single diagnostic finding.
|
|
20
|
+
|
|
21
|
+
The model is intentionally small and dependency-free so it can support
|
|
22
|
+
console, JSON, and Markdown output later without pulling in runtime tools.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
rule_id: str
|
|
26
|
+
severity: Severity
|
|
27
|
+
message: str
|
|
28
|
+
path: str | None = None
|
|
29
|
+
line: int | None = None
|
|
30
|
+
column: int | None = None
|
|
31
|
+
evidence: str | None = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not isinstance(self.severity, Severity):
|
|
35
|
+
raise TypeError("severity must be a Severity value")
|
|
36
|
+
|
|
37
|
+
normalized_rule_id = self.rule_id.strip()
|
|
38
|
+
normalized_message = self.message.strip()
|
|
39
|
+
|
|
40
|
+
if not normalized_rule_id:
|
|
41
|
+
raise ValueError("rule_id must not be blank")
|
|
42
|
+
if not normalized_message:
|
|
43
|
+
raise ValueError("message must not be blank")
|
|
44
|
+
|
|
45
|
+
object.__setattr__(self, "rule_id", normalized_rule_id)
|
|
46
|
+
object.__setattr__(self, "message", normalized_message)
|
|
47
|
+
|
|
48
|
+
if self.path is not None:
|
|
49
|
+
normalized_path = self.path.strip()
|
|
50
|
+
if not normalized_path:
|
|
51
|
+
raise ValueError("path must not be blank when provided")
|
|
52
|
+
object.__setattr__(self, "path", normalized_path)
|
|
53
|
+
|
|
54
|
+
if self.evidence is not None:
|
|
55
|
+
normalized_evidence = self.evidence.strip()
|
|
56
|
+
if not normalized_evidence:
|
|
57
|
+
raise ValueError("evidence must not be blank when provided")
|
|
58
|
+
object.__setattr__(self, "evidence", normalized_evidence)
|
|
59
|
+
|
|
60
|
+
if self.line is not None and self.line < 1:
|
|
61
|
+
raise ValueError("line must be greater than or equal to 1")
|
|
62
|
+
if self.column is not None and self.column < 1:
|
|
63
|
+
raise ValueError("column must be greater than or equal to 1")
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
66
|
+
"""Return a stable dictionary representation for future reporters."""
|
|
67
|
+
data: dict[str, str | int] = {
|
|
68
|
+
"rule_id": self.rule_id,
|
|
69
|
+
"severity": self.severity.value,
|
|
70
|
+
"message": self.message,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if self.path is not None:
|
|
74
|
+
data["path"] = self.path
|
|
75
|
+
if self.line is not None:
|
|
76
|
+
data["line"] = self.line
|
|
77
|
+
if self.column is not None:
|
|
78
|
+
data["column"] = self.column
|
|
79
|
+
if self.evidence is not None:
|
|
80
|
+
data["evidence"] = self.evidence
|
|
81
|
+
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["Finding", "Severity"]
|