Skip to content

Commit e355481

Browse files
committed
Cleanup code
1 parent 0ea7b5e commit e355481

9 files changed

Lines changed: 153 additions & 129 deletions

File tree

sync_ai_rules/__main__.py

Lines changed: 73 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
"""
3-
This script uses a plugin architecture to parse rules and generate documentation.
3+
Sync AI Rules - Plugin-based rule parser and documentation generator.
4+
Scans source directories, parses rules, and generates documentation sections.
45
"""
56

67
import os
@@ -14,72 +15,55 @@
1415

1516

1617
def find_project_root() -> str:
17-
"""Find the project root by looking for key indicators."""
18+
"""Find project root by looking for .cursor/rules or .code_review directories."""
1819
current = Path.cwd()
1920

20-
# Look for .cursor/rules directory
2121
for path in [current] + list(current.parents):
22-
if (path / ".cursor" / "rules").exists():
22+
if (path / ".cursor" / "rules").exists() or (path / ".code_review").exists():
2323
return str(path)
2424

25-
# Fallback to current directory
2625
return str(current)
2726

2827

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)
28+
def get_category(file_path: str, source_dir: str) -> str:
29+
"""Extract category from file path relative to source directory."""
30+
rel_path = os.path.relpath(file_path, source_dir)
3231
folder = os.path.dirname(rel_path)
32+
return folder if folder and folder != "." else "root"
3333

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 = {}
4334

35+
def group_by_category(rules: List[RuleMetadata]) -> Dict[str, List[RuleMetadata]]:
36+
"""Group rules by category."""
37+
groups: Dict[str, List[RuleMetadata]] = {}
4438
for rule in rules:
45-
category = rule.category
46-
if category not in groups:
47-
groups[category] = []
48-
groups[category].append(rule)
49-
39+
groups.setdefault(rule.category, []).append(rule)
5040
return groups
5141

5242

53-
def scan_rules_directory(
54-
rules_dir: str, project_root: str, plugin_manager: PluginManager
55-
) -> List[RuleMetadata]:
56-
"""Scan a directory for rules and parse them."""
43+
def scan_and_parse(parser, source_dir: str, project_root: str) -> List[RuleMetadata]:
44+
"""Scan directory and parse files with given parser."""
5745
rules = []
5846

59-
if not os.path.exists(rules_dir):
47+
if not os.path.exists(source_dir):
6048
return rules
6149

62-
for root, dirs, files in os.walk(rules_dir):
63-
# Skip generated and personal directories
50+
for root, _, files in os.walk(source_dir):
51+
# Skip generated/personal directories
6452
if "generated" in Path(root).parts or "personal" in Path(root).parts:
6553
continue
6654

6755
for file in files:
6856
file_path = os.path.join(root, file)
6957

70-
# Find appropriate parser
71-
parser = plugin_manager.get_parser_for_file(file_path)
72-
if not parser:
58+
if not parser.can_parse(file_path):
7359
continue
7460

75-
# Create parsing context
7661
context = {
7762
"project_root": project_root,
7863
"relative_path": os.path.relpath(file_path, project_root),
79-
"category": get_category(file_path, rules_dir),
64+
"category": get_category(file_path, source_dir),
8065
}
8166

82-
# Parse the rule
8367
rule = parser.parse(file_path, context)
8468
if rule:
8569
rules.append(rule)
@@ -88,76 +72,74 @@ def scan_rules_directory(
8872

8973

9074
def main():
91-
"""Main entry point."""
92-
# Find project root
75+
"""Main orchestration: load plugins → parse → generate → update files."""
76+
# Setup
9377
project_root = find_project_root()
94-
cursor_rules_dir = os.path.join(project_root, ".cursor", "rules")
95-
code_review_dir = os.path.join(project_root, ".code_review")
96-
97-
if not os.path.exists(cursor_rules_dir) and not os.path.exists(code_review_dir):
98-
print("Error: Neither .cursor/rules nor .code_review directory found")
99-
sys.exit(1)
100-
101-
# Initialize plugin manager
10278
script_dir = os.path.dirname(os.path.abspath(__file__))
79+
10380
plugin_manager = PluginManager()
10481
plugin_manager.load_plugins(script_dir)
10582

106-
# Scan development rules (.cursor/rules/)
107-
print(f"Scanning development rules from: {cursor_rules_dir}")
108-
dev_rules = scan_rules_directory(cursor_rules_dir, project_root, plugin_manager)
109-
grouped_dev_rules = group_rules_by_category(dev_rules)
110-
111-
# Scan code review guidelines (.code_review/)
112-
print(f"Scanning code review guidelines from: {code_review_dir}")
113-
review_rules = scan_rules_directory(code_review_dir, project_root, plugin_manager)
114-
grouped_review_rules = group_rules_by_category(review_rules)
115-
116-
# Print summary
117-
total_dev_rules = sum(len(rules) for rules in grouped_dev_rules.values())
118-
total_review_rules = sum(len(rules) for rules in grouped_review_rules.values())
119-
print(f"\nFound {total_dev_rules} development rules in {len(grouped_dev_rules)} categories")
120-
print(
121-
f"Found {total_review_rules} code review guidelines in {len(grouped_review_rules)} categories"
122-
)
123-
124-
# Get generators
125-
dev_generator = plugin_manager.get_generator("development-rules")
126-
review_generator = plugin_manager.get_generator("code-review-guidelines")
127-
128-
if not dev_generator or not review_generator:
129-
print("Error: Required generators not found")
83+
# Process each parser → generator pair
84+
results = {}
85+
86+
for parser in plugin_manager.parsers.values():
87+
# Get source directories from parser
88+
source_dirs = parser.source_directories
89+
if not source_dirs:
90+
continue
91+
92+
all_rules = []
93+
for rel_dir in source_dirs:
94+
source_dir = os.path.join(project_root, rel_dir)
95+
print(f"Scanning {rel_dir}...")
96+
rules = scan_and_parse(parser, source_dir, project_root)
97+
all_rules.extend(rules)
98+
99+
if not all_rules:
100+
continue
101+
102+
# Group rules by category
103+
grouped_rules = group_by_category(all_rules)
104+
105+
print(f" Found {len(all_rules)} rules in {len(grouped_rules)} categories")
106+
107+
# Store for generator
108+
results[parser.name] = grouped_rules
109+
110+
if not results:
111+
print("Error: No rules found in any source directory")
130112
sys.exit(1)
131113

132-
# Generate content for both sections
133-
dev_content = dev_generator.generate(grouped_dev_rules, {}) if dev_rules else None
134-
review_content = review_generator.generate(grouped_review_rules, {}) if review_rules else None
114+
# Generate and update documentation
115+
print("\nGenerating documentation...")
135116

136-
# Update output files (both generators use same target files)
117+
# Get target files (all generators use same files)
118+
first_generator = next(iter(plugin_manager.generators.values()))
137119
output_files = [
138-
os.path.join(project_root, filename) for filename in dev_generator.default_filenames
120+
os.path.join(project_root, filename) for filename in first_generator.default_filenames
139121
]
140122

141-
for file_path in output_files:
142-
# Update development rules section
143-
if dev_content:
144-
success, message = update_documentation_file(
145-
file_path, dev_content, dev_generator.get_section_markers()
146-
)
147-
if success:
148-
print(f"✓ Development rules: {message}")
149-
else:
150-
print(f"✗ Development rules: {message}")
123+
# Generate content from each generator
124+
for parser_name, grouped_rules in results.items():
125+
# Get the generator for this parser
126+
generator_name = plugin_manager.parser_to_generator.get(parser_name)
127+
if not generator_name:
128+
continue
129+
130+
generator = plugin_manager.generators.get(generator_name)
131+
if not generator:
132+
continue
133+
134+
content = generator.generate(grouped_rules, {})
151135

152-
# Update code review guidelines section
153-
if review_content:
136+
# Update all target files
137+
for file_path in output_files:
154138
success, message = update_documentation_file(
155-
file_path, review_content, review_generator.get_section_markers()
139+
file_path, content, generator.get_section_markers()
156140
)
157-
if success:
158-
print(f"✓ Code review guidelines: {message}")
159-
else:
160-
print(f"✗ Code review guidelines: {message}")
141+
status = "✓" if success else "✗"
142+
print(f"{status} {generator.name}: {message}")
161143

162144
print("\n✓ Rules synchronization completed!")
163145

sync_ai_rules/core/interfaces.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ def name(self) -> str:
4141
def supported_extensions(self) -> List[str]:
4242
"""File extensions this parser can handle."""
4343

44+
@property
45+
def source_directories(self) -> List[str]:
46+
"""
47+
Relative paths to directories this parser should scan.
48+
Override in subclass if parser is specific to certain directories.
49+
Returns empty list by default (scans all compatible files).
50+
"""
51+
return []
52+
4453
@abstractmethod
4554
def can_parse(self, file_path: str) -> bool:
4655
"""Check if this parser can handle the given file."""

sync_ai_rules/core/plugin_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class PluginManager:
1818
def __init__(self):
1919
self.parsers: Dict[str, InputParser] = {}
2020
self.generators: Dict[str, OutputGenerator] = {}
21+
self.parser_to_generator: Dict[str, str] = {} # Maps parser name to generator name
2122

2223
def load_plugins(self, base_path: str):
2324
"""Load all plugins from plugins.yaml configuration file."""
@@ -34,6 +35,9 @@ def load_plugins(self, base_path: str):
3435
# Load parsers
3536
for parser_config in config.get("parsers", []):
3637
self._load_parser(base_path, parser_config)
38+
# Store parser -> generator mapping
39+
if "generator" in parser_config:
40+
self.parser_to_generator[parser_config["name"]] = parser_config["generator"]
3741

3842
# Load generators
3943
for generator_config in config.get("generators", []):
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Base Generator - provides shared functionality for all generators.
4+
"""
5+
6+
from abc import abstractmethod
7+
from typing import List
8+
9+
from sync_ai_rules.core.interfaces import OutputGenerator, RuleMetadata
10+
11+
12+
class BaseGenerator(OutputGenerator):
13+
"""Base class for all generators with shared functionality."""
14+
15+
@property
16+
def default_filenames(self) -> List[str]:
17+
"""Default target files for all generators."""
18+
return [
19+
"CLAUDE.md",
20+
"AGENTS.md",
21+
".github/copilot-instructions.md",
22+
]
23+
24+
def _format_heading(self, category: str) -> str:
25+
"""Format category as heading."""
26+
if category == "root":
27+
return "Root Rules"
28+
29+
# Convert folder name to title case
30+
return category.replace("-", " ").replace("_", " ").title()
31+
32+
def _sort_categories(self, categories: List[str]) -> List[str]:
33+
"""Sort categories with 'root' always last."""
34+
return sorted(categories, key=lambda x: (x == "root", x))
35+
36+
def _sort_rules_by_title(self, rules: List[RuleMetadata]) -> List[RuleMetadata]:
37+
"""Sort rules alphabetically by title."""
38+
return sorted(rules, key=lambda r: r.title)
39+
40+
@abstractmethod
41+
def _format_rule(self, rule: RuleMetadata) -> List[str]:
42+
"""Format individual rule as markdown. Must be implemented by subclasses."""

sync_ai_rules/generators/code_review_guidelines_generator.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,17 @@
55

66
from typing import Any, Dict, List
77

8-
from sync_ai_rules.core.interfaces import OutputGenerator, RuleMetadata
8+
from sync_ai_rules.core.interfaces import RuleMetadata
9+
from sync_ai_rules.generators.base_generator import BaseGenerator
910

1011

11-
class CodeReviewGuidelinesGenerator(OutputGenerator):
12+
class CodeReviewGuidelinesGenerator(BaseGenerator):
1213
"""Generate code review guidelines documentation from .code_review/ rules."""
1314

1415
@property
1516
def name(self) -> str:
1617
return "code-review-guidelines"
1718

18-
@property
19-
def default_filenames(self) -> List[str]:
20-
return [
21-
"CLAUDE.md",
22-
"AGENTS.md",
23-
".github/copilot-instructions.md",
24-
]
25-
2619
def generate(self, rules: Dict[str, List[RuleMetadata]], config: Dict[str, Any]) -> str:
2720
"""Generate review guidelines content with XML tags."""
2821
lines = [
@@ -34,7 +27,7 @@ def generate(self, rules: Dict[str, List[RuleMetadata]], config: Dict[str, Any])
3427
]
3528

3629
# Sort categories (root comes last)
37-
sorted_categories = sorted(rules.keys(), key=lambda x: (x == "root", x))
30+
sorted_categories = self._sort_categories(list(rules.keys()))
3831

3932
for category in sorted_categories:
4033
category_rules = rules[category]
@@ -48,7 +41,7 @@ def generate(self, rules: Dict[str, List[RuleMetadata]], config: Dict[str, Any])
4841
lines.append("")
4942

5043
# Add each rule
51-
for rule in sorted(category_rules, key=lambda r: r.title):
44+
for rule in self._sort_rules_by_title(category_rules):
5245
lines.extend(self._format_rule(rule))
5346
lines.append("")
5447

@@ -59,11 +52,6 @@ def get_section_markers(self) -> tuple[str, str]:
5952
"""Return XML tags for the auto-generated section."""
6053
return ("<code-review-guidelines>", "</code-review-guidelines>")
6154

62-
def _format_heading(self, category: str) -> str:
63-
"""Format category as heading."""
64-
# Convert folder name to title case
65-
return category.replace("-", " ").title()
66-
6755
def _format_rule(self, rule: RuleMetadata) -> List[str]:
6856
"""Format individual rule as markdown."""
6957
# Use @ prefix for rule path

0 commit comments

Comments
 (0)