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 +91 -0
- ryni-0.1.0/PKG-INFO +189 -0
- ryni-0.1.0/README.md +179 -0
- ryni-0.1.0/pyproject.toml +21 -0
- ryni-0.1.0/src/ryni/__init__.py +1 -0
- ryni-0.1.0/src/ryni/cli.py +69 -0
- ryni-0.1.0/src/ryni/engine.py +88 -0
- ryni-0.1.0/src/ryni/models.py +45 -0
- ryni-0.1.0/src/ryni/rules/__init__.py +98 -0
- ryni-0.1.0/src/ryni/rules/claude_symlink.py +30 -0
- ryni-0.1.0/src/ryni/rules/skill_frontmatter.py +47 -0
- ryni-0.1.0/src/ryni/rules/skill_management.py +100 -0
- ryni-0.1.0/src/ryni/skill_repository.py +41 -0
- ryni-0.1.0/src/ryni/skill_repository_local.py +235 -0
- ryni-0.1.0/tests/repository/__init__.py +0 -0
- ryni-0.1.0/tests/repository/fake_repository.py +28 -0
- ryni-0.1.0/tests/repository/test_local_repository.py +281 -0
- ryni-0.1.0/tests/repository/test_skill_management.py +73 -0
- ryni-0.1.0/tests/test_catalog.py +117 -0
- ryni-0.1.0/tests/test_claude_symlink.py +54 -0
- ryni-0.1.0/tests/test_cli.py +99 -0
- ryni-0.1.0/tests/test_engine.py +114 -0
- ryni-0.1.0/tests/test_plugins.py +78 -0
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))
|