Skip to content

Commit 38ec432

Browse files
Integrate sync-ai-rules into pre-commit-hooks (#52)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 576183d commit 38ec432

17 files changed

Lines changed: 694 additions & 7 deletions

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
!ruff.toml
77
!stylesheet.xml
88
!svgo.config.js
9+
!sync_ai_rules

.pre-commit-hooks.yaml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,30 @@
33
entry: duolingo/pre-commit-hooks:1.10.0 /entry
44
language: docker_image
55
types: [text]
6+
exclude: &sync_ai_rules_output (AGENTS|CLAUDE|copilot-instructions)\.md$
67

7-
# Nobody should ever use this hook in production. It's just for testing PRs in
8+
- id: sync-ai-rules
9+
name: Sync AI Rules
10+
entry: &sync_ai_rules_entry sh -c "PYTHONPATH=/ python3 -m sync_ai_rules"
11+
language: docker
12+
files: ^\.cursor/rules/.*\.mdc$
13+
pass_filenames: false
14+
15+
# Nobody should ever use these hooks in production. They're just for testing PRs in
816
# the duolingo/pre-commit-hooks repo more easily without having to tag and push
9-
# temporary images to Docker Hub. Usage: edit a consumer repo's `id: duolingo`
10-
# hook config to instead declare `id: duolingo-dev` and `rev: <PR branch SHA>`,
11-
# then run `pre-commit run duolingo-dev --all-files`
17+
# temporary images to Docker Hub. Usage: edit a consumer repo's hook config to
18+
# instead declare `id: duolingo-dev` or `id: sync-ai-rules-dev` and `rev: <PR branch SHA>`,
19+
# then run `pre-commit run <hook-id> --all-files`
1220
- id: duolingo-dev
1321
name: Duolingo (dev)
1422
entry: /entry
1523
language: docker
1624
types: [text]
25+
exclude: *sync_ai_rules_output
26+
27+
- id: sync-ai-rules-dev
28+
name: Sync AI Rules (dev)
29+
entry: *sync_ai_rules_entry
30+
language: docker
31+
files: ^\.cursor/rules/.*\.mdc$
32+
pass_filenames: false

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ apk add --no-cache \
4646
pip3 install --break-system-packages \
4747
autoflake==1.7.8 \
4848
isort==5.13.2 \
49-
ruff==0.7.3
49+
ruff==0.7.3 \
50+
PyYAML>=6.0
5051

5152
# Install Python dependencies
5253
python3 -m venv /black21-venv

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# pre-commit hooks
22

3-
This repo currently contains a single [pre-commit](https://pre-commit.com/) hook that internally runs several code formatters in parallel:
3+
This repo contains [pre-commit](https://pre-commit.com/) hooks for Duolingo development:
4+
5+
## Code Formatting Hook (`duolingo`)
6+
7+
The main hook that runs several code formatters in parallel:
48

59
- [Prettier](https://github.com/prettier/prettier) v3.5.3 for CSS, HTML, JS, JSX, Markdown, Sass, TypeScript, XML, YAML
610
- [ESLint](https://eslint.org/) v9.23.0 for JS, TypeScript
@@ -29,18 +33,25 @@ To minimize developer friction, we enable only rules whose violations can be fix
2933

3034
We run this hook on developer workstations and enforce it in CI for all production repos at Duolingo.
3135

36+
## Sync AI Rules Hook (`sync-ai-rules`)
37+
38+
This hook synchronizes AI coding rules from .cursor/rules/\*.mdc files to other AI assistant configuration files (CLAUDE.md, AGENTS.md, etc.). This ensures all AI coding assistants in your project stay aware of the same rules and conventions, eliminating the need to manually copy rules between different AI config files.
39+
3240
## Usage
3341

34-
Repo maintainers can declare this hook in `.pre-commit-config.yaml`:
42+
Repo maintainers can declare these hooks in `.pre-commit-config.yaml`:
3543

3644
```yaml
3745
- repo: https://github.com/duolingo/pre-commit-hooks.git
3846
rev: 1.10.0
3947
hooks:
48+
# Code formatting hook
4049
- id: duolingo
4150
args: # Optional
4251
- --python-version=2 # Defaults to Python 3
4352
- --scala-version=3 # Defaults to Scala 2.12
53+
# Sync AI rules hook (for repos with Cursor AI rules)
54+
- id: sync-ai-rules
4455
```
4556
4657
Directories named `build` and `node_modules` are excluded by default - no need to declare them in the hook's `exclude` key.

sync_ai_rules/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# Ruff cache
7+
.ruff_cache/

sync_ai_rules/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# sync-ai-rules
2+
3+
- Synchronize AI coding rules from .cursor/rules/\*.mdc files to other AI assistant configuration files (CLAUDE.md, AGENTS.md, etc.)
4+
- Built with a plugin architecture that can easily support new input/output formats as the ecosystem evolves
5+
6+
## Extending to new formats
7+
8+
### Adding New Input Parsers
9+
10+
Create a parser class implementing `InputParser` in `sync_ai_rules/parsers/`:
11+
12+
```python
13+
from sync_ai_rules.core.interfaces import InputParser, RuleMetadata
14+
15+
class YourParser(InputParser):
16+
@property
17+
def name(self) -> str:
18+
return "your-format"
19+
20+
# Implement required methods...
21+
```
22+
23+
### Adding New Output Generators
24+
25+
Create a generator class implementing `OutputGenerator` in `sync_ai_rules/generators/`:
26+
27+
```python
28+
from sync_ai_rules.core.interfaces import OutputGenerator
29+
30+
class YourGenerator(OutputGenerator):
31+
@property
32+
def name(self) -> str:
33+
return "your-output"
34+
35+
# Implement required methods...
36+
```
37+
38+
### Register Your Extensions
39+
40+
Add them to `sync_ai_rules/plugins.yaml`:
41+
42+
```yaml
43+
parsers:
44+
- name: your-format
45+
module: your_parser
46+
class: YourParser
47+
48+
generators:
49+
- name: your-output
50+
module: your_generator
51+
class: YourGenerator
52+
```

sync_ai_rules/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Sync AI Rules - Automates generation of AI rule configurations."""
2+
3+
__version__ = "1.0.0"

sync_ai_rules/__main__.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""
3+
This script uses a plugin architecture to parse rules and generate documentation.
4+
"""
5+
6+
import os
7+
import sys
8+
from pathlib import Path
9+
from typing import Dict, List
10+
11+
from sync_ai_rules.core.interfaces import RuleMetadata
12+
from sync_ai_rules.core.plugin_manager import PluginManager
13+
from sync_ai_rules.file_updater import update_documentation_file
14+
15+
16+
def find_project_root() -> str:
17+
"""Find the project root by looking for key indicators."""
18+
current = Path.cwd()
19+
20+
# Look for .cursor/rules directory
21+
for path in [current] + list(current.parents):
22+
if (path / ".cursor" / "rules").exists():
23+
return str(path)
24+
25+
# Fallback to current directory
26+
return str(current)
27+
28+
29+
def get_category(file_path: str, rules_dir: str) -> str:
30+
"""Get category name from file path."""
31+
rel_path = os.path.relpath(file_path, rules_dir)
32+
folder = os.path.dirname(rel_path)
33+
34+
if not folder or folder == ".":
35+
return "root"
36+
37+
return folder
38+
39+
40+
def group_rules_by_category(rules: List[RuleMetadata]) -> Dict[str, List[RuleMetadata]]:
41+
"""Group rules by their category."""
42+
groups = {}
43+
44+
for rule in rules:
45+
category = rule.category
46+
if category not in groups:
47+
groups[category] = []
48+
groups[category].append(rule)
49+
50+
return groups
51+
52+
53+
def main():
54+
"""Main entry point."""
55+
# Find project root
56+
project_root = find_project_root()
57+
rules_dir = os.path.join(project_root, ".cursor", "rules")
58+
59+
if not os.path.exists(rules_dir):
60+
print(f"Error: Rules directory not found: {rules_dir}")
61+
sys.exit(1)
62+
63+
print(f"Syncing rules from: {rules_dir}")
64+
65+
# Initialize plugin manager
66+
script_dir = os.path.dirname(os.path.abspath(__file__))
67+
plugin_manager = PluginManager()
68+
plugin_manager.load_plugins(script_dir)
69+
70+
# Scan for rules using available parsers
71+
rules = []
72+
for root, dirs, files in os.walk(rules_dir):
73+
# Skip generated and personal directories
74+
if "generated" in Path(root).parts or "personal" in Path(root).parts:
75+
continue
76+
77+
for file in files:
78+
file_path = os.path.join(root, file)
79+
80+
# Find appropriate parser
81+
parser = plugin_manager.get_parser_for_file(file_path)
82+
if not parser:
83+
continue
84+
85+
# Create parsing context
86+
context = {
87+
"relative_path": os.path.relpath(file_path, project_root),
88+
"category": get_category(file_path, rules_dir),
89+
}
90+
91+
# Parse the rule
92+
rule = parser.parse(file_path, context)
93+
if rule:
94+
rules.append(rule)
95+
96+
if not rules:
97+
print("No rules found to sync")
98+
return
99+
100+
# Group rules by category
101+
grouped_rules = group_rules_by_category(rules)
102+
103+
# Get markdown generator
104+
generator = plugin_manager.get_generator("markdown")
105+
if not generator:
106+
print("Error: Markdown generator not found")
107+
sys.exit(1)
108+
109+
# Generate content
110+
content = generator.generate(grouped_rules, {})
111+
112+
# Print summary
113+
total_rules = sum(len(rules) for rules in grouped_rules.values())
114+
print(f"\nFound {total_rules} rules in {len(grouped_rules)} categories")
115+
116+
# Update output files using generator's default filenames
117+
output_files = [
118+
os.path.join(project_root, filename) for filename in generator.default_filenames
119+
]
120+
121+
for file_path in output_files:
122+
success, message = update_documentation_file(
123+
file_path, content, generator.get_section_markers()
124+
)
125+
126+
if success:
127+
print(f"✓ {message}")
128+
else:
129+
print(f"✗ {message}")
130+
131+
print("\n✓ Rules synchronization completed!")
132+
133+
134+
if __name__ == "__main__":
135+
main()

sync_ai_rules/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Core plugin system for sync-ai-rules."""

sync_ai_rules/core/interfaces.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Core interfaces for sync_ai_rules plugin system.
4+
Defines abstract base classes for parsers and generators.
5+
"""
6+
7+
from abc import ABC, abstractmethod
8+
from dataclasses import dataclass
9+
from typing import Any, Dict, List, Optional
10+
11+
12+
@dataclass
13+
class RuleMetadata:
14+
"""Universal rule representation, format-agnostic."""
15+
16+
file_path: str
17+
relative_path: str
18+
title: str
19+
description: str
20+
scope_patterns: List[str]
21+
always_apply: bool
22+
category: str
23+
raw_content: str
24+
metadata: Dict[str, Any] = None
25+
26+
def __post_init__(self):
27+
if self.metadata is None:
28+
self.metadata = {}
29+
30+
31+
class InputParser(ABC):
32+
"""Abstract base class for all input parsers."""
33+
34+
@property
35+
@abstractmethod
36+
def name(self) -> str:
37+
"""Unique name for this parser."""
38+
39+
@property
40+
@abstractmethod
41+
def supported_extensions(self) -> List[str]:
42+
"""File extensions this parser can handle."""
43+
44+
@abstractmethod
45+
def can_parse(self, file_path: str) -> bool:
46+
"""Check if this parser can handle the given file."""
47+
48+
@abstractmethod
49+
def parse(self, file_path: str, context: Dict[str, Any]) -> Optional[RuleMetadata]:
50+
"""Parse a file and return standardized metadata."""
51+
52+
53+
class OutputGenerator(ABC):
54+
"""Abstract base class for all output generators."""
55+
56+
@property
57+
@abstractmethod
58+
def name(self) -> str:
59+
"""Unique name for this generator."""
60+
61+
@property
62+
@abstractmethod
63+
def default_filenames(self) -> List[str]:
64+
"""Default output filenames."""
65+
66+
@abstractmethod
67+
def generate(self, rules: Dict[str, List[RuleMetadata]], config: Dict[str, Any]) -> str:
68+
"""Generate output content from grouped rules."""
69+
70+
@abstractmethod
71+
def get_section_markers(self) -> tuple[str, str]:
72+
"""Return start and end markers for auto-generated section."""

0 commit comments

Comments
 (0)