ryni 0.1.0__tar.gz

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.
ryni-0.1.0/.gitignore ADDED
@@ -0,0 +1,91 @@
1
+ # Git worktrees
2
+ worktrees/
3
+
4
+ # SQLite (default local path for api booking store)
5
+ booking.db
6
+ booking.db-wal
7
+ booking.db-shm
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ *$py.class
13
+ *.egg-info/
14
+ .venv/
15
+ venv/
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+ .ruff_cache/
19
+ dist/
20
+ build/
21
+
22
+ # Node
23
+ node_modules/
24
+ .next/
25
+ .turbo/
26
+ *.tsbuildinfo
27
+
28
+ # Env files (keep .env.example committed)
29
+ .env
30
+ .env.local
31
+ .env.*.local
32
+ !.env.example
33
+
34
+ # Office
35
+ *.docx
36
+
37
+ # OS
38
+ .DS_Store
39
+ Thumbs.db
40
+
41
+ # IDE
42
+ .idea/
43
+ .vscode/*
44
+ !.vscode/*.code-workspace
45
+ *.swp
46
+
47
+ # Unused agent tooling
48
+ .gemini/
49
+
50
+ # Logs
51
+ *.log
52
+ logs/
53
+
54
+ # Secrets
55
+ *.pem
56
+ *.key
57
+
58
+ # Sveltekit
59
+ .svelte-kit/
60
+ build/
61
+ out/
62
+ dist/
63
+
64
+ # Ignore harness logs
65
+ .cursor/hooks/.harness-edit-log.ndjson
66
+ .cursor/hooks/.harness-analysis-log.json
67
+ .cursor/hooks/.harness-analysis-log.ndjson
68
+
69
+ # sqlite.
70
+ booking.db
71
+
72
+ # Local runtime data (SQLite, keys, etc.)
73
+ .data/
74
+
75
+ # agr skills folders - controlled by agr.toml
76
+ **/.cursor/
77
+ **/.codex/
78
+ **/.claude/
79
+ **/.agents/
80
+ **/.github/skills/
81
+ **/.gemini/
82
+
83
+ # Git worktrees
84
+ .worktrees/
85
+
86
+ # Scratch
87
+ **/.scratch/
88
+
89
+ # External reference repository checkouts (registry and structure stay tracked)
90
+ external-repositories/*/public/*/
91
+ external-repositories/*/private/*/
ryni-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.5
2
+ Name: ryni
3
+ Version: 0.1.0
4
+ Summary: An opinionated linter for agent harnesses
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pydantic>=2.13.5
7
+ Requires-Dist: pyyaml>=6
8
+ Requires-Dist: typer>=0.20
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Rýni
12
+
13
+ **An opinionated linter for agent harnesses, written in Python.**
14
+
15
+ [Getting started](#getting-started) · [Usage](#usage) · [Rules](#rules) · [Plugins](#plugins) · [Contributing](#contributing)
16
+
17
+ Agent instructions and skills are part of your codebase. Rýni helps you keep them
18
+ consistent as your project grows, enforcing conventions for how harnesses are
19
+ structured, shared, and maintained, with actionable diagnostics when they drift.
20
+
21
+ - **Validate skills.** Check `SKILL.md` frontmatter for required metadata.
22
+ - **Keep instructions in sync.** Check that `CLAUDE.md` links to its sibling `AGENTS.md`.
23
+ - **Check skill organization.** Find misplaced skill sources, tracked installations,
24
+ and missing or inconsistent local installations.
25
+ - **Choose your rules.** Run the full catalog or select individual checks by ID.
26
+ - **Use it in automation.** Get structured JSON output and predictable exit codes.
27
+ - **Add your own conventions.** Distribute custom rules as Python packages.
28
+
29
+ Rýni's built-in checks run locally, without a model or API key, and do not modify
30
+ files. The project is in early development; the CLI and plugin API may change.
31
+
32
+ ## Getting started
33
+
34
+ Rýni requires **Python 3.14 or later**. Git is required for the installed-skills
35
+ ignore check (`SKILL002`).
36
+
37
+ Install from PyPI with uv:
38
+
39
+ ```bash
40
+ uv tool install ryni
41
+ ```
42
+
43
+ Or with pip:
44
+
45
+ ```bash
46
+ pip install ryni
47
+ ```
48
+
49
+ Then run Rýni in the project you want to check:
50
+
51
+ ```bash
52
+ ryni check .
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ Check files, directories, or multiple paths:
58
+
59
+ ```bash
60
+ ryni check .
61
+ ryni check skills/ AGENTS.md CLAUDE.md
62
+ ```
63
+
64
+ List available rules:
65
+
66
+ ```bash
67
+ ryni rule
68
+ ```
69
+
70
+ Produce JSON for scripts and CI:
71
+
72
+ ```bash
73
+ ryni check . --output-format json
74
+ ```
75
+
76
+ The JSON report contains `checked_files`, `findings`, and `errors`. Each finding
77
+ includes a path, line number, rule ID, and message. For repository-wide checks,
78
+ `checked_files` also includes repository roots.
79
+
80
+ | Exit code | Meaning |
81
+ | --- | --- |
82
+ | `0` | Checks completed without findings. |
83
+ | `1` | Checks completed with findings. |
84
+ | `2` | Invalid invocation, execution error, or incomplete checks. |
85
+
86
+ Reports preserve findings when another check fails. Runs with no applicable targets
87
+ explicitly report zero checked targets.
88
+
89
+ ### Configuration and discovery
90
+
91
+ All registered rules, including installed plugins, run by default. Use `--select`
92
+ to choose a comma-separated list of rule IDs. Configuration is currently through
93
+ CLI options; Rýni does not read a configuration file.
94
+
95
+ File rules search the supplied paths recursively and select files by name.
96
+ Discovery skips `.git`, `.venv`, `venv`, `node_modules`, `__pycache__`, `.agents`,
97
+ `.claude`, `.codex`, and `.github`. You can check those locations by passing them
98
+ explicitly. Directory symlinks are not traversed, and repeated paths are deduplicated
99
+ without resolving symlinks. File discovery does not apply `.gitignore` patterns.
100
+
101
+ Repository rules inspect the nearest enclosing Git checkout for each supplied
102
+ path, once per repository. Outside a Git checkout, they use the supplied directory
103
+ or the file's parent directory. **Selecting a single file does not narrow the scope
104
+ of repository rules.** Select only file rules when you want checks limited to those paths.
105
+
106
+ ## Rules
107
+
108
+ Rýni includes five deterministic rules:
109
+
110
+ | ID | Name | Checks |
111
+ | --- | --- | --- |
112
+ | `SKILL001` | `skill-frontmatter` | `SKILL.md` starts with valid YAML frontmatter containing non-empty string `name` and `description` fields. |
113
+ | `AGENT001` | `claude-agents-symlink` | Each existing `CLAUDE.md` is a symlink resolving to an existing sibling `AGENTS.md`. |
114
+ | `SKILL002` | `installed-skills-gitignored` | Installed skill directories for configured agents are covered by repository `.gitignore` rules and are not tracked. |
115
+ | `SKILL003` | `local-skills-source-location` | Locally owned skills live under a directory named `skills/`, with nested categories allowed. |
116
+ | `SKILL004` | `local-skills-installed` | Local skills have a matching local-source `skills-lock.json` entry and matching installed `SKILL.md` files for agents configured in their source scope. |
117
+
118
+ `SKILL001` allows additional frontmatter fields and checks structure only.
119
+ `AGENT001` allows `AGENTS.md` without a corresponding `CLAUDE.md`. `SKILL004`
120
+ accepts copies and symlinks and checks installation evidence, not command history.
121
+
122
+ These rules express Rýni’s opinions about maintaining agent harnesses. They do not assess
123
+ instruction quality or agent behavior. Semantic and hybrid rule kinds are reserved
124
+ for future use; agent execution is not implemented.
125
+
126
+ ## Plugins
127
+
128
+ Extend Rýni with rules for your team's conventions. A Python package can export a
129
+ `ryni.models.Rule` instance through the `ryni.rules` entry-point group.
130
+
131
+ In the plugin's `pyproject.toml`:
132
+
133
+ ```toml
134
+ [project.entry-points."ryni.rules"]
135
+ example = "my_rules:EXAMPLE"
136
+ ```
137
+
138
+ In `my_rules.py`:
139
+
140
+ ```python
141
+ from pathlib import Path
142
+
143
+ from ryni.models import Finding, Rule, RuleKind
144
+
145
+
146
+ def check_readme(path: Path) -> list[Finding]:
147
+ if not path.read_text(encoding="utf-8").strip():
148
+ return [Finding(str(path), 1, "CUSTOM001", "README must not be empty.")]
149
+ return []
150
+
151
+
152
+ EXAMPLE = Rule(
153
+ id="CUSTOM001",
154
+ name="nonempty-readme",
155
+ kind=RuleKind.DETERMINISTIC,
156
+ description="README.md must contain text.",
157
+ filename="README.md",
158
+ evaluate=check_readme,
159
+ )
160
+ ```
161
+
162
+ Install the plugin in the same Python environment as Rýni. Both `check` and `rule`
163
+ load installed plugins; duplicate rule IDs are rejected. Plugins execute Python
164
+ code, so only install packages you trust.
165
+
166
+ Evaluators receive a path and return a list of findings. They must not print or
167
+ modify files. An evaluator exception produces an execution error and exit code `2`,
168
+ while other checks continue.
169
+
170
+ ## Contributing
171
+
172
+ Bug reports, rule proposals, documentation improvements, and pull requests are
173
+ welcome. For a bug report, include the command you ran, a minimal example, and the
174
+ expected and actual output. For a new rule, describe the convention it checks and
175
+ show examples that should pass and fail.
176
+
177
+ Rýni currently lives in `tools/ryni` in this repository. From the repository root:
178
+
179
+ ```bash
180
+ uv run ryni --help
181
+ uv run pytest tools/ryni/tests
182
+ uv run ruff check tools/ryni
183
+ ```
184
+
185
+ The [CLI](src/ryni/cli.py) handles commands and output, the
186
+ [engine](src/ryni/engine.py) discovers targets and runs checks, and the
187
+ [rule catalog](src/ryni/rules/__init__.py) registers built-in rules and loads plugins.
188
+ Add built-in rules under `src/ryni/rules/`, register them in `BUILTINS`, and include
189
+ tests under `tests/`.
ryni-0.1.0/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # Rýni
2
+
3
+ **An opinionated linter for agent harnesses, written in Python.**
4
+
5
+ [Getting started](#getting-started) · [Usage](#usage) · [Rules](#rules) · [Plugins](#plugins) · [Contributing](#contributing)
6
+
7
+ Agent instructions and skills are part of your codebase. Rýni helps you keep them
8
+ consistent as your project grows, enforcing conventions for how harnesses are
9
+ structured, shared, and maintained, with actionable diagnostics when they drift.
10
+
11
+ - **Validate skills.** Check `SKILL.md` frontmatter for required metadata.
12
+ - **Keep instructions in sync.** Check that `CLAUDE.md` links to its sibling `AGENTS.md`.
13
+ - **Check skill organization.** Find misplaced skill sources, tracked installations,
14
+ and missing or inconsistent local installations.
15
+ - **Choose your rules.** Run the full catalog or select individual checks by ID.
16
+ - **Use it in automation.** Get structured JSON output and predictable exit codes.
17
+ - **Add your own conventions.** Distribute custom rules as Python packages.
18
+
19
+ Rýni's built-in checks run locally, without a model or API key, and do not modify
20
+ files. The project is in early development; the CLI and plugin API may change.
21
+
22
+ ## Getting started
23
+
24
+ Rýni requires **Python 3.14 or later**. Git is required for the installed-skills
25
+ ignore check (`SKILL002`).
26
+
27
+ Install from PyPI with uv:
28
+
29
+ ```bash
30
+ uv tool install ryni
31
+ ```
32
+
33
+ Or with pip:
34
+
35
+ ```bash
36
+ pip install ryni
37
+ ```
38
+
39
+ Then run Rýni in the project you want to check:
40
+
41
+ ```bash
42
+ ryni check .
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ Check files, directories, or multiple paths:
48
+
49
+ ```bash
50
+ ryni check .
51
+ ryni check skills/ AGENTS.md CLAUDE.md
52
+ ```
53
+
54
+ List available rules:
55
+
56
+ ```bash
57
+ ryni rule
58
+ ```
59
+
60
+ Produce JSON for scripts and CI:
61
+
62
+ ```bash
63
+ ryni check . --output-format json
64
+ ```
65
+
66
+ The JSON report contains `checked_files`, `findings`, and `errors`. Each finding
67
+ includes a path, line number, rule ID, and message. For repository-wide checks,
68
+ `checked_files` also includes repository roots.
69
+
70
+ | Exit code | Meaning |
71
+ | --- | --- |
72
+ | `0` | Checks completed without findings. |
73
+ | `1` | Checks completed with findings. |
74
+ | `2` | Invalid invocation, execution error, or incomplete checks. |
75
+
76
+ Reports preserve findings when another check fails. Runs with no applicable targets
77
+ explicitly report zero checked targets.
78
+
79
+ ### Configuration and discovery
80
+
81
+ All registered rules, including installed plugins, run by default. Use `--select`
82
+ to choose a comma-separated list of rule IDs. Configuration is currently through
83
+ CLI options; Rýni does not read a configuration file.
84
+
85
+ File rules search the supplied paths recursively and select files by name.
86
+ Discovery skips `.git`, `.venv`, `venv`, `node_modules`, `__pycache__`, `.agents`,
87
+ `.claude`, `.codex`, and `.github`. You can check those locations by passing them
88
+ explicitly. Directory symlinks are not traversed, and repeated paths are deduplicated
89
+ without resolving symlinks. File discovery does not apply `.gitignore` patterns.
90
+
91
+ Repository rules inspect the nearest enclosing Git checkout for each supplied
92
+ path, once per repository. Outside a Git checkout, they use the supplied directory
93
+ or the file's parent directory. **Selecting a single file does not narrow the scope
94
+ of repository rules.** Select only file rules when you want checks limited to those paths.
95
+
96
+ ## Rules
97
+
98
+ Rýni includes five deterministic rules:
99
+
100
+ | ID | Name | Checks |
101
+ | --- | --- | --- |
102
+ | `SKILL001` | `skill-frontmatter` | `SKILL.md` starts with valid YAML frontmatter containing non-empty string `name` and `description` fields. |
103
+ | `AGENT001` | `claude-agents-symlink` | Each existing `CLAUDE.md` is a symlink resolving to an existing sibling `AGENTS.md`. |
104
+ | `SKILL002` | `installed-skills-gitignored` | Installed skill directories for configured agents are covered by repository `.gitignore` rules and are not tracked. |
105
+ | `SKILL003` | `local-skills-source-location` | Locally owned skills live under a directory named `skills/`, with nested categories allowed. |
106
+ | `SKILL004` | `local-skills-installed` | Local skills have a matching local-source `skills-lock.json` entry and matching installed `SKILL.md` files for agents configured in their source scope. |
107
+
108
+ `SKILL001` allows additional frontmatter fields and checks structure only.
109
+ `AGENT001` allows `AGENTS.md` without a corresponding `CLAUDE.md`. `SKILL004`
110
+ accepts copies and symlinks and checks installation evidence, not command history.
111
+
112
+ These rules express Rýni’s opinions about maintaining agent harnesses. They do not assess
113
+ instruction quality or agent behavior. Semantic and hybrid rule kinds are reserved
114
+ for future use; agent execution is not implemented.
115
+
116
+ ## Plugins
117
+
118
+ Extend Rýni with rules for your team's conventions. A Python package can export a
119
+ `ryni.models.Rule` instance through the `ryni.rules` entry-point group.
120
+
121
+ In the plugin's `pyproject.toml`:
122
+
123
+ ```toml
124
+ [project.entry-points."ryni.rules"]
125
+ example = "my_rules:EXAMPLE"
126
+ ```
127
+
128
+ In `my_rules.py`:
129
+
130
+ ```python
131
+ from pathlib import Path
132
+
133
+ from ryni.models import Finding, Rule, RuleKind
134
+
135
+
136
+ def check_readme(path: Path) -> list[Finding]:
137
+ if not path.read_text(encoding="utf-8").strip():
138
+ return [Finding(str(path), 1, "CUSTOM001", "README must not be empty.")]
139
+ return []
140
+
141
+
142
+ EXAMPLE = Rule(
143
+ id="CUSTOM001",
144
+ name="nonempty-readme",
145
+ kind=RuleKind.DETERMINISTIC,
146
+ description="README.md must contain text.",
147
+ filename="README.md",
148
+ evaluate=check_readme,
149
+ )
150
+ ```
151
+
152
+ Install the plugin in the same Python environment as Rýni. Both `check` and `rule`
153
+ load installed plugins; duplicate rule IDs are rejected. Plugins execute Python
154
+ code, so only install packages you trust.
155
+
156
+ Evaluators receive a path and return a list of findings. They must not print or
157
+ modify files. An evaluator exception produces an execution error and exit code `2`,
158
+ while other checks continue.
159
+
160
+ ## Contributing
161
+
162
+ Bug reports, rule proposals, documentation improvements, and pull requests are
163
+ welcome. For a bug report, include the command you ran, a minimal example, and the
164
+ expected and actual output. For a new rule, describe the convention it checks and
165
+ show examples that should pass and fail.
166
+
167
+ Rýni currently lives in `tools/ryni` in this repository. From the repository root:
168
+
169
+ ```bash
170
+ uv run ryni --help
171
+ uv run pytest tools/ryni/tests
172
+ uv run ruff check tools/ryni
173
+ ```
174
+
175
+ The [CLI](src/ryni/cli.py) handles commands and output, the
176
+ [engine](src/ryni/engine.py) discovers targets and runs checks, and the
177
+ [rule catalog](src/ryni/rules/__init__.py) registers built-in rules and loads plugins.
178
+ Add built-in rules under `src/ryni/rules/`, register them in `BUILTINS`, and include
179
+ tests under `tests/`.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ryni"
7
+ version = "0.1.0"
8
+ description = "An opinionated linter for agent harnesses"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ dependencies = [
12
+ "typer>=0.20",
13
+ "pyyaml>=6",
14
+ "pydantic>=2.13.5",
15
+ ]
16
+
17
+ [project.scripts]
18
+ ryni = "ryni.cli:app"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/ryni"]
@@ -0,0 +1 @@
1
+ """Rýni: rule-based linting for agent harnesses."""
@@ -0,0 +1,69 @@
1
+ import json
2
+ from dataclasses import asdict
3
+ from enum import StrEnum
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ryni.engine import check as run_checks
10
+ from ryni.rules import CatalogLoadError, RuleCatalog, UnknownRulesError, load_catalog
11
+
12
+ app = typer.Typer(help="Rýni: rule-based linting for agent harnesses.", no_args_is_help=True)
13
+
14
+
15
+ class OutputFormat(StrEnum):
16
+ TEXT = "text"
17
+ JSON = "json"
18
+
19
+
20
+ def available_rules() -> RuleCatalog:
21
+ try:
22
+ return load_catalog()
23
+ except CatalogLoadError as error:
24
+ typer.echo(f"Error loading rules: {error}", err=True)
25
+ raise typer.Exit(2) from error
26
+
27
+
28
+ @app.command()
29
+ def check(
30
+ paths: Annotated[list[Path], typer.Argument(help="Files or directories to check.")],
31
+ select: Annotated[str | None, typer.Option(help="Comma-separated rule IDs.")] = None,
32
+ output_format: Annotated[OutputFormat, typer.Option(help="Diagnostic output format.")] = (
33
+ OutputFormat.TEXT
34
+ ),
35
+ ) -> None:
36
+ """Check selected paths. Exit 0: clean, 1: findings, 2: error/incomplete."""
37
+ catalog = available_rules()
38
+ try:
39
+ rules = catalog.select(select.split(",") if select is not None else None)
40
+ except UnknownRulesError as error:
41
+ raise typer.BadParameter(str(error), param_hint="--select") from error
42
+ result = run_checks(paths, rules)
43
+ if output_format == OutputFormat.JSON:
44
+ typer.echo(json.dumps(asdict(result), indent=2))
45
+ else:
46
+ for finding in result.findings:
47
+ typer.echo(f"{finding.path}:{finding.line}: {finding.rule_id} {finding.message}")
48
+ for error in result.errors:
49
+ typer.echo(f"Error: {error}", err=True)
50
+ typer.echo(
51
+ f"Checked {len(result.checked_files)} targets · {len(result.findings)} findings · "
52
+ f"{len(result.errors)} errors"
53
+ )
54
+ raise typer.Exit(result.exit_code)
55
+
56
+
57
+ @app.command()
58
+ def rule(rule_id: Annotated[str | None, typer.Argument(help="Rule ID to explain.")] = None) -> None:
59
+ """List rules, or explain one rule with an example."""
60
+ catalog = available_rules()
61
+ if rule_id is None:
62
+ for item in catalog.select():
63
+ typer.echo(f"{item.id} {item.name} [{item.kind}]")
64
+ return
65
+ try:
66
+ item = catalog.get(rule_id)
67
+ except UnknownRulesError as error:
68
+ raise typer.BadParameter(str(error)) from error
69
+ typer.echo(f"{item.id}: {item.name} [{item.kind}]\n\n{item.description}")
@@ -0,0 +1,88 @@
1
+ import os
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+
5
+ from ryni.models import CheckResult, Finding, Rule, RuleScope
6
+ from ryni.skill_repository_local import repository_root
7
+
8
+ EXCLUDED_DIRECTORIES = {
9
+ ".git",
10
+ ".venv",
11
+ "venv",
12
+ "node_modules",
13
+ "__pycache__",
14
+ ".agents",
15
+ ".claude",
16
+ ".codex",
17
+ ".github",
18
+ }
19
+
20
+
21
+ def discover(paths: list[Path], result: CheckResult) -> list[Path]:
22
+ files: dict[Path, Path] = {}
23
+ for path in paths:
24
+ if path.is_file() or path.is_symlink():
25
+ files.setdefault(path.absolute(), path)
26
+ elif path.is_dir():
27
+ files.setdefault(path.absolute(), path)
28
+ for root, directories, names in os.walk(
29
+ path, onerror=lambda error: result.errors.append(str(error))
30
+ ):
31
+ directories[:] = sorted(
32
+ name for name in directories if name not in EXCLUDED_DIRECTORIES
33
+ )
34
+ # Preserve named directories too: a directory named CLAUDE.md is invalid.
35
+ for name in sorted(names + directories):
36
+ candidate = Path(root) / name
37
+ files.setdefault(candidate.absolute(), candidate)
38
+ else:
39
+ result.errors.append(f"Path does not exist or is not a regular file/directory: {path}")
40
+ return sorted(files.values(), key=str)
41
+
42
+
43
+ def check(paths: list[Path], rules: Sequence[Rule]) -> CheckResult:
44
+ result = CheckResult()
45
+ repository_rules = [rule for rule in rules if rule.scope == RuleScope.REPOSITORY]
46
+ file_rules = [rule for rule in rules if rule.scope == RuleScope.FILE]
47
+ if repository_rules:
48
+ roots = {repository_root(path) for path in paths if path.exists()}
49
+ for root in sorted(roots):
50
+ _evaluate(root, repository_rules, result)
51
+ for path in discover(paths, result):
52
+ applicable = [rule for rule in file_rules if path.name == rule.filename]
53
+ if not applicable:
54
+ continue
55
+ _evaluate(path, applicable, result)
56
+ return result
57
+
58
+
59
+ def _evaluate(path: Path, rules: Sequence[Rule], result: CheckResult) -> None:
60
+ complete = True
61
+ for rule in rules:
62
+ try:
63
+ findings = _validated_findings(rule.evaluate(path), rule.id)
64
+ result.findings.extend(findings)
65
+ except Exception as error:
66
+ result.errors.append(f"Cannot check {path} with {rule.id}: {error}")
67
+ complete = False
68
+ if complete:
69
+ result.checked_files.append(str(path))
70
+
71
+
72
+ def _validated_findings(output: object, rule_id: str) -> list[Finding]:
73
+ if not isinstance(output, list):
74
+ raise ValueError("Rule must return a list of Finding objects")
75
+ validated: list[Finding] = []
76
+ for finding in output:
77
+ if not isinstance(finding, Finding):
78
+ raise ValueError("Rule must return a list of Finding objects")
79
+ if not all(
80
+ isinstance(value, str) for value in (finding.path, finding.rule_id, finding.message)
81
+ ):
82
+ raise ValueError("Finding path, rule_id, and message must be strings")
83
+ if type(finding.line) is not int or finding.line < 1:
84
+ raise ValueError("Finding line must be a positive integer")
85
+ if finding.rule_id != rule_id:
86
+ raise ValueError(f"Finding rule_id must match {rule_id}")
87
+ validated.append(finding)
88
+ return validated
@@ -0,0 +1,45 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from enum import StrEnum
4
+ from pathlib import Path
5
+
6
+
7
+ class RuleKind(StrEnum):
8
+ DETERMINISTIC = "deterministic"
9
+ SEMANTIC = "semantic"
10
+ HYBRID = "hybrid"
11
+
12
+
13
+ class RuleScope(StrEnum):
14
+ FILE = "file"
15
+ REPOSITORY = "repository"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Finding:
20
+ path: str
21
+ line: int
22
+ rule_id: str
23
+ message: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Rule:
28
+ id: str
29
+ name: str
30
+ kind: RuleKind
31
+ description: str
32
+ filename: str
33
+ evaluate: Callable[[Path], list[Finding]]
34
+ scope: RuleScope = RuleScope.FILE
35
+
36
+
37
+ @dataclass
38
+ class CheckResult:
39
+ checked_files: list[str] = field(default_factory=list)
40
+ findings: list[Finding] = field(default_factory=list)
41
+ errors: list[str] = field(default_factory=list)
42
+
43
+ @property
44
+ def exit_code(self) -> int:
45
+ return 2 if self.errors else int(bool(self.findings))