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
119 changes: 119 additions & 0 deletions examples/hooks/rules_frontmatter_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Claude Code Hook: Rules Frontmatter Validator
===============================================
This hook validates that rules files (.md) in .claude/rules/ use the correct
CSV string syntax for the `paths:` frontmatter field, not YAML array syntax.

Background: The `paths:` field is parsed by an internal CSV parser that expects
a comma-separated string (e.g., `paths: "**/*.ts,**/*.tsx"`). When YAML array
syntax is used (e.g., `paths:\n - "**/*.ts"`), the YAML parser returns a
JavaScript array which the CSV parser iterates element-by-element instead of
character-by-character, silently producing invalid globs that match nothing.

Related issues:
- https://github.com/anthropics/claude-code/issues/19377
- https://github.com/anthropics/claude-code/issues/13905

This hook runs as a PostToolUse hook for Write and Edit tools. When a rules
file is written or edited, it checks the frontmatter for broken `paths:` syntax
and warns the user.

Hook configuration (add to settings.json):

{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 /path/to/rules_frontmatter_validator.py"
}
]
}
]
}
}

"""

import json
import re
import sys
from pathlib import Path


def extract_frontmatter(content: str) -> str | None:
"""Extract YAML frontmatter from a markdown file."""
match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract_frontmatter only matches LF line endings (\n). On Windows/CRLF rules files, this won’t detect frontmatter and the validator will silently do nothing. Consider using \r?\n in the regex (and optionally allowing ---\s*\r?\n for both delimiters).

Suggested change
match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
match = re.match(r"^---\s*\r?\n(.*?)\r?\n---", content, re.DOTALL)

Copilot uses AI. Check for mistakes.
return match.group(1) if match else None


def check_paths_syntax(frontmatter: str) -> list[str]:
"""Check for broken paths:/globs: syntax in frontmatter."""
issues = []

for field in ("paths", "globs"):
# Pattern 1: YAML list syntax (field: followed by newline then " - ")
if re.search(rf"^{field}:\s*$", frontmatter, re.MULTILINE) and re.search(
r"^\s+-\s+", frontmatter, re.MULTILINE
):
Comment on lines +59 to +62
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The YAML-list detection can false-positive because it checks for paths: (or globs:) being present and then any list item anywhere in the frontmatter, even if the list belongs to a different key. Tighten the regex to ensure the - items are actually under the same field (e.g., match the block starting at ^{field}: and subsequent indented - lines).

Suggested change
# Pattern 1: YAML list syntax (field: followed by newline then " - ")
if re.search(rf"^{field}:\s*$", frontmatter, re.MULTILINE) and re.search(
r"^\s+-\s+", frontmatter, re.MULTILINE
):
# Pattern 1: YAML list syntax (field: followed by newline then indented "- " lines)
yaml_list_pattern = rf"^{field}:\s*\n(?:[ \t]+-\s+[^\n]*\n?)+"
if re.search(yaml_list_pattern, frontmatter, re.MULTILINE):

Copilot uses AI. Check for mistakes.
issues.append(
f"`{field}:` uses YAML list syntax which silently fails. "
f'Use CSV string instead: {field}: "glob1,glob2"'
)

# Pattern 2: JSON inline array syntax (field: ["...", "..."])
if re.search(rf"^{field}:\s*\[", frontmatter, re.MULTILINE):
issues.append(
f"`{field}:` uses JSON array syntax which silently fails. "
f'Use CSV string instead: {field}: "glob1,glob2"'
)

return issues


def main() -> None:
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
return

tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")

# Only check .md files in rules directories
if not file_path.endswith(".md"):
return

rules_indicators = ["/rules/", "/.claude/rules/"]
if not any(indicator in file_path for indicator in rules_indicators):
Comment on lines +91 to +92
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues that will prevent this from working reliably on Windows paths: (1) rules_indicators only checks for forward-slash substrings, so C:\\...\\.claude\\rules\\x.md won’t match; (2) the docstring says it targets .claude/rules/ but the code also matches any /rules/ directory. Consider normalizing file_path to POSIX separators before substring checks and narrowing (or documenting) the intended directories.

Suggested change
rules_indicators = ["/rules/", "/.claude/rules/"]
if not any(indicator in file_path for indicator in rules_indicators):
# Normalize to POSIX-style separators for reliable substring checks
normalized_path = Path(file_path).as_posix()
# Only process rules files in .claude/rules/ as documented
rules_indicators = ["/.claude/rules/", ".claude/rules/"]
if not any(indicator in normalized_path for indicator in rules_indicators):

Copilot uses AI. Check for mistakes.
return

path = Path(file_path)
if not path.exists():
return

try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return

frontmatter = extract_frontmatter(content)
if not frontmatter:
return

issues = check_paths_syntax(frontmatter)
if issues:
print(
f"WARNING: Rules file {path.name} has frontmatter issues:\n"
+ "\n".join(f" - {issue}" for issue in issues),
file=sys.stderr,
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prints the main warning to stderr but exits with code 0. Per the hooks docs, PostToolUse only reliably shows stdout on exit 0; stderr is typically only surfaced on exit 2. To ensure the warning is visible, print to stdout (or emit the standard JSON output with systemMessage) and keep exit 0.

Suggested change
file=sys.stderr,

Copilot uses AI. Check for mistakes.
)
print("See: https://github.com/anthropics/claude-code/issues/19377")


if __name__ == "__main__":
main()
85 changes: 85 additions & 0 deletions examples/rules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Rules Frontmatter Examples

Example Claude Code rules files demonstrating **correct** and **incorrect** frontmatter syntax for the `paths:` field.

> [!WARNING]
> The `paths:` field in rules frontmatter uses a **CSV string parser** internally. YAML array syntax and JSON inline array syntax will cause rules to silently fail to match any files — the rule content loads but never applies.

## Correct Syntax

### Single glob (unquoted)

```yaml
---
paths: "**/*.ts"
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The heading says “Single glob (unquoted)”, but the example value is quoted ("**/*.ts"). Either update the heading to say quoted, or change the example to be truly unquoted to avoid confusing readers about what’s required/allowed.

Suggested change
paths: "**/*.ts"
paths: **/*.ts

Copilot uses AI. Check for mistakes.
---
```

### Multiple globs (CSV string)

```yaml
---
paths: "**/*.ts,**/*.tsx,**/*.js,**/*.jsx"
---
```

### Using `globs:` (alternative, also CSV)

```yaml
---
globs: "**/*.py,**/*.pyi"
---
```

## Incorrect Syntax (Silent Failures)

### YAML list syntax — BROKEN

```yaml
---
# DO NOT USE — rules will never match any files
paths:
- "**/*.ts"
- "**/*.tsx"
---
```

**Why it breaks:** The internal CSV parser (`_9A()`) expects a string and iterates character by character. When YAML parse returns a JavaScript array, the parser iterates the array _elements_ instead of characters, concatenating all globs without any separator (e.g., `**/*.ts**/*.tsx`), producing an invalid glob that matches nothing.

### JSON inline array — BROKEN

```yaml
---
# DO NOT USE — rules will never match any files
paths: ["**/*.ts", "**/*.tsx"]
---
```

**Same root cause** as above — the YAML parser produces a JavaScript array.

### Quoted single value with paths: — MAY BREAK

```yaml
---
# In some versions, quoted paths: may also fail
# Use globs: if you need quoted values
paths: "**/*.cs"
---
```

See [#17204](https://github.com/anthropics/claude-code/issues/17204) — `paths:` with quoted values may undergo additional processing that strips or misinterprets the value. Use `globs:` as a more reliable alternative.
Comment on lines +60 to +70
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section labels quoted paths: as “MAY BREAK”, but earlier in “Correct Syntax” the recommended examples for paths: are also quoted. Please clarify the guidance (e.g., recommend unquoted paths: vs globs: consistently, and specify which versions/contexts the quoted form fails in).

Copilot uses AI. Check for mistakes.

## Related Issues

- [#19377](https://github.com/anthropics/claude-code/issues/19377) — `paths:` YAML array syntax silently fails (root cause analysis in comments)
- [#13905](https://github.com/anthropics/claude-code/issues/13905) — Rules with `paths:` frontmatter not triggering
- [#21858](https://github.com/anthropics/claude-code/issues/21858) — Rules do not apply to `.claude/rules/` properly
- [#17204](https://github.com/anthropics/claude-code/issues/17204) — `paths:` with quoted globs broken

## Validation Hook

See [`examples/hooks/rules_frontmatter_validator.py`](../hooks/rules_frontmatter_validator.py) — a PreToolUse hook that detects rules files using broken frontmatter syntax.
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README describes the validator as a “PreToolUse hook”, but the validator script and its sample config are PostToolUse. Please align the README with the intended hook event so users configure it correctly.

Suggested change
See [`examples/hooks/rules_frontmatter_validator.py`](../hooks/rules_frontmatter_validator.py) — a PreToolUse hook that detects rules files using broken frontmatter syntax.
See [`examples/hooks/rules_frontmatter_validator.py`](../hooks/rules_frontmatter_validator.py) — a PostToolUse hook that detects rules files using broken frontmatter syntax.

Copilot uses AI. Check for mistakes.

## Full Documentation

See https://code.claude.com/docs/en/settings for complete documentation on rules and settings.
10 changes: 10 additions & 0 deletions examples/rules/python-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
description: Python coding conventions
globs: "**/*.py,**/*.pyi"
---

# Python Style

- Follow PEP 8 conventions
- Use type annotations on all function signatures
- Use `dataclass(frozen=True)` for immutable data structures
10 changes: 10 additions & 0 deletions examples/rules/typescript-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
description: TypeScript coding conventions
paths: "**/*.ts,**/*.tsx"
---

# TypeScript Style

- Use `const` by default, `let` only when reassignment is needed
- Prefer `interface` over `type` for object shapes
- Use strict TypeScript configuration
Loading