Skip to content

Commit 339ef8a

Browse files
committed
Add rule_library with YAML schema validation rule
Create a top-level rule_library folder for example rules. Add yaml-schema-validation rule that: - Triggers on all .yml and .yaml files - Detects $schema declarations (URLs or local paths) - Validates files against their declared JSON Schema - Returns pass for valid files or blocking JSON with error details - Uses compare_to: prompt Includes validation script and test examples.
1 parent 79b36ad commit 339ef8a

File tree

5 files changed

+288
-0
lines changed

5 files changed

+288
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
$schema: ./test-schema.json
2+
name: example
3+
version: 123
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "https://json-schema.org/draft-07/schema",
3+
"type": "object",
4+
"required": ["name", "version"],
5+
"properties": {
6+
"name": {
7+
"type": "string"
8+
},
9+
"version": {
10+
"type": "string"
11+
}
12+
}
13+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
$schema: ./test-schema.json
2+
name: example
3+
version: "1.0.0"
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Validate YAML files against their declared JSON Schema.
4+
5+
Looks for a $schema declaration at the top of YAML files and validates
6+
the file content against that schema. The schema can be a URL or a local path.
7+
8+
Exit codes:
9+
0 - Validation passed (or no schema declared)
10+
1 - Validation failed (outputs JSON with failure details)
11+
2 - Error fetching/loading schema
12+
"""
13+
14+
import json
15+
import sys
16+
import urllib.request
17+
import urllib.error
18+
from pathlib import Path
19+
20+
try:
21+
import yaml
22+
except ImportError:
23+
print(json.dumps({
24+
"status": "error",
25+
"message": "PyYAML is not installed. Run: pip install pyyaml"
26+
}))
27+
sys.exit(2)
28+
29+
try:
30+
import jsonschema
31+
from jsonschema import Draft7Validator, ValidationError
32+
except ImportError:
33+
print(json.dumps({
34+
"status": "error",
35+
"message": "jsonschema is not installed. Run: pip install jsonschema"
36+
}))
37+
sys.exit(2)
38+
39+
40+
def load_yaml_file(file_path: str) -> tuple[dict | list | None, str | None]:
41+
"""Load a YAML file and return its contents."""
42+
try:
43+
with open(file_path, "r", encoding="utf-8") as f:
44+
content = yaml.safe_load(f)
45+
return content, None
46+
except yaml.YAMLError as e:
47+
return None, f"Invalid YAML syntax: {e}"
48+
except FileNotFoundError:
49+
return None, f"File not found: {file_path}"
50+
except Exception as e:
51+
return None, f"Error reading file: {e}"
52+
53+
54+
def extract_schema_reference(content: dict) -> str | None:
55+
"""Extract the $schema reference from YAML content."""
56+
if not isinstance(content, dict):
57+
return None
58+
return content.get("$schema")
59+
60+
61+
def fetch_schema_from_url(url: str) -> tuple[dict | None, str | None]:
62+
"""Fetch a JSON Schema from a URL."""
63+
try:
64+
req = urllib.request.Request(
65+
url,
66+
headers={"User-Agent": "yaml-schema-validator/1.0"}
67+
)
68+
with urllib.request.urlopen(req, timeout=30) as response:
69+
schema_content = response.read().decode("utf-8")
70+
71+
# Try to parse as JSON first, then YAML
72+
try:
73+
return json.loads(schema_content), None
74+
except json.JSONDecodeError:
75+
try:
76+
return yaml.safe_load(schema_content), None
77+
except yaml.YAMLError as e:
78+
return None, f"Invalid schema format at URL: {e}"
79+
80+
except urllib.error.URLError as e:
81+
return None, f"Failed to fetch schema from URL: {e}"
82+
except Exception as e:
83+
return None, f"Error fetching schema: {e}"
84+
85+
86+
def load_schema_from_path(schema_path: str, yaml_file_path: str) -> tuple[dict | None, str | None]:
87+
"""Load a JSON Schema from a local file path."""
88+
# Resolve relative paths relative to the YAML file's directory
89+
path = Path(schema_path)
90+
if not path.is_absolute():
91+
yaml_dir = Path(yaml_file_path).parent
92+
path = yaml_dir / path
93+
94+
path = path.resolve()
95+
96+
if not path.exists():
97+
return None, f"Schema file not found: {path}"
98+
99+
try:
100+
with open(path, "r", encoding="utf-8") as f:
101+
content = f.read()
102+
103+
# Try to parse as JSON first, then YAML
104+
try:
105+
return json.loads(content), None
106+
except json.JSONDecodeError:
107+
try:
108+
return yaml.safe_load(content), None
109+
except yaml.YAMLError as e:
110+
return None, f"Invalid schema format: {e}"
111+
112+
except Exception as e:
113+
return None, f"Error reading schema file: {e}"
114+
115+
116+
def load_schema(schema_ref: str, yaml_file_path: str) -> tuple[dict | None, str | None]:
117+
"""Load a schema from either a URL or local path."""
118+
if schema_ref.startswith(("http://", "https://")):
119+
return fetch_schema_from_url(schema_ref)
120+
else:
121+
return load_schema_from_path(schema_ref, yaml_file_path)
122+
123+
124+
def validate_against_schema(content: dict, schema: dict) -> list[dict]:
125+
"""Validate content against a JSON Schema and return list of errors."""
126+
validator = Draft7Validator(schema)
127+
errors = []
128+
129+
for error in sorted(validator.iter_errors(content), key=lambda e: e.path):
130+
error_info = {
131+
"message": error.message,
132+
"path": "/" + "/".join(str(p) for p in error.absolute_path) if error.absolute_path else "/",
133+
"schema_path": "/" + "/".join(str(p) for p in error.absolute_schema_path) if error.absolute_schema_path else "/",
134+
}
135+
136+
# Add the failing value if it's simple enough to display
137+
if error.instance is not None and not isinstance(error.instance, (dict, list)):
138+
error_info["value"] = error.instance
139+
140+
errors.append(error_info)
141+
142+
return errors
143+
144+
145+
def main():
146+
if len(sys.argv) < 2:
147+
print(json.dumps({
148+
"status": "error",
149+
"message": "Usage: validate_yaml_schema.py <file.yaml>"
150+
}))
151+
sys.exit(2)
152+
153+
file_path = sys.argv[1]
154+
155+
# Load the YAML file
156+
content, error = load_yaml_file(file_path)
157+
if error:
158+
print(json.dumps({
159+
"status": "error",
160+
"file": file_path,
161+
"message": error
162+
}))
163+
sys.exit(2)
164+
165+
# Check for $schema reference
166+
schema_ref = extract_schema_reference(content)
167+
if not schema_ref:
168+
# No schema declared - pass silently
169+
print(json.dumps({
170+
"status": "pass",
171+
"file": file_path,
172+
"message": "No $schema declared, skipping validation"
173+
}))
174+
sys.exit(0)
175+
176+
# Load the schema
177+
schema, error = load_schema(schema_ref, file_path)
178+
if error:
179+
print(json.dumps({
180+
"status": "error",
181+
"file": file_path,
182+
"schema": schema_ref,
183+
"message": error
184+
}))
185+
sys.exit(2)
186+
187+
# Validate
188+
errors = validate_against_schema(content, schema)
189+
190+
if not errors:
191+
print(json.dumps({
192+
"status": "pass",
193+
"file": file_path,
194+
"schema": schema_ref,
195+
"message": "Validation passed"
196+
}))
197+
sys.exit(0)
198+
else:
199+
print(json.dumps({
200+
"status": "fail",
201+
"file": file_path,
202+
"schema": schema_ref,
203+
"error_count": len(errors),
204+
"errors": errors
205+
}, indent=2))
206+
sys.exit(1)
207+
208+
209+
if __name__ == "__main__":
210+
main()
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
name: YAML Schema Validation
3+
trigger:
4+
- "**/*.yml"
5+
- "**/*.yaml"
6+
action:
7+
command: python3 rule_library/scripts/validate_yaml_schema.py {file}
8+
run_for: each_match
9+
compare_to: prompt
10+
---
11+
Validates YAML files against their declared JSON Schema.
12+
13+
This rule triggers on any `.yml` or `.yaml` file that is modified. It looks for
14+
a `$schema` declaration at the top of the file:
15+
16+
```yaml
17+
$schema: https://json-schema.org/draft-07/schema
18+
# or
19+
$schema: ./schemas/my-schema.json
20+
```
21+
22+
## Behavior
23+
24+
- **Pass**: File validates against the declared schema, or no schema is declared
25+
- **Fail**: Returns a blocking JSON response with validation error details
26+
27+
## Example Output
28+
29+
On validation failure:
30+
```json
31+
{
32+
"status": "fail",
33+
"file": "config.yaml",
34+
"schema": "https://example.com/schemas/config.json",
35+
"error_count": 2,
36+
"errors": [
37+
{
38+
"message": "'name' is a required property",
39+
"path": "/",
40+
"schema_path": "/required"
41+
},
42+
{
43+
"message": "42 is not of type 'string'",
44+
"path": "/version",
45+
"schema_path": "/properties/version/type",
46+
"value": 42
47+
}
48+
]
49+
}
50+
```
51+
52+
## Requirements
53+
54+
The validation script requires:
55+
- Python 3.10+
56+
- `pyyaml` package
57+
- `jsonschema` package
58+
59+
Install with: `pip install pyyaml jsonschema`

0 commit comments

Comments
 (0)