Skip to content

Commit c46399b

Browse files
Added optional tags field and filtering support
1 parent aa89c04 commit c46399b

File tree

6 files changed

+103
-8
lines changed

6 files changed

+103
-8
lines changed

sources/core/codeguard-0-api-web-services.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ languages:
1212
- typescript
1313
- xml
1414
- yaml
15+
tags:
16+
- api
17+
- web-security
18+
- microservices
1519
alwaysApply: false
1620
---
1721

sources/core/codeguard-0-authentication-mfa.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ languages:
1313
- ruby
1414
- swift
1515
- typescript
16+
tags:
17+
- authentication
18+
- web-security
1619
alwaysApply: false
1720
---
1821

src/convert_to_ide_formats.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ def sync_plugin_metadata(version: str) -> None:
3636
print(f"✅ Synced plugin metadata to {version}")
3737

3838

39+
def matches_tag_filter(rule_tags: list[str], filter_tags: list[str]) -> bool:
40+
"""
41+
Check if rule has all required tags (case-insensitive AND logic).
42+
43+
Args:
44+
rule_tags: List of tags from the rule (already lowercase from parsing)
45+
filter_tags: List of tags to filter by
46+
47+
Returns:
48+
True if rule has all filter tags (or no filter), False otherwise
49+
"""
50+
if not filter_tags:
51+
return True # No filter means all pass
52+
53+
return all(tag.lower() in rule_tags for tag in filter_tags)
54+
55+
3956
def update_skill_md(language_to_rules: dict[str, list[str]], skill_path: str) -> None:
4057
"""
4158
Update SKILL.md with language-to-rules mapping table.
@@ -81,7 +98,7 @@ def update_skill_md(language_to_rules: dict[str, list[str]], skill_path: str) ->
8198
print(f"Updated SKILL.md with language mappings")
8299

83100

84-
def convert_rules(input_path: str, output_dir: str = "dist", include_claudecode: bool = True, version: str = None) -> dict[str, list[str]]:
101+
def convert_rules(input_path: str, output_dir: str = "dist", include_claudecode: bool = True, version: str = None, filter_tags: list[str] = None) -> dict[str, list[str]]:
85102
"""
86103
Convert rule file(s) to all supported IDE formats using RuleConverter.
87104
@@ -90,6 +107,7 @@ def convert_rules(input_path: str, output_dir: str = "dist", include_claudecode:
90107
output_dir: Output directory (default: 'dist/')
91108
include_claudecode: Whether to generate Claude Code plugin (default: True, only for core rules)
92109
version: Version string to use (default: read from pyproject.toml)
110+
filter_tags: Optional list of tags to filter by (AND logic, case-insensitive)
93111
94112
Returns:
95113
Dictionary with 'success' and 'errors' lists:
@@ -138,14 +156,19 @@ def convert_rules(input_path: str, output_dir: str = "dist", include_claudecode:
138156
# Setup output directory
139157
output_base = Path(output_dir)
140158

141-
results = {"success": [], "errors": []}
159+
results = {"success": [], "errors": [], "skipped": []}
142160
language_to_rules = defaultdict(list)
143161

144162
# Process each file
145163
for md_file in md_files:
146164
try:
147165
# Convert the file (raises exceptions on error)
148166
result = converter.convert(md_file)
167+
168+
# Apply tag filter if specified
169+
if filter_tags and not matches_tag_filter(result.tags, filter_tags):
170+
results["skipped"].append(result.filename)
171+
continue
149172

150173
# Write each format
151174
output_files = []
@@ -192,9 +215,14 @@ def convert_rules(input_path: str, output_dir: str = "dist", include_claudecode:
192215
results["errors"].append(error_msg)
193216

194217
# Summary
195-
print(
196-
f"\nResults: {len(results['success'])} success, {len(results['errors'])} errors"
197-
)
218+
if filter_tags:
219+
print(
220+
f"\nResults: {len(results['success'])} success, {len(results['skipped'])} skipped (tag filter), {len(results['errors'])} errors"
221+
)
222+
else:
223+
print(
224+
f"\nResults: {len(results['success'])} success, {len(results['errors'])} errors"
225+
)
198226

199227
# Generate SKILL.md with language mappings (only if Claude Code is included)
200228
if include_claudecode and language_to_rules:
@@ -256,6 +284,13 @@ def _resolve_source_paths(args) -> list[Path]:
256284
default="dist",
257285
help="Output directory for generated bundles (default: dist).",
258286
)
287+
parser.add_argument(
288+
"--tag",
289+
"--tags",
290+
dest="tags",
291+
action="append",
292+
help="Filter rules by tags (case-insensitive, AND logic). Can be specified multiple times.",
293+
)
259294

260295
cli_args = parser.parse_args()
261296
source_paths = _resolve_source_paths(cli_args)
@@ -316,7 +351,13 @@ def _resolve_source_paths(args) -> list[Path]:
316351
print()
317352

318353
# Convert all sources
319-
aggregated = {"success": [], "errors": []}
354+
aggregated = {"success": [], "errors": [], "skipped": []}
355+
filter_tags = cli_args.tags if cli_args.tags else None
356+
357+
# Print tag filter info if active
358+
if filter_tags:
359+
print(f"Tag filter active: {', '.join(filter_tags)} (AND logic - rules must have all tags)\n")
360+
320361
for source_path in source_paths:
321362
is_core = source_path == Path("sources/core")
322363

@@ -325,11 +366,14 @@ def _resolve_source_paths(args) -> list[Path]:
325366
str(source_path),
326367
cli_args.output_dir,
327368
include_claudecode=is_core,
328-
version=version
369+
version=version,
370+
filter_tags=filter_tags
329371
)
330372

331373
aggregated["success"].extend(results["success"])
332374
aggregated["errors"].extend(results["errors"])
375+
if "skipped" in results:
376+
aggregated["skipped"].extend(results["skipped"])
333377
print("")
334378

335379
if aggregated["errors"]:

src/converter.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class ConversionResult:
4545
basename: Filename without extension (e.g., 'my-rule')
4646
outputs: Dictionary mapping format names to their outputs
4747
languages: List of programming languages the rule applies to, empty list if always applies
48+
tags: List of tags for categorizing and filtering rules
4849
Example:
4950
result = ConversionResult(
5051
filename="my-rule.md",
@@ -56,14 +57,16 @@ class ConversionResult:
5657
subpath=".cursor/rules"
5758
)
5859
},
59-
languages=["python", "javascript"]
60+
languages=["python", "javascript"],
61+
tags=["authentication", "web-security"]
6062
)
6163
"""
6264

6365
filename: str
6466
basename: str
6567
outputs: dict[str, FormatOutput]
6668
languages: list[str]
69+
tags: list[str]
6770

6871

6972
class RuleConverter:
@@ -159,6 +162,28 @@ def parse_rule(self, content: str, filename: str) -> ProcessedRule:
159162
f"'languages' must be a non-empty list in {filename} when alwaysApply is false"
160163
)
161164

165+
# Parse and validate tags (optional field)
166+
tags = []
167+
if "tags" in frontmatter:
168+
raw_tags = frontmatter["tags"]
169+
if not isinstance(raw_tags, list):
170+
raise ValueError(f"'tags' must be a list in {filename}")
171+
172+
for tag in raw_tags:
173+
if not isinstance(tag, str):
174+
raise ValueError(f"All tags must be strings in {filename}")
175+
176+
# Check for whitespace characters
177+
if any(c.isspace() for c in tag):
178+
raise ValueError(
179+
f"Tags cannot contain spaces or whitespace characters in {filename}: '{tag}'"
180+
)
181+
182+
if not tag:
183+
raise ValueError(f"Empty tag found in {filename}")
184+
185+
tags.append(tag.lower())
186+
162187
# Adding rule_id to the beginning of the content
163188
rule_id = Path(filename).stem
164189
markdown_content = f"rule_id: {rule_id}\n\n{markdown_content}"
@@ -169,6 +194,7 @@ def parse_rule(self, content: str, filename: str) -> ProcessedRule:
169194
always_apply=always_apply,
170195
content=markdown_content,
171196
filename=filename,
197+
tags=tags,
172198
)
173199

174200
def generate_globs(self, languages: list[str]) -> str:
@@ -242,4 +268,5 @@ def convert(self, filepath: str) -> ConversionResult:
242268
basename=basename,
243269
outputs=outputs,
244270
languages=rule.languages,
271+
tags=rule.tags,
245272
)

src/formats/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ class ProcessedRule:
2525
always_apply: Whether this rule should apply to all files
2626
content: The actual rule content in markdown format
2727
filename: Original filename of the rule
28+
tags: List of tags for categorizing and filtering rules
2829
"""
2930

3031
description: str
3132
languages: list[str]
3233
always_apply: bool
3334
content: str
3435
filename: str
36+
tags: list[str]
3537

3638

3739
class BaseFormat(ABC):

src/validate_unified_rules.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ def validate_rule(file_path: Path) -> dict[str, list[str]]:
5454
if unknown:
5555
warnings.append(f"Unknown languages: {', '.join(unknown)}")
5656

57+
# Validate tags if present
58+
if "tags" in frontmatter:
59+
tags = frontmatter["tags"]
60+
if not isinstance(tags, list):
61+
errors.append("'tags' must be a list")
62+
elif tags: # Only validate if not empty
63+
for tag in tags:
64+
if not isinstance(tag, str):
65+
errors.append(f"All tags must be strings, found: {type(tag).__name__}")
66+
break
67+
elif any(c.isspace() for c in tag):
68+
errors.append(f"Tags cannot contain whitespace: '{tag}'")
69+
elif not tag:
70+
errors.append("Empty tag found")
71+
5772
# Check content exists
5873
if not markdown_content.strip():
5974
errors.append("Rule content cannot be empty")

0 commit comments

Comments
 (0)