Skip to content

Commit c1398c1

Browse files
authored
doc: add project documentation & Claude Code configuration (#5)
1 parent 78b5003 commit c1398c1

12 files changed

Lines changed: 1137 additions & 1 deletion

File tree

‎.claude/agents/rust-reviewer.md‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
name: rust-reviewer
3+
description: Expert Rust code reviewer specializing in ownership, lifetimes, error handling, unsafe usage, and idiomatic patterns. Use for all Rust code changes. MUST BE USED for Rust projects.
4+
tools: ["Read", "Grep", "Glob", "Bash"]
5+
model: sonnet
6+
---
7+
8+
You are a senior Rust code reviewer ensuring high standards of safety, idiomatic patterns, and performance.
9+
10+
When invoked:
11+
1. Run `cargo check`, `cargo clippy -- -D warnings`, `cargo fmt --check`, and `cargo test` — if any fail, stop and report
12+
2. Run `git diff HEAD~1 -- '*.rs'` (or `git diff main...HEAD -- '*.rs'` for PR review) to see recent Rust file changes
13+
3. Focus on modified `.rs` files
14+
4. If the project has CI or merge requirements, note that review assumes a green CI and resolved merge conflicts where applicable; call out if the diff suggests otherwise.
15+
5. Begin review
16+
17+
## Review Priorities
18+
19+
### CRITICAL — Safety
20+
21+
- **Unchecked `unwrap()`/`expect()`**: In production code paths — use `?` or handle explicitly
22+
- **Unsafe without justification**: Missing `// SAFETY:` comment documenting invariants
23+
- **SQL injection**: String interpolation in queries — use parameterized queries
24+
- **Command injection**: Unvalidated input in `std::process::Command`
25+
- **Path traversal**: User-controlled paths without canonicalization and prefix check
26+
- **Hardcoded secrets**: API keys, passwords, tokens in source
27+
- **Insecure deserialization**: Deserializing untrusted data without size/depth limits
28+
- **Use-after-free via raw pointers**: Unsafe pointer manipulation without lifetime guarantees
29+
30+
### CRITICAL — Error Handling
31+
32+
- **Silenced errors**: Using `let _ = result;` on `#[must_use]` types
33+
- **Missing error context**: `return Err(e)` without `.context()` or `.map_err()`
34+
- **Panic for recoverable errors**: `panic!()`, `todo!()`, `unreachable!()` in production paths
35+
- **`Box<dyn Error>` in libraries**: Use `thiserror` for typed errors instead
36+
37+
### HIGH — Ownership and Lifetimes
38+
39+
- **Unnecessary cloning**: `.clone()` to satisfy borrow checker without understanding the root cause
40+
- **String instead of &str**: Taking `String` when `&str` or `impl AsRef<str>` suffices
41+
- **Vec instead of slice**: Taking `Vec<T>` when `&[T]` suffices
42+
- **Missing `Cow`**: Allocating when `Cow<'_, str>` would avoid it
43+
- **Lifetime over-annotation**: Explicit lifetimes where elision rules apply
44+
45+
### HIGH — Concurrency
46+
47+
- **Blocking in async**: `std::thread::sleep`, `std::fs` in async context — use tokio equivalents
48+
- **Unbounded channels**: `mpsc::channel()`/`tokio::sync::mpsc::unbounded_channel()` need justification — prefer bounded channels (`tokio::sync::mpsc::channel(n)` in async, `sync_channel(n)` in sync)
49+
- **`Mutex` poisoning ignored**: Not handling `PoisonError` from `.lock()`
50+
- **Missing `Send`/`Sync` bounds**: Types shared across threads without proper bounds
51+
- **Deadlock patterns**: Nested lock acquisition without consistent ordering
52+
53+
### HIGH — Code Quality
54+
55+
- **Large functions**: Over 50 lines
56+
- **Deep nesting**: More than 4 levels
57+
- **Wildcard match on business enums**: `_ =>` hiding new variants
58+
- **Non-exhaustive matching**: Catch-all where explicit handling is needed
59+
- **Dead code**: Unused functions, imports, or variables
60+
61+
### MEDIUM — Performance
62+
63+
- **Unnecessary allocation**: `to_string()` / `to_owned()` in hot paths
64+
- **Repeated allocation in loops**: String or Vec creation inside loops
65+
- **Missing `with_capacity`**: `Vec::new()` when size is known — use `Vec::with_capacity(n)`
66+
- **Excessive cloning in iterators**: `.cloned()` / `.clone()` when borrowing suffices
67+
- **N+1 queries**: Database queries in loops
68+
69+
### MEDIUM — Best Practices
70+
71+
- **Clippy warnings unaddressed**: Suppressed with `#[allow]` without justification
72+
- **Missing `#[must_use]`**: On non-`must_use` return types where ignoring values is likely a bug
73+
- **Derive order**: Should follow `Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize`
74+
- **Public API without docs**: `pub` items missing `///` documentation
75+
- **`format!` for simple concatenation**: Use `push_str`, `concat!`, or `+` for simple cases
76+
77+
## Diagnostic Commands
78+
79+
```bash
80+
cargo clippy -- -D warnings
81+
cargo fmt --check
82+
cargo test
83+
if command -v cargo-audit >/dev/null; then cargo audit; else echo "cargo-audit not installed"; fi
84+
if command -v cargo-deny >/dev/null; then cargo deny check; else echo "cargo-deny not installed"; fi
85+
cargo build --release 2>&1 | head -50
86+
```
87+
88+
## Approval Criteria
89+
90+
- **Approve**: No CRITICAL or HIGH issues
91+
- **Warning**: MEDIUM issues only
92+
- **Block**: CRITICAL or HIGH issues found
93+
94+
For detailed Rust code examples and anti-patterns, see `skill: rust-patterns`.

‎.claude/rules/architecture.md‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
paths:
3+
- "src/**"
4+
---
5+
6+
# Architecture
7+
8+
## Core Components
9+
10+
**CLI Entry Point** (`src/main.rs`)
11+
- Reads a single PHP file path from `argv[1]`
12+
- Loads file contents into memory
13+
- Iterates over all registered rules from `all_rules()`, chaining each rule's output into the next
14+
- If final content differs from original, writes the file back
15+
- Prints timing report per rule and peak memory usage
16+
17+
**Library Root** (`src/lib.rs`)
18+
- Trivial re-export of public modules (`reporter`, `rules`)
19+
20+
**Rule System** (`src/rules/mod.rs`)
21+
- Defines the rule contract: `type RuleFn = fn(&str) -> Option<String>`
22+
- Input: PHP source code as `&str`
23+
- Output: `None` if no changes needed, `Some(modified_source)` if changed
24+
- Rules are pure functions — no side effects, no state
25+
- `pub fn all_rules() -> Vec<(&'static str, RuleFn)>` — the rule registry
26+
- Single place to register new rules
27+
- Integration tests automatically discover and run all registered rules
28+
29+
**Observability** (`src/reporter.rs`)
30+
- Measures wall-clock time per rule (via `std::time::Instant`)
31+
- Reports peak memory usage via `libc::getrusage()` (macOS/Linux only; returns 0 elsewhere)
32+
- Formats and prints timing line to stdout: `[INFO] Refactoring took X.XXms (peak memory: X.XMB) | rule1: Xms, rule2: Xms, ...`
33+
34+
**PHP AST Parsing**
35+
- Uses `mago-syntax` (PHP ecosystem) to parse source into an AST
36+
- Allocates AST into `bumpalo` arena for zero-copy traversal
37+
- Rules walk the AST, collect byte offsets (from spans), then apply text edits back-to-front (to preserve offset validity)
38+
39+
---
40+
41+
## Current Rules
42+
43+
### `src/rules/quality/add_final_keyword.rs`
44+
45+
**What it does**: Adds `final` keyword to non-abstract, non-final PHP classes (prevents accidental subclassing)
46+
47+
**Behavior**:
48+
- Parses PHP source into AST
49+
- Walks all class declarations and classes within namespaces
50+
- For each class: if it has no `final` or `abstract` modifiers, inserts `final ` before the `class` keyword
51+
- If class is `readonly`, inserts before `readonly` to produce `final readonly class Foo {}`
52+
- Applies insertions back-to-front to avoid offset shifting
53+
- Returns `None` if no changes; returns `Some(modified_source)` if changed
54+
55+
**Skips**:
56+
- Abstract classes
57+
- Classes that already have `final`
58+
- Interfaces and traits (no `final` modifier)
59+
- Enums (PHP enums cannot be marked `final`)
60+
61+
---
62+
63+
## Quality Issues (High Priority)
64+
65+
See `/code-quality-issues` skill for the full H1–H3 blockers, M1–M7 medium-priority, and L1–L6 low-priority list.
66+
67+
Key high-priority items to address:
68+
- **H1**: `reporter.rs` unsafe block missing `// SAFETY:` comment
69+
- **H2**: `.unwrap()` in test fixtures lose error context
70+
- **H3**: Unnecessary `String` clone in `main.rs`

‎.claude/rules/php-ast-patterns.md‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
---
2+
paths:
3+
- "src/rules/**"
4+
---
5+
6+
# PHP AST Implementation Patterns
7+
8+
## Overview
9+
10+
Rules transform PHP code by:
11+
1. Parsing source into an AST using `mago-syntax`
12+
2. Allocating the AST into a `bumpalo` arena (zero-copy, single-free)
13+
3. Walking the AST to collect byte offsets (from spans)
14+
4. Applying text edits back-to-front (to preserve offset validity)
15+
5. Returning `Some(modified_source)` or `None`
16+
17+
---
18+
19+
## Parsing & Allocation
20+
21+
```rust
22+
use bumpalo::Bump;
23+
use mago_syntax::parser::Parser;
24+
25+
pub fn apply(source: &str) -> Option<String> {
26+
// Create arena for zero-copy AST allocation
27+
let arena = Bump::new();
28+
29+
// Parse into AST
30+
let mut parser = Parser::new(&arena, source.as_bytes());
31+
let result = parser.parse();
32+
33+
// Handle parse errors gracefully — return None, don't panic
34+
let ast = result.ok()?;
35+
36+
// ... rest of rule logic
37+
}
38+
```
39+
40+
---
41+
42+
## Walking the AST
43+
44+
`mago-syntax` provides AST node types like:
45+
- `Statement` (enum of statement variants)
46+
- `Sequence<Statement>` (collection of statements)
47+
- `Class` (class declaration with modifiers, name, members)
48+
- `Namespace` (namespace wrapper around statements)
49+
50+
Example: find all top-level and namespaced classes:
51+
52+
```rust
53+
for statement in &ast.statements {
54+
match statement {
55+
Statement::Class(class) => {
56+
// Process top-level class
57+
}
58+
Statement::Namespace(ns) => {
59+
for inner_stmt in &ns.statements {
60+
if let Statement::Class(class) = inner_stmt {
61+
// Process namespaced class
62+
}
63+
}
64+
}
65+
_ => {}
66+
}
67+
}
68+
```
69+
70+
---
71+
72+
## Collecting Byte Offsets
73+
74+
AST nodes have `.span` (a byte offset range). Collect all spans that need edits:
75+
76+
```rust
77+
let mut edits: Vec<(usize, &'static str)> = vec![];
78+
79+
// For each class that needs a `final` keyword
80+
for class in classes_needing_final {
81+
let position = class.span.start; // byte offset
82+
edits.push((position, "final "));
83+
}
84+
```
85+
86+
**Important**: Spans are byte offsets, not character offsets. UTF-8 matters.
87+
88+
---
89+
90+
## Applying Text Edits Back-to-Front
91+
92+
To avoid offset shifting, apply edits in reverse order (from end of file to start):
93+
94+
```rust
95+
// Sort edits by position (descending)
96+
edits.sort_by(|a, b| b.0.cmp(&a.0));
97+
98+
let mut result = source.to_string();
99+
100+
for (pos, text) in edits {
101+
result.insert_str(pos, text);
102+
}
103+
104+
if result == source {
105+
None // No changes
106+
} else {
107+
Some(result)
108+
}
109+
```
110+
111+
**Why back-to-front?** Inserting at position 10 shifts all positions after 10 forward. By working backward, earlier positions remain valid.
112+
113+
---
114+
115+
## Common Patterns
116+
117+
### Checking Modifiers
118+
119+
```rust
120+
if class.modifiers.contains_final() {
121+
// Already has `final`
122+
return None;
123+
}
124+
125+
if class.modifiers.contains_abstract() {
126+
// Skip abstract classes
127+
return None;
128+
}
129+
```
130+
131+
### Handling `readonly` Classes
132+
133+
If inserting before the `class` keyword, check for `readonly`:
134+
135+
```rust
136+
let insert_before = if class.modifiers.contains_readonly() {
137+
class.readonly_position
138+
} else {
139+
class.class_position
140+
};
141+
142+
edits.push((insert_before, "final "));
143+
```
144+
145+
### Safe Byte Offset Handling
146+
147+
Document UTF-8 assumptions. If casting `usize`, use `usize::try_from()`:
148+
149+
```rust
150+
let pos: usize = usize::try_from(span.start)
151+
.expect("span offset should fit in usize");
152+
```
153+
154+
---
155+
156+
## Error Handling
157+
158+
- **Never panic on malformed input.** The rule runs on user code.
159+
- **Return `None` if AST walk fails or is incomplete.** Better to skip than crash.
160+
- **Log suspicious assumptions with comments.** Example: "assumes mago-syntax always sets span.start < span.end"
161+
162+
---
163+
164+
## Testing Your Rule
165+
166+
See `/testing-patterns` for fixture format.
167+
168+
Key test cases:
169+
- One positive fixture (shows transformation)
170+
- One no-op fixture per skip condition (abstract, final, readonly, interface, trait, enum)
171+
- Edge case: mixing `readonly` with other modifiers
172+
- Idempotency: running rule twice should be idempotent

‎.claude/rules/rule-contract.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
paths:
3+
- "src/rules/**"
4+
---
5+
6+
# Rule Contract
7+
8+
## Function Signature
9+
10+
Every rule is a pure function:
11+
12+
```rust
13+
type RuleFn = fn(&str) -> Option<String>
14+
```
15+
16+
**Input**: PHP source code as `&str`
17+
**Output**:
18+
- `None` if no changes needed
19+
- `Some(modified_source)` if the rule transformed the code
20+
21+
## Constraints
22+
23+
### Pure Function
24+
- No global state
25+
- No side effects
26+
- Deterministic output — same input always produces same output
27+
- No panic on malformed input — either transform or return `None`
28+
29+
### Return Semantics
30+
- Return `None` = "no changes needed, skip to next rule"
31+
- Return `Some(String)` = "I changed the code, pass this to next rule"
32+
- Never return an unchanged copy of the input; return `None` instead
33+
34+
### Idempotency (Critical)
35+
**Applying a rule twice to the same file must produce the same output as applying it once.**
36+
37+
This is essential for rule chaining. If rule A produces output, then rule B runs on it, then rule A runs again, the result must be identical to the first time rule A ran.
38+
39+
Test this explicitly: if you write a fixture file, verify that running your rule twice produces the same result.
40+
41+
### Registration
42+
43+
Rules are registered in `src/rules/mod.rs` via the `all_rules()` function:
44+
45+
```rust
46+
pub fn all_rules() -> Vec<(&'static str, RuleFn)> {
47+
vec![
48+
("category/rule_name", category::rule_name::apply),
49+
]
50+
}
51+
```
52+
53+
The key (first element) is used in timing reports and test discovery. Use forward slashes and snake_case.

0 commit comments

Comments
 (0)