Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/dda/cli/validate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
from __future__ import annotations

from dda.cli.base import dynamic_group


@dynamic_group(
short_help="Validate tools and utilities",
)
def cmd() -> None:
"""
Validate tools and utilities for development workflow.
"""
110 changes: 110 additions & 0 deletions src/dda/cli/validate/ai_rules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
from __future__ import annotations

from typing import TYPE_CHECKING

import click

from dda.cli.base import dynamic_command, pass_app
from dda.utils.diff import pretty_diff
from dda.utils.fs import Path

if TYPE_CHECKING:
from dda.cli.application import Application

CURSOR_RULES_DIR = Path(".cursor/rules")
TARGETS_FILES = [Path("CLAUDE.md")]


@dynamic_command(short_help="Validate AI rules are coherent between all the coding agent config files")
@click.option(
"--fix",
"-k",
"should_fix",
is_flag=True,
help="Fix the rules if they are not coherent.",
)
@pass_app
def cmd(app: Application, *, should_fix: bool) -> None:
cursor_rules_dir = CURSOR_RULES_DIR
targets_files = TARGETS_FILES

# Find all rule files
rule_files = get_rule_files(cursor_rules_dir)

if not rule_files:
app.display_warning(f"No rule files found in {cursor_rules_dir}")
app.display_info("Add your cursor rules files to the .cursor/rules directory and run this command again.")
return

app.display_debug(f"Found {len(rule_files)} rule files")

unsynced_targets = []
for target_file in targets_files:
new_content = generate_content(rule_files, target_file)
old_content = ""
if target_file.exists() and target_file.is_file():
with open(target_file, encoding="utf-8") as f:
old_content = f.read()
diff = pretty_diff(old_content, new_content)
if not diff:
continue
app.display_info(f"Target file {target_file} is not in sync")
app.display_info(diff)
if should_fix:
with open(target_file, "w", encoding="utf-8") as f:
f.write(new_content)
app.display_success(f"Successfully fixed {target_file}")
else:
unsynced_targets.append(str(target_file))
if unsynced_targets:
app.display_error(f"The following targets are not in sync: {', '.join(unsynced_targets)}")
app.abort()
app.display_success("All targets are in sync")


def get_rule_files(cursor_rules_dir: Path) -> list[Path]:
"""Find all rule files in cursor rules directory (recursively), excluding personal rules."""
return sorted(rule for rule in cursor_rules_dir.glob("**/*.mdc") if "personal" not in rule.parts)


def generate_content(rule_files: list[Path], target_file: Path) -> str:
"""Generate the content to be written to the target file."""
# Add header with warning and instructions
content_parts = [
"""<!--
WARNING: This file is auto-generated by 'dda validate ai-rules'
Do not edit this file manually. Instead, modify files in the .cursor/rules folder
and run 'dda validate ai-rules' to update this file.
-->

# AI Assistant Rules

This file contains concatenated rules from the `.cursor/rules` folder to help Claude understand the project context and coding standards.

## How to Read Metadata in Cursor Rules

Cursor rules contains the following metadata at the begnning of the file between `---` lines.
- alwaysApply: boolean, if true, the rule will be applied to all files
- globs: array of strings, glob patterns specifying which files to apply the rule to
- description: string, a description of the rule
---

"""
]
# Process each rule file
content_parts.extend([
f"@{rule_file.absolute().relative_to(target_file.parent.absolute(), walk_up=True)}" for rule_file in rule_files
])

# Add CLAUDE_PERSONAL.md reference
content_parts.append("""
# Personal rules
@CLAUDE_PERSONAL.md""")

combined_content = "\n".join(content_parts)

# Remove trailing separators
return combined_content.rstrip("\n-").rstrip() + "\n"
18 changes: 18 additions & 0 deletions src/dda/utils/diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import difflib


def pretty_diff(string1: str, string2: str) -> str:
lines1 = string1.splitlines()
lines2 = string2.splitlines()

diff = difflib.unified_diff(lines1, lines2, lineterm="")

result = []
for line in diff:
if line.startswith("-"):
result.append(f"\033[31m{line}\033[0m") # Red for removals
elif line.startswith("+"):
result.append(f"\033[32m{line}\033[0m") # Green for additions
else:
result.append(line)
return "\n".join(result)
3 changes: 3 additions & 0 deletions tests/cli/validate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is a test rule.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Old content
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use TypeScript for all frontend code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
alwaysApply: true
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
alwaysApply: true
---
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Always validate input parameters.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Write unit tests for all functions.
104 changes: 104 additions & 0 deletions tests/cli/validate/test_ai_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
from __future__ import annotations

import os
import shutil
from typing import TYPE_CHECKING

import pytest

from dda.utils.fs import Path

if TYPE_CHECKING:
from collections.abc import Callable

from tests.conftest import CliRunner


@pytest.fixture(name="use_temp_fixture_folder")
def fixt_use_temp_folder(temp_dir: Path) -> Callable[[str], Path]:
def _use_temp_folder(folder_name: str) -> Path:
shutil.copytree(Path(__file__).parent / "fixtures" / "ai_rules" / folder_name, temp_dir / folder_name)
return Path(temp_dir) / folder_name

return _use_temp_folder


def test_validate_rule_files_no_target_file(
dda: CliRunner,
use_temp_fixture_folder: Callable[[str], Path],
) -> None:
"""Test validation with multiple rule files."""

path = use_temp_fixture_folder("simple_rules_no_target")
with path.as_cwd():
result = dda("validate", "ai-rules")

result.check_exit_code(exit_code=1)


def test_validate_with_fix_flag(
dda: CliRunner,
use_temp_fixture_folder: Callable[[str], Path],
) -> None:
"""Test validation with fix flag when files are out of sync."""
path = use_temp_fixture_folder("simple_rules_no_target")
with path.as_cwd():
result = dda("validate", "ai-rules", "--fix")

result.check_exit_code(exit_code=0)
assert (path / "CLAUDE.md").exists()
content = (path / "CLAUDE.md").read_text(encoding="utf-8")
assert f"@{os.path.join('.cursor', 'rules', 'coding-standards.mdc')}" in content
assert f"@{os.path.join('.cursor', 'rules', 'security.mdc')}" in content
assert f"@{os.path.join('.cursor', 'rules', 'testing.mdc')}" in content
assert "imhere.txt" not in content
assert f"@{os.path.join('.cursor', 'rules', 'personal', 'my-rule.mdc')}" not in content
assert f"@{os.path.join('.cursor', 'rules', 'nested', 'my-nested-rule.mdc')}" in content
assert "@CLAUDE_PERSONAL.md" in content


def test_validate_no_cursor_rules_directory(
dda: CliRunner,
use_temp_fixture_folder: Callable[[str], Path],
) -> None:
"""Test validation when cursor rules directory doesn't exist."""
path = use_temp_fixture_folder("no_cursor_rules")
with path.as_cwd():
result = dda("validate", "ai-rules")

result.check_exit_code(exit_code=0)
# Should not create target file if no rules directory
target_file = path / "CLAUDE.md"
assert not target_file.exists()


def test_validate_in_sync(
dda: CliRunner,
use_temp_fixture_folder: Callable[[str], Path],
) -> None:
"""Test validation when files are already in sync."""
path = use_temp_fixture_folder("simple_rules_no_target")

with path.as_cwd():
# First fix the files
result = dda("validate", "ai-rules", "--fix")
result.check_exit_code(exit_code=0)

# Then validate without fix
result = dda("validate", "ai-rules")
result.check_exit_code(exit_code=0)


def test_validate_out_of_sync(
dda: CliRunner,
use_temp_fixture_folder: Callable[[str], Path],
) -> None:
"""Test validation when files are out of sync."""
path = use_temp_fixture_folder("out_of_sync")

with path.as_cwd():
result = dda("validate", "ai-rules")
result.check_exit_code(exit_code=1)