Skip to content
Merged
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
29 changes: 23 additions & 6 deletions .claude/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "php-refactor"
version = "0.1.1"
version = "0.2.0"
edition = "2024"

[[bin]]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
49 changes: 49 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
```

68 changes: 68 additions & 0 deletions src/rules/quality/add_readonly_keyword.rs
Original file line number Diff line number Diff line change
@@ -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<Regex> = 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<String> {
// 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: &regex::Captures| {
let indent = &caps[1];
format!("{}readonly class ", indent)
});

if result == source {
None
} else {
Some(result.into_owned())
}
}
1 change: 1 addition & 0 deletions src/rules/quality/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod add_final_keyword;
pub mod add_readonly_keyword;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

class Something {
}

-----
<?php

readonly class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Rules\AddReadonlyKeyword;

class Something {
}

-----
<?php

namespace Rules\AddReadonlyKeyword;

readonly class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

abstract class Something {
}
4 changes: 4 additions & 0 deletions tests/rules/quality/add_readonly_keyword/skip_enum.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

enum Status {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

final class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

interface Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

readonly class Something {
}
4 changes: 4 additions & 0 deletions tests/rules/quality/add_readonly_keyword/skip_trait.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

trait Something {
}