Skip to content

Commit 0850522

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

File tree

7 files changed

+117
-10
lines changed

7 files changed

+117
-10
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: 53 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,12 @@ 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+
help="Filter rules by tags (comma-separated, case-insensitive, AND logic). Example: --tag api,web-security",
292+
)
259293

260294
cli_args = parser.parse_args()
261295
source_paths = _resolve_source_paths(cli_args)
@@ -316,7 +350,16 @@ def _resolve_source_paths(args) -> list[Path]:
316350
print()
317351

318352
# Convert all sources
319-
aggregated = {"success": [], "errors": []}
353+
aggregated = {"success": [], "errors": [], "skipped": []}
354+
# Parse comma-separated tags
355+
filter_tags = None
356+
if cli_args.tags:
357+
filter_tags = [tag.strip() for tag in cli_args.tags.split(",") if tag.strip()]
358+
359+
# Print tag filter info if active
360+
if filter_tags:
361+
print(f"Tag filter active: {', '.join(filter_tags)} (AND logic - rules must have all tags)\n")
362+
320363
for source_path in source_paths:
321364
is_core = source_path == Path("sources/core")
322365

@@ -325,11 +368,14 @@ def _resolve_source_paths(args) -> list[Path]:
325368
str(source_path),
326369
cli_args.output_dir,
327370
include_claudecode=is_core,
328-
version=version
371+
version=version,
372+
filter_tags=filter_tags
329373
)
330374

331375
aggregated["success"].extend(results["success"])
332376
aggregated["errors"].extend(results["errors"])
377+
if "skipped" in results:
378+
aggregated["skipped"].extend(results["skipped"])
333379
print("")
334380

335381
if aggregated["errors"]:

src/converter.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pathlib import Path
1313

1414
from language_mappings import languages_to_globs
15-
from utils import parse_frontmatter_and_content
15+
from utils import parse_frontmatter_and_content, validate_tags
1616
from formats import (
1717
BaseFormat,
1818
ProcessedRule,
@@ -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,11 @@ 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+
tags = validate_tags(frontmatter["tags"], filename)
169+
162170
# Adding rule_id to the beginning of the content
163171
rule_id = Path(filename).stem
164172
markdown_content = f"rule_id: {rule_id}\n\n{markdown_content}"
@@ -169,6 +177,7 @@ def parse_rule(self, content: str, filename: str) -> ProcessedRule:
169177
always_apply=always_apply,
170178
content=markdown_content,
171179
filename=filename,
180+
tags=tags,
172181
)
173182

174183
def generate_globs(self, languages: list[str]) -> str:
@@ -242,4 +251,5 @@ def convert(self, filepath: str) -> ConversionResult:
242251
basename=basename,
243252
outputs=outputs,
244253
languages=rule.languages,
254+
tags=rule.tags,
245255
)

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/utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,41 @@ def parse_frontmatter_and_content(content: str) -> tuple[dict | None, str]:
5757
return frontmatter, markdown_content.strip()
5858

5959

60+
def validate_tags(tags, filename=None) -> list[str]:
61+
"""
62+
Validate tags list and return normalized (lowercase) tags.
63+
64+
Args:
65+
tags: The tags value to validate (should be a list)
66+
filename: Optional filename for better error messages
67+
68+
Returns:
69+
List of normalized (lowercase) tags
70+
71+
Raises:
72+
ValueError: If tags are invalid (wrong type, contain whitespace, empty, etc.)
73+
"""
74+
context = f" in {filename}" if filename else ""
75+
76+
if not isinstance(tags, list):
77+
raise ValueError(f"'tags' must be a list{context}")
78+
79+
normalized = []
80+
for tag in tags:
81+
if not isinstance(tag, str):
82+
raise ValueError(f"All tags must be strings{context}, found: {type(tag).__name__}")
83+
84+
if any(c.isspace() for c in tag):
85+
raise ValueError(f"Tags cannot contain whitespace: '{tag}'{context}")
86+
87+
if not tag:
88+
raise ValueError(f"Empty tag found{context}")
89+
90+
normalized.append(tag.lower())
91+
92+
return normalized
93+
94+
6095
def get_version_from_pyproject() -> str:
6196
"""
6297
Read version from pyproject.toml using Python's built-in TOML parser.

src/validate_unified_rules.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pathlib import Path
1313

1414
from language_mappings import LANGUAGE_TO_EXTENSIONS
15-
from utils import parse_frontmatter_and_content
15+
from utils import parse_frontmatter_and_content, validate_tags
1616

1717

1818
def validate_rule(file_path: Path) -> dict[str, list[str]]:
@@ -54,6 +54,13 @@ 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+
try:
60+
validate_tags(frontmatter["tags"], file_path.name)
61+
except ValueError as e:
62+
errors.append(str(e))
63+
5764
# Check content exists
5865
if not markdown_content.strip():
5966
errors.append("Rule content cannot be empty")

0 commit comments

Comments
 (0)