diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 7f86925..83f7a8e 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -45,21 +45,38 @@ paths: **What it does**: Adds `final` keyword to non-abstract, non-final PHP classes (prevents accidental subclassing) **Behavior**: -- Parses PHP source into AST -- Walks all class declarations and classes within namespaces -- For each class: if it has no `final` or `abstract` modifiers, inserts `final ` before the `class` keyword -- If class is `readonly`, inserts before `readonly` to produce `final readonly class Foo {}` -- Applies insertions back-to-front to avoid offset shifting +- Uses a single line-anchored regex to find bare `class` and `readonly class` declarations +- Captures leading whitespace (indentation) and optional `readonly ` modifier, then prepends `final ` +- Produces `final readonly class Foo {}` when the class was `readonly` - Returns `None` if no changes; returns `Some(modified_source)` if changed **Skips**: -- Abstract classes +- Abstract classes (regex doesn't anchor on `abstract`) - Classes that already have `final` - Interfaces and traits (no `final` modifier) - Enums (PHP enums cannot be marked `final`) --- +### `src/rules/quality/add_readonly_keyword.rs` + +**What it does**: Adds `readonly` keyword to bare concrete PHP classes (forces all instance properties to be readonly; PHP 8.2+) + +**Behavior**: +- Uses a single line-anchored regex (`(?m)^(\s*)class\s`) to find bare `class` declarations +- Captures leading whitespace (indentation), then prepends `readonly ` +- Scope is **bare classes only** — does not touch `final class` or `abstract class` + +**Skips**: +- Classes already marked `readonly` (regex doesn't match `readonly class`) +- `final class` and `abstract class` (out of scope — regex doesn't anchor on modifiers) +- Interfaces, traits, enums (regex requires literal `class` token) +- `::class`, `$class`, and expression-level class references + +**Known limitations**: Syntactic rule only; does not inspect class bodies. Applying to classes with static/untyped properties, default values, dynamic properties, or non-readonly parents produces PHP fatal errors at runtime. Scope via `config.toml` path globs. + +--- + ## Quality Issues (High Priority) See `/code-quality-issues` skill for the full H1–H3 blockers, M1–M7 medium-priority, and L1–L6 low-priority list. diff --git a/CLAUDE.md b/CLAUDE.md index f597b21..1da2663 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,8 @@ src/ ├── mod.rs # Rule registry (all_rules, all_source_transforms) └── quality/ # Quality rules by category ├── mod.rs - └── add_final_keyword.rs # Example rule (adds final to classes) + ├── add_final_keyword.rs # Adds `final` to concrete classes + └── add_readonly_keyword.rs # Adds `readonly` to bare classes (PHP 8.2+) tests/ ├── *_test.rs # Individual unit tests diff --git a/Cargo.lock b/Cargo.lock index 823d9e6..a59722c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "php-refactor" -version = "0.1.1" +version = "0.2.0" dependencies = [ "glob", "globset", diff --git a/Cargo.toml b/Cargo.toml index fb346c2..7e6aa8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "php-refactor" -version = "0.1.1" +version = "0.2.0" edition = "2024" [[bin]] diff --git a/README.md b/README.md index 4103b2d..37a4624 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ The tool prints a summary: | Rule | What It Does | |------|-------------| | `quality/add_final_keyword` | Adds `final` keyword to concrete classes | +| `quality/add_readonly_keyword` | Adds `readonly` keyword to bare concrete classes | See [docs/rules.md](docs/rules.md) for detailed rule documentation. diff --git a/docs/rules.md b/docs/rules.md index 97bd998..4b99f44 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -38,3 +38,52 @@ namespace App; final class Model {} ``` +--- + +## `quality/add_readonly_keyword` + +**Summary**: Adds `readonly` keyword to bare concrete classes so all instance properties are implicitly readonly (PHP 8.2+). + +**When to use**: Enforce immutability on value objects, DTOs, and other classes whose state shouldn't change after construction. + +**What it does:** +- Adds `readonly` keyword before any bare `class` declaration +- Supports classes inside namespaces +- Preserves original indentation + +**What it skips:** +- Classes already marked `readonly` +- `final class` and `abstract class` declarations (out of scope — bare classes only) +- Interfaces, traits, and enums (cannot be `readonly` in PHP) +- Class references in expressions (`::class`, `$class`, etc.) + +**Example:** + +```php +// Before +class UserDto {} +namespace App; +class Config {} + +// After +readonly class UserDto {} +namespace App; +readonly class Config {} +``` + +**Known limitations (syntactic rule — no class body analysis):** + +PHP will raise a fatal error at class-load time if a `readonly` class contains any of: +- Static properties +- Untyped properties +- Properties with default values +- `#[AllowDynamicProperties]` attribute +- A non-readonly parent class + +Scope the rule via `config.toml` path globs to classes you know are readonly-compatible: + +```toml +[quality] +add_readonly_keyword.paths = ["src/Dto/**/*.php", "src/ValueObject/**/*.php"] +``` + diff --git a/src/rules/quality/add_readonly_keyword.rs b/src/rules/quality/add_readonly_keyword.rs new file mode 100644 index 0000000..bed477e --- /dev/null +++ b/src/rules/quality/add_readonly_keyword.rs @@ -0,0 +1,68 @@ +use rayon::prelude::*; +use regex::Regex; +use std::fs; +use std::path::PathBuf; +use std::sync::LazyLock; +use std::sync::atomic::{AtomicUsize, Ordering}; + +// NOTE: This rule is purely syntactic. It does not analyze class bodies. +// Adding `readonly` to a class with static properties, untyped properties, +// properties with default values, a non-readonly parent, or `#[AllowDynamicProperties]` +// will produce a PHP fatal error at runtime. Scope application via `config.toml` +// path globs to classes you know are readonly-compatible. + +static RE: LazyLock = LazyLock::new(|| { + // Matches a bare "class" keyword at the start of a line (after optional whitespace). + // Captures: (1) leading whitespace. + // Naturally excludes: readonly/final/abstract class, interface, trait, enum, ::class, $class, etc. + Regex::new(r"(?m)^(\s*)class\s").unwrap() +}); + +/// File-aware entry point: applies the rule to the given set of files in parallel. +pub fn apply(files: &[PathBuf]) -> crate::rules::RuleResult { + let files_matched = AtomicUsize::new(0); + let files_changed = AtomicUsize::new(0); + + files.par_iter().for_each(|file_path| { + let Ok(original) = fs::read_to_string(file_path) else { + return; + }; + + if let Some(modified) = apply_to_source(&original) { + files_matched.fetch_add(1, Ordering::Relaxed); + if fs::write(file_path, &modified).is_ok() { + files_changed.fetch_add(1, Ordering::Relaxed); + } + } + }); + + crate::rules::RuleResult { + files_changed: files_changed.load(Ordering::Relaxed), + files_matched: files_matched.load(Ordering::Relaxed), + files_analyzed: files.len(), + } +} + +/// Pure source transformation: used by tests. +pub fn apply_to_source(source: &str) -> Option { + // Early exit: skip expensive regex if source has no class declaration + if !source.contains("class ") { + return None; + } + + // Check if pattern exists before attempting replacement + if !RE.is_match(source) { + return None; + } + + let result = RE.replace_all(source, |caps: ®ex::Captures| { + let indent = &caps[1]; + format!("{}readonly class ", indent) + }); + + if result == source { + None + } else { + Some(result.into_owned()) + } +} diff --git a/src/rules/quality/mod.rs b/src/rules/quality/mod.rs index 662542f..64dbe1b 100644 --- a/src/rules/quality/mod.rs +++ b/src/rules/quality/mod.rs @@ -1 +1,2 @@ pub mod add_final_keyword; +pub mod add_readonly_keyword; diff --git a/tests/rules/quality/add_readonly_keyword/add_readonly_keyword.php.inc b/tests/rules/quality/add_readonly_keyword/add_readonly_keyword.php.inc new file mode 100644 index 0000000..5d753eb --- /dev/null +++ b/tests/rules/quality/add_readonly_keyword/add_readonly_keyword.php.inc @@ -0,0 +1,10 @@ +