Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

13
.bladeformatterrc.json Executable file
View File

@@ -0,0 +1,13 @@
{
"indentSize": 2,
"wrapLineLength": 120,
"wrapAttributes": "auto",
"sortTailwindcssClasses": true,
"sortHtmlAttributes": "alphabetical",
"noMultipleEmptyLines": true,
"endWithNewline": true,
"indentInnerHtml": true,
"useTabs": false,
"noPhpSyntaxCheck": false,
"singleQuote": false
}

View File

@@ -0,0 +1 @@
# Custom Dictionary Words

24
.editorconfig Executable file
View File

@@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml,json,js.map,css.map}]
indent_size = 2
[*.{blade.php,html}]
indent_size = 4
[*.{scss,css,js}]
indent_size = 4
[docker-compose.yml]
indent_size = 4

54
.env.dist Executable file
View File

@@ -0,0 +1,54 @@
APP_NAME="RSpade"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=info
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=rspade
DB_USERNAME=rspade
DB_PASSWORD=rspadepass
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=525600
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Debug Settings
# SHOW_CONSOLE_DEBUG_CLI: Enable console_debug() output in CLI mode
SHOW_CONSOLE_DEBUG_CLI=false
# SHOW_CONSOLE_DEBUG_HTTP: Enable console_debug() output in HTTP mode (browser console)
SHOW_CONSOLE_DEBUG_HTTP=false
# FORCE_REBUILD_EVERY_REQUEST: Clear build cache on each request (development only)
# Gatekeeper Development Preview Authentication
GATEKEEPER_ENABLED=true
GATEKEEPER_PASSWORD=preview123
GATEKEEPER_TITLE="Development Preview"
GATEKEEPER_SUBTITLE="This is a restricted development preview site. Please enter the access password to continue."
SSR_FPC_ENABLED=true

11
.gitattributes vendored Executable file
View File

@@ -0,0 +1,11 @@
* text=auto
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

59
.gitignore vendored Executable file
View File

@@ -0,0 +1,59 @@
# /node_modules # We commit node_modules to ensure consistent dependencies
# /vendor # We commit vendor to ensure consistent dependencies
/public/hot
/public/storage
/public/css
/public/js
/public/build
/public/webfonts
# .env # Keeping in git for now
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/.idea
# /.vscode # Keeping in git for shared settings
/.fleet
/.vapor
.DS_Store
._*
Thumbs.db
# Entire storage directory - will be created on bootstrap
/storage/
# Supervisor files
supervisord.log*
supervisord.pid*
*.log
*.pid
# Local settings
**/claude/settings.local.json
**/.claude/settings.local.json
# RSX Framework
.rsx-manifest-cache
.migrating
_rsx_helper.php
rsx/_rsx_helper.php
_ide_helper.php
# Build artifacts
/build/cache/
/build/temp/
/build/
/ace_reference/
.php-cs-fixer.cache
runonsave_debug.log
test-*
__pycache__
# Environment configuration (use .env.dist as template)
.env
docs.dev/*/node_modules/
docs.dev/ace_reference/vendor/
.rspade_last_commit_for_publish

60
.gitignore.project Executable file
View File

@@ -0,0 +1,60 @@
# Common files to ignore in all git repositories
# OS Generated Files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# Editor/IDE Files
*~
*.swp
*.swo
*.swn
.idea/
.vscode/settings.json
.vscode/launch.json
*.sublime-project
*.sublime-workspace
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Temporary Files
*.tmp
*.temp
*-temp.*
*-tmp.*
temp-*
tmp-*
# Build Artifacts (project-specific, not dependency builds)
/build/cache/
/build/temp/
# Local Environment Files
.env.local
.env.*.local
# Test Coverage
coverage/
.nyc_output/
# Misc
.rsx-manifest-cache
.php-cs-fixer.cache
runonsave_debug.log

0
.gitmodules vendored Executable file
View File

3
.npmrc Executable file
View File

@@ -0,0 +1,3 @@
# Private NPM Registry Configuration
@jqhtml:registry=https://privatenpm.hanson.xyz/
//privatenpm.hanson.xyz/:_auth=cnNwYWRlOjg1ZmY2MTllZDQyNTM3ZTY1NzQ0NmQ2ZWFkOWU1OGI5

147
.php-cs-fixer.php Executable file
View File

@@ -0,0 +1,147 @@
<?php
/**
* PHP CS Fixer Configuration for RSpade Framework
*
* This configuration enforces:
* - Modern PHP array syntax [] instead of array()
* - PSR-12 coding standards
* - Laravel/RSpade specific conventions
* - underscore_case for methods/variables (where possible)
*/
$finder = PhpCsFixer\Finder::create()
->in([
__DIR__ . '/app',
__DIR__ . '/rsx',
__DIR__ . '/config',
__DIR__ . '/database',
__DIR__ . '/routes',
__DIR__ . '/tests',
])
->name('*.php')
->notName('*.blade.php')
->notPath('vendor')
->notPath('storage')
->notPath('bootstrap/cache')
->ignoreDotFiles(true)
->ignoreVCS(true);
$config = new PhpCsFixer\Config();
return $config
->setRules([
// PSR-12 standard as baseline
'@PSR12' => true,
// Enforce modern array syntax
'array_syntax' => ['syntax' => 'short'],
// Ensure consistent array formatting
'array_indentation' => true,
'trailing_comma_in_multiline' => [
'elements' => ['arrays'],
],
'no_whitespace_before_comma_in_array' => true,
'whitespace_after_comma_in_array' => true,
// Clean up imports
'ordered_imports' => [
'sort_algorithm' => 'alpha',
'imports_order' => ['const', 'class', 'function'],
],
'single_import_per_statement' => true,
'fully_qualified_strict_types' => false,
'global_namespace_import' => [
'import_classes' => true,
'import_constants' => false,
'import_functions' => false,
],
// Spacing and formatting
'blank_line_after_opening_tag' => false,
'blank_line_before_statement' => [
'statements' => ['return', 'throw', 'try'],
],
'no_extra_blank_lines' => [
'tokens' => [
'case',
'continue',
'curly_brace_block',
'default',
'extra',
'parenthesis_brace_block',
'square_brace_block',
'switch',
'throw',
'use',
],
],
'no_blank_lines_after_phpdoc' => true,
// PHP language features
'declare_strict_types' => false, // Don't force strict types
'void_return' => false, // Don't force void returns
'no_superfluous_elseif' => true,
'no_useless_else' => true,
'no_useless_return' => true,
'return_type_declaration' => [
'space_before' => 'none',
],
// String handling
'single_quote' => true, // Prefer single quotes
'escape_implicit_backslashes' => [
'double_quoted' => true,
'heredoc_syntax' => true,
'single_quoted' => false,
],
// Operators
'binary_operator_spaces' => [
'default' => 'single_space',
],
'concat_space' => ['spacing' => 'one'],
'increment_style' => ['style' => 'post'],
'not_operator_with_successor_space' => false,
'object_operator_without_whitespace' => true,
'ternary_operator_spaces' => true,
'unary_operator_spaces' => true,
// Class and method formatting
'class_attributes_separation' => [
'elements' => [
'const' => 'one',
'method' => 'one',
'property' => 'one',
],
],
'visibility_required' => [
'elements' => ['property', 'method', 'const'],
],
// Comments and PHPDoc
'multiline_comment_opening_closing' => true,
'no_empty_comment' => true,
'no_trailing_whitespace_in_comment' => true,
'single_line_comment_style' => [
'comment_types' => ['hash'],
],
// Control structures
'elseif' => true,
'no_alternative_syntax' => true,
'switch_case_semicolon_to_colon' => true,
'switch_case_space' => true,
// Keep existing code organization
'no_blank_lines_after_class_opening' => false,
'blank_line_after_namespace' => true,
// Don't change function/variable names (preserve underscore_case)
// PHP CS Fixer doesn't have rules to enforce snake_case, which is good
// as we want to preserve our existing naming conventions
])
->setRiskyAllowed(false) // Don't allow risky rules that might break code
->setFinder($finder)
->setUsingCache(true)
->setCacheFile(__DIR__ . '/.php-cs-fixer.cache');

2262
.phpstorm.meta.php Executable file

File diff suppressed because it is too large Load Diff

21
.prettierrc.js Executable file
View File

@@ -0,0 +1,21 @@
module.exports = {
plugins: ['@prettier/plugin-php'],
printWidth: 140,
tabWidth: 4,
useTabs: false,
singleQuote: true,
trailingComma: 'es5',
bracketSpacing: true,
phpVersion: '8.0',
overrides: [
{
files: '*.php',
options: {
parser: 'php',
singleQuote: true,
printWidth: 140,
tabWidth: 4,
},
},
],
};

93
FPC_IMPLEMENTATION_PLAN.md Executable file
View File

@@ -0,0 +1,93 @@
# SSR Full Page Cache (FPC) Implementation Plan
## Overview
Server-side rendered static page caching system using Playwright + Redis. Routes marked with `#[Static_Page]` auto-generate and serve pre-rendered HTML to unauthenticated users.
## Implementation Order
### Phase 1: Foundation & Research
1. Study `rsx:debug` command and Playwright script structure
2. Understand current Dispatch.php flow and route attribute processing
### Phase 2: Core Commands
3. Create `rsx:ssr_fpc:create` command (copy from `rsx:debug`)
4. Create Playwright script `generate-static-cache.js` in command's resource directory
5. Implement exclusive lock `GENERATE_STATIC_CACHE` in Playwright script
6. Strip GET parameters from URL before rendering
7. Handle redirect interception (manual redirect mode)
8. Capture response: DOM + headers OR redirect location
9. Generate cache structure with 30-char ETag (SHA1 of build_key + URL + content)
### Phase 3: Storage Layer
10. Implement Redis cache with key format: `ssr_fpc:{build_key}:{sha1(url)}`
11. Store JSON: `{url, code, page_dom/redirect, build_key, etag, generated_at}`
12. Add comprehensive error logging to `storage/logs/ssr-fpc-errors.log`
### Phase 4: Runtime Integration
13. Update `Session::__is_fpc_client()` with header check: `X-RSpade-FPC-Client: 1`
14. Add header to Playwright script
15. Create `#[Static_Page]` attribute stub in `.vscode/attribute-stubs.php`
16. Modify Dispatch.php to:
- Check for `#[Static_Page]` attribute
- Verify `!Session::is_active()` (unauthenticated only)
- Skip FPC if `Session::__is_fpc_client()` is true
- Check Redis cache, generate if missing (fatal on failure)
- Validate ETag for 304 responses
- Serve with proper cache headers (0s dev, 5min prod)
### Phase 5: Management & Config
17. Create `rsx:ssr_fpc:reset` command (flush Redis `ssr_fpc:*` keys)
18. Add config to `config/rsx.php`:
```php
'ssr_fpc' => [
'enabled' => env('SSR_FPC_ENABLED', false),
'generation_timeout' => 30000, // ms
]
```
### Phase 6: Documentation
19. Create `app/RSpade/man/ssr_fpc.txt` with:
- Purpose and usage
- Attribute syntax
- Cache lifecycle
- Bypass headers
- Future roadmap (sitemap, parallelization, external service, shared secret)
- Security considerations
### Phase 7: Testing
20. Test simple static page generation and serving
21. Test redirect caching and serving
22. Test ETag validation (304 responses)
23. Test authenticated user bypass
24. Test cache invalidation on build_key change
## Key Technical Decisions
### Redis Cache Key
Format: `ssr_fpc:{build_key}:{sha1(request_path)}`
- Auto-invalidates on deployment (build_key changes)
- URL hash prevents key collisions
- GET params stripped before hashing
### ETag
First 30 chars of SHA1(build_key + url + content)
### FPC Client Header
`X-RSpade-FPC-Client: 1`
### Session Check
`Session::is_active()` (not `RsxAuth::check()`)
### Redirect Handling
Cache first 302, don't follow
### Cache TTL
Indefinite (Redis LRU handles eviction)
## Future Roadmap (Not in Initial Implementation)
- Option for `rsx:ssr_fpc:create --from-sitemap` rather than a specific url
- Shared, private, automatic key for the fpc runner, so the fpc headers are only recognized by the server itself
- The same should be done for `rsx:debug`
- External service to generate the fpc renderings
- Parallelization
- Programmatic cache reset for things like updating cms or blog posts

View File

@@ -0,0 +1,341 @@
# Research Report: jqhtml Component Loading State Pattern Issue
**Date:** 2025-10-10
**Author:** AI Assistant (Claude)
**Subject:** Critical documentation gap in jqhtml component lifecycle - loading state anti-patterns
---
## Executive Summary
During the development of a DataGrid component for a real-world RSpade application, a critical pattern mistake was identified in how jqhtml components handle loading states. The issue stems from incomplete documentation of jqhtml's automatic re-rendering behavior and lifecycle constraints, leading developers to implement incorrect loading patterns that violate the framework's design principles.
**Impact:** Medium-High - This anti-pattern can be implemented without immediate errors but leads to unpredictable component behavior, violates the "fail loud" philosophy, and creates maintenance issues.
**Recommendation:** Update jqhtml lifecycle documentation to explicitly document the correct loading state pattern and common anti-patterns with clear examples.
---
## Problem Statement
### What Happened
An AI assistant (myself) was tasked with implementing a DataGrid component that loads data asynchronously from a server endpoint. Following common patterns from other frameworks (React, Vue, Angular), the implementation used:
1. **Manual `this.render()` calls** in `on_load()` to trigger re-rendering
2. **Complex nested state objects** (`this.data.state.loading`) to track loading status
3. **Loading flags set at the START** of `on_load()` before data fetching
After framework update, the jqhtml runtime emitted a warning about setting properties other than `this.data` in `on_load()`. The implementation was corrected to use `this.data.state` instead of `this.state`, but this still violated the intended pattern.
The user (framework creator) observed the implementation and provided corrective guidance, demonstrating the **correct pattern**:
- **NO manual `this.render()` calls** - Framework handles re-rendering automatically
- **Simple flat `this.data.loaded` flag** - Set to `true` at END of `on_load()`
- **Template-level loading checks** - Use `<% if (!this.data.loaded) %>` in jqhtml template
- **Trust automatic re-rendering** - Framework detects `this.data` changes and re-renders
### Incorrect Implementation (What I Did)
**JavaScript:**
```javascript
class Contacts_DataGrid extends Jqhtml_Component {
async on_load() {
// ❌ WRONG: Setting loading state at START
this.data.state = {loading: true};
this.render(); // ❌ WRONG: Manual render call
try {
const response = await $.ajax({...}); // ❌ WRONG: $.ajax() instead of controller stub
if (response.success) {
this.data = {
records: response.records,
total: response.total,
state: {loading: false} // ❌ WRONG: Complex nested state
};
}
} catch (error) {
this.data.state = {loading: false, error: true};
}
}
}
```
**Template:**
```jqhtml
<% if (this.data.state && this.data.state.loading) { %>
<!-- ❌ WRONG: Complex state check -->
<div>Loading...</div>
<% } else if (this.data.records && this.data.records.length > 0) { %>
<!-- Data display -->
<% } %>
```
### Correct Implementation (User's Correction)
**JavaScript:**
```javascript
class Contacts_DataGrid extends Jqhtml_Component {
async on_load() {
// ✅ CORRECT: NO loading flags at start
// ✅ CORRECT: NO manual this.render() calls
// ✅ CORRECT: Use Ajax endpoint pattern
const response = await Frontend_Contacts_Controller.datagrid_fetch({
page: 1,
per_page: 25,
sort: 'id',
order: 'asc'
});
// ✅ CORRECT: Populate this.data directly
this.data.records = response.records;
this.data.total = response.total;
this.data.page = response.page;
this.data.per_page = response.per_page;
this.data.total_pages = response.total_pages;
// ✅ CORRECT: Simple flag at END
this.data.loaded = true;
// ✅ Automatic re-render happens because this.data changed
}
}
```
**Template:**
```jqhtml
<% if (!this.data || !this.data.loaded) { %>
<!-- ✅ FIRST RENDER: Loading state (this.data is empty {}) -->
<div class="loading-spinner text-center py-5">
<div class="spinner-border text-primary mb-3"></div>
<p class="text-muted">Loading contacts...</p>
</div>
<% } else if (this.data.records && this.data.records.length > 0) { %>
<!-- ✅ SECOND RENDER: Data loaded -->
<table class="table">
<% for (let record of this.data.records) { %>
<tr><!-- row content --></tr>
<% } %>
</table>
<% } else { %>
<!-- ✅ Empty state -->
<div class="text-center py-5">
<p class="text-muted">No records found</p>
</div>
<% } %>
```
---
## Root Cause Analysis
### Why This Mistake Happened
1. **Documentation Gap**: Existing jqhtml documentation focused on:
- What `on_load()` should do (load async data)
- What `on_load()` CANNOT do (DOM manipulation)
- Restriction to only modify `this.data`
But it **did not explicitly document**:
- The automatic re-rendering behavior when `this.data` changes
- The correct loading state pattern (`this.data.loaded` flag at END)
- Common anti-patterns (manual `this.render()`, nested state objects)
2. **Framework Paradigm Difference**: jqhtml's approach differs from mainstream frameworks:
- **React/Vue**: Explicit state management, manual render triggers
- **jqhtml**: Automatic re-rendering on `this.data` change, trust the framework
Developers with React/Vue experience naturally implement familiar patterns that violate jqhtml's design.
3. **Silent Failure Mode**: The incorrect implementation **appeared to work**:
- No runtime errors
- Visual output seemed correct
- Only violated best practices, not hard constraints
This violates RSpade's "fail loud" philosophy - the framework should have caught this earlier.
### Why It Matters
1. **Unpredictable Behavior**: Manual `this.render()` calls can interfere with the framework's automatic re-rendering, causing double-renders or race conditions.
2. **Maintenance Burden**: Complex nested state objects (`this.data.state.loading`) add cognitive overhead and make code harder to understand.
3. **Framework Violation**: Setting loading state at the START of `on_load()` means the first render triggers before data loading begins, defeating the purpose of the double-render pattern.
4. **Philosophy Violation**: The pattern violates RSpade's "trust the framework" principle - manually calling `this.render()` indicates lack of trust in automatic re-rendering.
---
## How the Pattern SHOULD Work
### The Double-Render Pattern
jqhtml components that load data in `on_load()` automatically render **twice**:
**Render 1 (Initial)**:
- Template executes
- `this.data = {}` (empty object)
- DOM created with loading state (`<% if (!this.data.loaded) %>` is true)
- User sees loading spinner
**on_load() Executes**:
- Fetches data from server
- Populates `this.data.records`, `this.data.total`, etc.
- Sets `this.data.loaded = true` at END
- Framework detects `this.data` changed
**Render 2 (Automatic)**:
- Framework automatically re-renders template
- `this.data.loaded === true` now
- Template shows data (`<% } else if (this.data.records.length > 0) %>`)
- User sees actual data
**on_ready() Fires**:
- Called ONCE after second render
- All children components ready
- Safe to attach event handlers and DOM manipulation
### Key Principles
1. **Trust Automatic Re-Rendering**
- Framework watches `this.data` for changes
- Modifying `this.data` triggers automatic re-render
- NEVER manually call `this.render()` in `on_load()`
2. **Simple Flat State**
- Use `this.data.loaded = true` (flat property)
- NOT `this.data.state.loading = false` (nested object)
- Simpler to check in templates: `!this.data.loaded`
3. **Template-Level Checks**
- Loading state logic belongs in jqhtml template
- NOT in JavaScript: `if (!loaded) { this.show_spinner(); }`
- Declarative template: `<% if (!this.data.loaded) %>`
4. **Flag at END Only**
- Set `this.data.loaded = true` as LAST line of `on_load()`
- NOT at start: `this.data.loading = true` before fetch
- Framework handles the "loading" state via empty `this.data`
---
## Recommendations
### 1. Update jqhtml Lifecycle Documentation
**Add new section**: "Loading State Pattern" (CRITICAL level)
Include:
- Complete correct pattern example (JS + template)
- Complete incorrect pattern example (what NOT to do)
- Explanation of why the pattern works (double-render)
- Common anti-patterns with warnings
- Comparison to React/Vue to help developers understand the difference
**Location**: After the lifecycle execution flow section, before the double-render pattern section.
### 2. Add to "Common Pitfalls" Section
Create new subsection: "Loading Pattern Anti-Patterns"
Include:
- ❌ NEVER call `this.render()` manually in `on_load()`
- ❌ NEVER use nested state objects (`this.data.state.loading`)
- ❌ NEVER set loading flags at START of `on_load()`
- ✅ DO set `this.data.loaded = true` at END
- ✅ DO check `!this.data.loaded` in template for loading state
### 3. Framework Warning Enhancement
**Current**: Runtime warning when setting properties other than `this.data` in `on_load()`
**Suggested**: Additional runtime warnings for:
- Calling `this.render()` within `on_load()` → "WARNING: Manual render() call detected in on_load(). Remove it - framework handles re-rendering automatically."
- Setting `this.data.loaded = true` at line 1 of `on_load()` → "WARNING: Loading flag set before data fetch. Move to END of on_load()."
These would align with RSpade's "fail loud" philosophy.
### 4. Example Components
Add reference implementations to framework:
- **Simple_List_Component** - Loads array of items, shows loading state
- **Data_Grid_Component** - Loads paginated data, handles empty state
- **Form_Component** - Loads initial data, handles submission
These serve as copy-paste templates for common use cases.
### 5. AI Assistant Training
Update CLAUDE.md (RSpade AI development guide) to include:
- Complete loading pattern documentation
- Anti-pattern warnings in "Common Pitfalls"
- Links to example components
**Status**: ✅ COMPLETED - This research report documents the changes made.
---
## Impact Assessment
### Severity: Medium-High
- **Likelihood**: High - Any developer with React/Vue background will likely implement this anti-pattern
- **Detection Difficulty**: Medium - Code appears to work, only causes issues during maintenance or edge cases
- **Fix Difficulty**: Low - Pattern is simple once understood
- **Business Impact**: Medium - Causes unpredictable behavior, violates framework principles, creates maintenance burden
### Affected Audience
- **Primary**: AI assistants developing with RSpade/jqhtml (high likelihood of implementing anti-pattern)
- **Secondary**: Human developers new to jqhtml (moderate likelihood)
- **Tertiary**: Experienced jqhtml developers (low likelihood, but benefits from explicit documentation)
### Documentation Priority
**CRITICAL** - This should be added to jqhtml documentation immediately because:
1. High likelihood of implementation by new developers
2. Violates core framework principles ("trust the framework")
3. Creates silent failures (appears to work but causes issues)
4. Simple to fix once pattern is understood
5. Already causing issues in real-world usage
---
## Conclusion
The loading state pattern issue represents a critical documentation gap in jqhtml's lifecycle documentation. While the framework correctly restricts what can be done in `on_load()` (only modify `this.data`, no DOM manipulation), it does not explicitly document the **correct pattern** for implementing loading states.
Developers with experience in other frameworks (React, Vue, Angular) will naturally implement familiar patterns that violate jqhtml's automatic re-rendering design. The framework should document this pattern explicitly with both correct and incorrect examples to prevent these anti-patterns.
The recommended fixes are straightforward:
1. Add comprehensive "Loading State Pattern" section to jqhtml docs
2. Add "Loading Pattern Anti-Patterns" to common pitfalls
3. Consider runtime warnings for common mistakes
4. Provide reference implementations
These changes will significantly improve the developer experience and reduce instances of this anti-pattern in production code.
---
## Appendix: Conversation Timeline
1. **Initial Implementation**: AI assistant implements DataGrid with manual `this.render()` calls and nested state
2. **Framework Update**: jqhtml runtime warns about setting `this.state` instead of `this.data.state`
3. **First Correction**: AI corrects to use `this.data.state` but still uses nested state and manual render
4. **User Intervention**: User demonstrates correct pattern with simple `this.data.loaded` flag
5. **Comprehension Check**: AI explains pattern back to user to confirm understanding
6. **Documentation Review**: User requests review of CLAUDE.md to identify documentation gaps
7. **Documentation Update**: AI updates CLAUDE.md with comprehensive loading pattern documentation
8. **This Report**: Documentation of the issue for jqhtml maintainers
---
**Next Steps:**
1. ✅ Update CLAUDE.md (RSpade AI development guide) - COMPLETED
2. ⏳ Review this report with jqhtml maintainers
3. ⏳ Update official jqhtml lifecycle documentation
4. ⏳ Consider runtime warning enhancements
5. ⏳ Add reference component examples to framework

162
app/Console/Commands/CLAUDE.md Executable file
View File

@@ -0,0 +1,162 @@
# Console Commands Directory Documentation
This directory contains Artisan console commands used for maintenance, utilities, and administrative functions within the RSpade application.
## CLAUDE.md File Standards
Each CLAUDE.md file in the project follows these standards:
1. **Purpose**: The file begins with a brief synopsis of the directory's purpose and role in the application.
2. **File Index**: Contains a list of important files with their sizes and last modified dates.
- When visiting a directory, always run `ls -la` to get current file sizes
- If a file's current size differs from what's documented in CLAUDE.md, the file has changed
- Before trusting information about a changed file, first read the current version and update the CLAUDE.md documentation
3. **File Documentation**: For each important file, includes:
- Short description of purpose and functionality
- Key points of information necessary for understanding the file
- Important implementation details or architectural considerations
4. **Selective Coverage**: Only documents important files that contribute to understanding the codebase
- Images, temporary files, and generated content are typically not documented
- Configuration files, models, controllers, and other architectural components are always documented
5. **Additional Context**: May include other critical information about the directory's role in the system
Always check file sizes against the current state before relying on the documentation in CLAUDE.md files.
## Important Files
### Database Safety Guardrails
- **RestrictedDatabaseCommand.php** (2838 bytes) - Base class for restricted commands
- Provides standardized messaging for disabled database operations
- Enforces forward-only migration policy
- Offers guidance on alternative approaches
- Ensures safety for automated systems and AI agents
- **Database/WipeCommand.php** (946 bytes) - Override for db:wipe
- Prevents complete database destruction
- Protects against accidental data loss
- **Migrate/FreshCommand.php** (1528 bytes) - Override for migrate:fresh
- Prevents dropping and recreating all tables
- Maintains data integrity
- **Migrate/ResetCommand.php** (1109 bytes) - Override for migrate:reset
- Prevents rollback of all migrations
- Ensures forward-only migration strategy
- **Migrate/RefreshCommand.php** (1316 bytes) - Override for migrate:refresh
- Prevents reset and re-run of migrations
- Protects existing data
- **Migrate/RollbackCommand.php** (1343 bytes) - Override for migrate:rollback
- Prevents rollback of migrations
- Enforces schema evolution through new migrations only
### Schema Maintenance Commands
- **Maint_Migrate.php** (3482 bytes) - Enhanced migration command
- Extends Laravel's migrate command with additional maintenance steps
- Runs required table column checks
- Regenerates model constants
- Exports constants to JavaScript
- Usage: `php artisan maint:migrate`
- **Migrate/MigrateNormalizeSchema.php** - Normalizes database schema to framework standards
- Standardizes data types (INT→BIGINT, TEXT→LONGTEXT, FLOAT→DOUBLE)
- Converts character encoding to UTF8MB4 for full Unicode/emoji support
- Adds required columns (created_at, updated_at, created_by, updated_by)
- Adds indexes on timestamp columns
- Updates datetime precision to milliseconds
- Usage: `php artisan migrate:normalize_schema`
- **Note**: Automatically called during migrations - manual execution rarely needed
### Code Generation Commands
- **Migrate/MigrateRegenerateConstants.php** - Model constant generation
- Scans model files for static $enums properties
- Generates class constants for enum values
- Updates PHPDoc comments with property and method tags
- Adds $dates property with datetime columns
- Validates enum definitions for consistency
- Updates model files in-place with constants and docblocks
- Usage: `php artisan migrate:regenerate_constants`
- **Note**: Automatically called during migrations - manual execution rarely needed
### Content Management Commands
- **CreateSampleKnowledgeBase.php** (14626 bytes) - Sample content generation
- Creates sample knowledge base articles for testing
- Generates categories and content hierarchy
- Adds formatted content with Markdown
- Creates content relationships and metadata
- Usage: `php artisan create:sample-kb`
- **FeatureKnowledgeBaseArticles.php** (1594 bytes) - Content curation
- Selects and features knowledge base articles
- Updates featured status for homepage display
- Manages featured article ordering
- Usage: `php artisan feature:kb-articles`
### Utility Commands
- **GenerateSitemap.php** (4206 bytes) - SEO sitemap generation
- Creates XML sitemap for search engines
- Includes all public routes and content pages
- Sets priority and change frequency
- Optimizes for search engine indexing
- Usage: `php artisan generate:sitemap`
- **GetEnvironment.php** (579 bytes) - Environment diagnostic
- Displays current application environment
- Shows configuration values for debugging
- Helps with environment-specific troubleshooting
- Usage: `php artisan env:get`
## Command Categories
The commands are organized into several functional categories:
1. **Maintenance Commands** (prefix: `maint:`)
- Database and schema maintenance
- Model code generation
- System consistency checks
2. **Utility Commands** (prefix: `utility:`)
- Helper functions for development
- Code generation utilities
- Export functionality
3. **Content Commands** (no standard prefix)
- Sample content generation
- Content management and curation
- Seeding and fixture creation
4. **Diagnostic Commands** (usually prefixed with the subsystem name)
- System status and health checks
- Configuration validation
- Performance diagnostics
## Important Concepts
1. **Enum System Integration**: Several commands work with the model enum system, generating constants and JavaScript exports.
2. **Database Schema Management**: Commands ensure consistent schema structure and features across tables.
3. **Code Generation**: Commands generate code and documentation to reduce manual work and ensure consistency.
4. **Content Management**: Commands facilitate content seeding and management for testing and initial setup.
## Usage in Development Workflow
These commands are typically used in the following scenarios:
1. After database migrations to ensure schema consistency
2. During development to keep model constants in sync with enum definitions
3. For generating test data and sample content
4. As part of deployment processes to prepare the application
The commands can be run manually or as part of automated scripts, and many are integrated with Laravel's migration system to run automatically when migrations are executed.

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Base class for framework developer commands
*
* Commands extending this class will be hidden from the artisan command list
* unless IS_FRAMEWORK_DEVELOPER=true is set in the .env file.
*
* These commands can still be called directly, but are hidden to avoid
* confusion for end users who shouldn't need to use them.
*/
abstract class FrameworkDeveloperCommand extends Command
{
/**
* Hide command from list unless framework developer mode is enabled
*
* @var bool
*/
protected $hidden = false;
/**
* Create a new command instance
*/
public function __construct()
{
parent::__construct();
// Hide command unless IS_FRAMEWORK_DEVELOPER flag is set
$this->hidden = !env('IS_FRAMEWORK_DEVELOPER', false);
}
/**
* Get the console command description with framework developer indicator
*
* @return string
*/
public function getDescription(): string
{
$description = parent::getDescription();
// Add prefix if in framework developer mode
if (env('IS_FRAMEWORK_DEVELOPER', false)) {
return "[Framework Dev] {$description}";
}
return $description;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\Console\Commands;
use Illuminate\Console\Command;
class GetEnvironment extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'env';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get the current application environment';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->line(app()->environment());
return 0;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
/**
* Override Laravel's key:generate to prevent accidental key regeneration
*
* This command replaces the default key:generate behavior with a safer version
* that requires explicit --force flag when a key already exists.
*/
class KeyGenerateOverride extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'key:generate
{--show : Display the key instead of modifying files}
{--force : Force the operation even if key exists}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Set the application key (requires --force if key exists)';
/**
* Execute the console command.
*/
public function handle()
{
$current_key = config('app.key');
// Check if key already exists and --force not provided
if ($current_key && !$this->option('force')) {
$this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->error(' APPLICATION KEY ALREADY EXISTS');
$this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->newLine();
$this->line('Current key: <fg=yellow>' . substr($current_key, 0, 20) . '...</>');
$this->newLine();
$this->warn('⚠️ Regenerating the application key will cause:');
$this->warn(' • All existing encrypted data becomes unreadable');
$this->warn(' • All user sessions invalidated (everyone logged out)');
$this->warn(' • All password reset tokens invalidated');
$this->warn(' • All remember-me cookies invalidated');
$this->newLine();
$this->error('If you really need to regenerate (THIS IS DESTRUCTIVE):');
$this->line(' <fg=red>php artisan key:generate --force</>');
$this->newLine();
$this->info('For first-time setup (when key is empty):');
$this->line(' <fg=green>php artisan key:generate</>');
$this->newLine();
$this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
return 1;
}
// If --force provided with existing key, show additional warning
if ($current_key && $this->option('force')) {
$this->warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->warn(' DESTRUCTIVE OPERATION - REGENERATING APPLICATION KEY');
$this->warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->newLine();
$this->error('This will DESTROY all encrypted data and invalidate all sessions!');
$this->newLine();
if (!$this->confirm('Are you absolutely sure you want to continue?', false)) {
$this->info('Operation cancelled.');
return 0;
}
$this->newLine();
}
// Generate new key
$key = $this->generate_random_key();
if ($this->option('show')) {
$this->line('<comment>' . $key . '</comment>');
return 0;
}
// Set key in .env file
if (!$this->set_key_in_environment_file($key)) {
$this->error('Failed to write key to .env file');
return 1;
}
$this->laravel['config']['app.key'] = $key;
$this->info('Application key set successfully.');
return 0;
}
/**
* Generate a random key for the application.
*/
protected function generate_random_key(): string
{
return 'base64:' . base64_encode(
Encrypter::generateKey($this->laravel['config']['app.cipher'])
);
}
/**
* Set the application key in the environment file.
*/
protected function set_key_in_environment_file(string $key): bool
{
$env_file = $this->laravel->environmentFilePath();
$current_key = $this->laravel['config']['app.key'];
if (strlen($current_key) !== 0) {
// Replace existing key
$replaced = preg_replace(
$this->key_replacement_pattern(),
'APP_KEY=' . $key,
$input = file_get_contents($env_file)
);
if ($replaced === $input || $replaced === null) {
$this->error('Unable to set application key. Manual setting required.');
return false;
}
} else {
// Add new key
$replaced = file_get_contents($env_file) . PHP_EOL . 'APP_KEY=' . $key . PHP_EOL;
}
file_put_contents($env_file, $replaced);
return true;
}
/**
* Get a regex pattern that will match APP_KEY with any random key.
*/
protected function key_replacement_pattern(): string
{
$escaped = preg_quote('=' . $this->laravel['config']['app.key'], '/');
return "/^APP_KEY{$escaped}/m";
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
class KeyGenerateSafe extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'key:generate:safe
{--show : Display the key instead of modifying files}
{--force : Force the operation to run when in production}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate application key only if one does not exist (prevents accidental regeneration)';
/**
* Execute the console command.
*/
public function handle()
{
$current_key = config('app.key');
// Check if key already exists
if ($current_key) {
$this->error('Application key already exists!');
$this->newLine();
$this->line('Current key: ' . $current_key);
$this->newLine();
$this->warn('Regenerating the application key will:');
$this->warn(' - Invalidate all existing sessions');
$this->warn(' - Break all encrypted data');
$this->warn(' - Require all users to log in again');
$this->newLine();
$this->error('If you really need to regenerate, use: php artisan key:generate --force');
$this->newLine();
return 1;
}
// Generate new key
$key = $this->generate_random_key();
if ($this->option('show')) {
$this->line('<comment>' . $key . '</comment>');
return 0;
}
// Check if .env file exists
$env_file = $this->laravel->environmentFilePath();
if (!file_exists($env_file)) {
$this->error('.env file not found at: ' . $env_file);
return 1;
}
// Set key in .env file
if (!$this->set_key_in_environment_file($key)) {
$this->error('Failed to write key to .env file');
return 1;
}
$this->laravel['config']['app.key'] = $key;
$this->info('Application key set successfully.');
return 0;
}
/**
* Generate a random key for the application.
*/
protected function generate_random_key(): string
{
return 'base64:' . base64_encode(
Encrypter::generateKey($this->laravel['config']['app.cipher'])
);
}
/**
* Set the application key in the environment file.
*/
protected function set_key_in_environment_file(string $key): bool
{
$env_file = $this->laravel->environmentFilePath();
$current_key = $this->laravel['config']['app.key'];
if (strlen($current_key) !== 0) {
// Replace existing key
$replaced = preg_replace(
$this->key_replacement_pattern(),
'APP_KEY=' . $key,
$input = file_get_contents($env_file)
);
if ($replaced === $input || $replaced === null) {
$this->error('Unable to set application key. Manual setting required.');
return false;
}
} else {
// Add new key
$replaced = file_get_contents($env_file) . PHP_EOL . 'APP_KEY=' . $key . PHP_EOL;
}
file_put_contents($env_file, $replaced);
return true;
}
/**
* Get a regex pattern that will match APP_KEY with any random key.
*/
protected function key_replacement_pattern(): string
{
$escaped = preg_quote('=' . $this->laravel['config']['app.key'], '/');
return "/^APP_KEY{$escaped}/m";
}
}

View File

@@ -0,0 +1,105 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Log;
/**
* Session Cleanup Command
* ========================
*
* PURPOSE:
* This command performs routine cleanup of expired and abandoned sessions from
* the database to prevent unbounded growth of the sessions table.
*
* CLEANUP RULES:
* 1. Guest sessions (user_id = null, site_id = null) older than 24 hours
* 2. Any sessions older than 3 years (regardless of user/site association)
*
* IMPLEMENTATION:
* - Uses raw DB::statement() for efficiency
* - Runs hourly via Laravel scheduler
* - Tracks deletion counts for monitoring
*
* WHY RAW SQL:
* - More efficient for bulk deletions
* - Clear audit trail in logs
* - Avoids ORM overhead for maintenance tasks
*
* INDEXING:
* The sessions table has indexes on:
* - user_id
* - site_id
* - updated_at
* - (updated_at, user_id) composite
* These indexes ensure efficient query execution.
*/
class SessionCleanupCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'session:cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean up expired and abandoned sessions from the database';
/**
* Execute the console command.
*/
public function handle()
{
$start_time = microtime(true);
// Delete guest sessions older than 24 hours
// Using MySQL date functions for accuracy and performance
$guest_count = DB::delete('
DELETE FROM sessions
WHERE updated_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND user_id IS NULL
AND site_id IS NULL
');
// Delete any sessions older than 3 years
$old_count = DB::delete('
DELETE FROM sessions
WHERE updated_at < DATE_SUB(NOW(), INTERVAL 1 YEAR)
');
$total_deleted = $guest_count + $old_count;
$execution_time = round(microtime(true) - $start_time, 3);
// Log results
if ($total_deleted > 0) {
$this->info('Session cleanup completed:');
$this->info(" • Guest sessions (>24h): {$guest_count} deleted");
$this->info(" • Old sessions (>3y): {$old_count} deleted");
$this->info(" • Total: {$total_deleted} sessions removed");
$this->info(" • Execution time: {$execution_time}s");
// Also log to Laravel log for monitoring
Log::info("Session cleanup: {$total_deleted} sessions deleted", [
'guest_sessions' => $guest_count,
'old_sessions' => $old_count,
'execution_time' => $execution_time,
]);
} else {
$this->info("No sessions to clean up (execution time: {$execution_time}s)");
}
return 0;
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\Console\Commands\Temp;
use Illuminate\Console\Command;
use App\RSpade\Core\Build_Manager;
class ClearCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'temp:clear
{--older-than=0 : Clear files older than N hours}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear temporary files';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$older_than = (int) $this->option('older-than');
if ($older_than > 0) {
$count = Build_Manager::clear_temp($older_than);
$this->info("Cleared $count temporary files older than $older_than hours.");
} else {
$count = Build_Manager::clear_temp();
$this->info("Cleared all $count temporary files.");
}
return 0;
}
}

75
app/Console/Kernel.php Executable file
View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
// Run task scheduler every minute
$schedule->command('task:scheduler')
->everyMinute()
->runInBackground()
->withoutOverlapping();
// Generate sitemap every 3 hours
$schedule->command('sitemap:generate --queue')
->cron('0 */3 * * *')
->runInBackground()
->withoutOverlapping();
// Run task cleanup daily
$schedule->command('task:scheduler --cleanup')
->daily()
->at('02:00')
->runInBackground()
->withoutOverlapping();
// Run session cleanup every hour
$schedule->command('session:cleanup')
->hourly()
->runInBackground()
->withoutOverlapping();
// Process geocoding tasks hourly
$schedule->command('geocoding:process --limit=20')
->hourly()
->runInBackground()
->withoutOverlapping();
// Process email queue every minute
$schedule->command('email:process --batch=10')
->everyMinute()
->runInBackground()
->withoutOverlapping();
// Process document conversions every 5 minutes
$schedule->command('documents:process --limit=10')
->everyFiveMinutes()
->runInBackground()
->withoutOverlapping();
}
/**
* Register the commands for the application.
*
* @return void
*/
protected function commands()
{
$this->load(__DIR__.'/Commands'); // Application commands
$this->load(__DIR__.'/../RSpade/Commands'); // Framework commands
require base_path('routes/console.php');
}
}

118
app/Constants.php Executable file
View File

@@ -0,0 +1,118 @@
<?php
namespace App;
/**
* Application-wide constants
*
* This file contains static resources and constants that are used throughout the application.
*
* Purpose:
* - Centralizes commonly used resources like state lists, MIME types, etc.
* - Provides a single source of truth for these constants
* - Makes maintenance easier - edit once, use everywhere
* - Improves consistency across the application
*
* Usage:
* - Include this file where constants are needed using: use App\Constants;
* - Access constants via the class constants (e.g., Constants::STATES)
*
* Guidelines:
* - Keep this file as a catchall for non-application-specific constants
* - Group related constants in meaningful arrays/classes
* - Add clear comments for each constant group
* - Use UPPERCASE for constant names
* - For long lists, maintain alphabetical order where appropriate
*
* Examples of appropriate constants:
* - US States and territories
* - Country codes
* - MIME types
* - Common date/time formats
* - Standard measurement units
* - Common regular expressions (email, phone, etc.)
*/
class Constants
{
// User ID for CLI operations
const CLI_USER = 'cli';
// System user email
const SYSTEM_USER_EMAIL = 'system@rspade.local';
// System user access level
const SYSTEM_USER_ACCESS_LEVEL = 90;
/**
* US States and territories with abbreviations
* Array of state abbreviations to full state names, in alphabetical order
*/
public const STATES = [
'AL' => 'Alabama',
'AK' => 'Alaska',
'AZ' => 'Arizona',
'AR' => 'Arkansas',
'CA' => 'California',
'CO' => 'Colorado',
'CT' => 'Connecticut',
'DE' => 'Delaware',
'DC' => 'District of Columbia',
'FL' => 'Florida',
'GA' => 'Georgia',
'HI' => 'Hawaii',
'ID' => 'Idaho',
'IL' => 'Illinois',
'IN' => 'Indiana',
'IA' => 'Iowa',
'KS' => 'Kansas',
'KY' => 'Kentucky',
'LA' => 'Louisiana',
'ME' => 'Maine',
'MD' => 'Maryland',
'MA' => 'Massachusetts',
'MI' => 'Michigan',
'MN' => 'Minnesota',
'MS' => 'Mississippi',
'MO' => 'Missouri',
'MT' => 'Montana',
'NE' => 'Nebraska',
'NV' => 'Nevada',
'NH' => 'New Hampshire',
'NJ' => 'New Jersey',
'NM' => 'New Mexico',
'NY' => 'New York',
'NC' => 'North Carolina',
'ND' => 'North Dakota',
'OH' => 'Ohio',
'OK' => 'Oklahoma',
'OR' => 'Oregon',
'PA' => 'Pennsylvania',
'RI' => 'Rhode Island',
'SC' => 'South Carolina',
'SD' => 'South Dakota',
'TN' => 'Tennessee',
'TX' => 'Texas',
'UT' => 'Utah',
'VT' => 'Vermont',
'VA' => 'Virginia',
'WA' => 'Washington',
'WV' => 'West Virginia',
'WI' => 'Wisconsin',
'WY' => 'Wyoming'
];
/**
* Common date formats
*/
public const DATE_FORMATS = [
'SHORT' => 'm/d/Y',
'MEDIUM' => 'M j, Y',
'LONG' => 'F j, Y',
'TIME' => 'g:i A',
'DATETIME' => 'M j, Y g:i A'
];
// Additional constants can be added here as needed:
// const MIME_TYPES = [...];
// const COUNTRY_CODES = [...];
// etc.
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\Database;
use Illuminate\Database\Eloquent\Builder;
/**
* Custom Eloquent query builder that prevents eager loading and unsafe operations
*
* This builder overrides dangerous methods to enforce RSpade framework safety rules:
* - All eager loading methods throw exceptions (with/withCount/etc)
* - DELETE without WHERE clause throws exception to prevent accidental mass deletion
*/
class RestrictedEloquentBuilder extends Builder
{
/**
* Prevent eager loading via with()
*
* @param mixed $relations
* @return $this
* @throws \RuntimeException
*/
public function with($relations, $callback = null)
{
// Allow empty with() calls (Laravel uses these internally)
if (empty($relations)) {
return parent::with($relations, $callback);
}
// Also allow if relations is an empty array
if (is_array($relations) && count($relations) === 0) {
return parent::with($relations, $callback);
}
// Throw exception for actual eager loading attempts
throw new \RuntimeException(
'Eager loading via with() is not allowed in the RSpade framework. ' .
'Use explicit queries for each relationship instead. ' .
'Attempted to eager load: ' . $this->format_relations($relations)
);
}
/**
* Prevent eager loading via withCount()
*
* @param mixed $relations
* @return $this
* @throws \RuntimeException
*/
public function withCount($relations)
{
// Allow empty withCount() calls
if (empty($relations)) {
return parent::withCount($relations);
}
throw new \RuntimeException(
'Eager loading counts via withCount() is not allowed in the RSpade framework. ' .
'Use explicit count queries instead. ' .
'Attempted to eager load counts for: ' . $this->format_relations($relations)
);
}
/**
* Prevent eager loading via withMax()
*
* @param array|string $relation
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function withMax($relation, $column)
{
// Allow empty withMax() calls
if (empty($relation)) {
return parent::withMax($relation, $column);
}
throw new \RuntimeException(
'Eager loading max via withMax() is not allowed in the RSpade framework. ' .
'Use explicit max queries instead.'
);
}
/**
* Prevent eager loading via withMin()
*
* @param array|string $relation
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function withMin($relation, $column)
{
// Allow empty withMin() calls
if (empty($relation)) {
return parent::withMin($relation, $column);
}
throw new \RuntimeException(
'Eager loading min via withMin() is not allowed in the RSpade framework. ' .
'Use explicit min queries instead.'
);
}
/**
* Prevent eager loading via withSum()
*
* @param array|string $relation
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function withSum($relation, $column)
{
// Allow empty withSum() calls
if (empty($relation)) {
return parent::withSum($relation, $column);
}
throw new \RuntimeException(
'Eager loading sum via withSum() is not allowed in the RSpade framework. ' .
'Use explicit sum queries instead.'
);
}
/**
* Prevent eager loading via withAvg()
*
* @param array|string $relation
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function withAvg($relation, $column)
{
// Allow empty withAvg() calls
if (empty($relation)) {
return parent::withAvg($relation, $column);
}
throw new \RuntimeException(
'Eager loading avg via withAvg() is not allowed in the RSpade framework. ' .
'Use explicit avg queries instead.'
);
}
/**
* Prevent eager loading via withExists()
*
* @param array|string $relation
* @return $this
* @throws \RuntimeException
*/
public function withExists($relation)
{
// Allow empty withExists() calls
if (empty($relation)) {
return parent::withExists($relation);
}
throw new \RuntimeException(
'Eager loading exists via withExists() is not allowed in the RSpade framework. ' .
'Use explicit exists queries instead.'
);
}
/**
* Prevent eager loading via withAggregate()
*
* @param mixed $relations
* @param string $column
* @param string $function
* @return $this
* @throws \RuntimeException
*/
public function withAggregate($relations, $column, $function = null)
{
// Allow empty withAggregate() calls
if (empty($relations)) {
return parent::withAggregate($relations, $column, $function);
}
throw new \RuntimeException(
'Eager loading aggregates via withAggregate() is not allowed in the RSpade framework. ' .
'Use explicit aggregate queries instead.'
);
}
/**
* Prevent lazy eager loading via has()
* Note: has() is different - it's for filtering, not loading
* But we'll still prevent it if it tries to eager load
*
* @param string $relation
* @param string $operator
* @param int $count
* @param string $boolean
* @param \Closure|null $callback
* @return $this
*/
public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?\Closure $callback = null)
{
// has() is allowed as it's for filtering, not eager loading
// But log it for monitoring
if (app()->environment() !== 'testing') {
logger()->debug('has() query used on relation', [
'model' => get_class($this->model),
'relation' => $relation,
'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5)
]);
}
return parent::has($relation, $operator, $count, $boolean, $callback);
}
/**
* Override delete to prevent deleting all records without WHERE clause
*
* @param mixed $id
* @return int
* @throws \RuntimeException
*/
public function delete($id = null)
{
// If $id is provided, allow it (deleting by primary key)
if ($id !== null) {
return parent::delete($id);
}
// Check if there are any WHERE clauses on the underlying query
$baseQuery = $this->getQuery();
if (empty($baseQuery->wheres)) {
// @PHP-DB-01-EXCEPTION - DB::table() mentioned in error message, not used
shouldnt_happen(
'Attempted to delete all records from ' . $this->getModel()->getTable() . ' without WHERE clause. ' .
'This operation is forbidden to prevent accidental data loss. ' .
'If you truly need to delete all records, use DB::table()->truncate() or add a WHERE clause.'
);
}
return parent::delete();
}
/**
* Format relations for error messages
*
* @param mixed $relations
* @return string
*/
protected function format_relations($relations)
{
if (is_array($relations)) {
return implode(', ', array_keys($relations));
}
return (string) $relations;
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Database;
use Illuminate\Database\Eloquent\Collection;
/**
* Custom Eloquent collection that prevents eager loading
*
* This collection overrides all eager loading methods to throw exceptions
* when any form of relationship preloading is attempted on collections.
*/
class RestrictedEloquentCollection extends Collection
{
/**
* Prevent eager loading via load()
*
* @param mixed $relations
* @return $this
* @throws \RuntimeException
*/
public function load($relations)
{
throw new \RuntimeException(
'Eager loading on collections via load() is not allowed in the RSpade framework. ' .
'Use explicit queries for each relationship instead. ' .
'Attempted to eager load: ' . $this->format_relations($relations)
);
}
/**
* Prevent eager loading via loadMissing()
*
* @param mixed $relations
* @return $this
* @throws \RuntimeException
*/
public function loadMissing($relations)
{
throw new \RuntimeException(
'Conditional eager loading on collections via loadMissing() is not allowed in the RSpade framework. ' .
'Use explicit queries for each relationship instead. ' .
'Attempted to conditionally eager load: ' . $this->format_relations($relations)
);
}
/**
* Prevent eager loading via loadCount()
*
* @param mixed $relations
* @return $this
* @throws \RuntimeException
*/
public function loadCount($relations)
{
throw new \RuntimeException(
'Eager loading counts on collections via loadCount() is not allowed in the RSpade framework. ' .
'Use explicit count queries instead. ' .
'Attempted to eager load counts for: ' . $this->format_relations($relations)
);
}
/**
* Prevent eager loading via loadMax()
*
* @param array|string $relations
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function loadMax($relations, $column)
{
throw new \RuntimeException(
'Max eager loading on collections via loadMax() is not allowed in the RSpade framework. ' .
'Use explicit max queries instead.'
);
}
/**
* Prevent eager loading via loadMin()
*
* @param array|string $relations
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function loadMin($relations, $column)
{
throw new \RuntimeException(
'Min eager loading on collections via loadMin() is not allowed in the RSpade framework. ' .
'Use explicit min queries instead.'
);
}
/**
* Prevent eager loading via loadSum()
*
* @param array|string $relations
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function loadSum($relations, $column)
{
throw new \RuntimeException(
'Sum eager loading on collections via loadSum() is not allowed in the RSpade framework. ' .
'Use explicit sum queries instead.'
);
}
/**
* Prevent eager loading via loadAvg()
*
* @param array|string $relations
* @param string $column
* @return $this
* @throws \RuntimeException
*/
public function loadAvg($relations, $column)
{
throw new \RuntimeException(
'Avg eager loading on collections via loadAvg() is not allowed in the RSpade framework. ' .
'Use explicit avg queries instead.'
);
}
/**
* Prevent eager loading via loadExists()
*
* @param array|string $relations
* @return $this
* @throws \RuntimeException
*/
public function loadExists($relations)
{
throw new \RuntimeException(
'Exists eager loading on collections via loadExists() is not allowed in the RSpade framework. ' .
'Use explicit exists queries instead.'
);
}
/**
* Prevent eager loading via loadAggregate()
*
* @param mixed $relations
* @param string $column
* @param string $function
* @return $this
* @throws \RuntimeException
*/
public function loadAggregate($relations, $column, $function = null)
{
throw new \RuntimeException(
'Aggregate eager loading on collections via loadAggregate() is not allowed in the RSpade framework. ' .
'Use explicit aggregate queries instead.'
);
}
/**
* Prevent eager loading via loadMorph()
*
* @param string $relation
* @param array $relations
* @return $this
* @throws \RuntimeException
*/
public function loadMorph($relation, $relations)
{
throw new \RuntimeException(
'Morphed eager loading on collections via loadMorph() is not allowed in the RSpade framework. ' .
'Use explicit queries for each relationship instead.'
);
}
/**
* Prevent eager loading via loadMorphCount()
*
* @param string $relation
* @param array $relations
* @return $this
* @throws \RuntimeException
*/
public function loadMorphCount($relation, $relations)
{
throw new \RuntimeException(
'Morphed count eager loading on collections via loadMorphCount() is not allowed in the RSpade framework. ' .
'Use explicit count queries instead.'
);
}
/**
* Format relations for error messages
*
* @param mixed $relations
* @return string
*/
protected function format_relations($relations)
{
if (is_array($relations)) {
$formatted = [];
foreach ($relations as $key => $value) {
if (is_string($key)) {
$formatted[] = $key;
} else {
$formatted[] = $value;
}
}
return implode(', ', $formatted);
}
return (string) $relations;
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Exceptions;
use Exception;
class MassAssignmentException extends Exception
{
protected $model_class;
protected $attempted_fields;
protected $method_name;
/**
* Create a new mass assignment exception
*
* @param string $model_class The model class that was targeted
* @param array $attempted_fields The fields that were attempted to be mass assigned
* @param bool $was_force Whether forceFill was attempted
* @param bool $is_static Whether a static method was called
* @param string|null $method_name Specific method name if relevant
*/
public function __construct($model_class, array $attempted_fields = [], $was_force = false, $is_static = false, $method_name = null)
{
$this->model_class = $model_class;
$this->attempted_fields = $attempted_fields;
$this->method_name = $method_name ?: ($was_force ? 'forceFill' : 'fill');
$message = $this->build_message($is_static);
parent::__construct($message);
}
/**
* Build the exception message with helpful guidance
*
* @param bool $is_static
* @return string
*/
protected function build_message($is_static)
{
$short_class = class_basename($this->model_class);
$var_name = '$' . strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $short_class));
$fields_list = implode(', ', $this->attempted_fields);
$message = "🚫 MASS ASSIGNMENT PROHIBITED IN RSPADE FRAMEWORK\n\n";
$message .= "Model: {$this->model_class}\n";
$message .= "Method: {$this->method_name}()\n";
if (!empty($this->attempted_fields)) {
$message .= "Attempted fields: {$fields_list}\n";
}
$message .= "\n";
$message .= "The RSpade framework prohibits mass assignment for security and code clarity.\n";
$message .= "All model fields must be explicitly assigned one by one.\n\n";
$message .= "❌ INCORRECT (mass assignment):\n";
if ($is_static) {
if ($this->method_name === 'create') {
$message .= "{$short_class}::create(\$request->all());\n";
$message .= "{$short_class}::create(\$request->validated());\n";
} elseif ($this->method_name === 'firstOrCreate') {
$message .= "{$short_class}::firstOrCreate(['email' => \$email], \$data);\n";
} elseif ($this->method_name === 'updateOrCreate') {
$message .= "{$short_class}::updateOrCreate(['id' => \$id], \$data);\n";
}
} else {
if ($this->method_name === 'update') {
$message .= "{$var_name}->update(\$request->all());\n";
$message .= "{$var_name}->update(\$request->validated());\n";
} else {
$message .= "{$var_name}->fill(\$request->all());\n";
$message .= "{$var_name}->forceFill(\$data);\n";
}
}
$message .= "\n✅ CORRECT (explicit field assignment):\n";
// Generate example based on attempted fields
if (!empty($this->attempted_fields)) {
if ($is_static || $this->method_name === 'update') {
if ($this->method_name === 'update') {
$message .= "{$var_name} = {$short_class}::find(\$id);\n";
} else {
$message .= "{$var_name} = new {$short_class}();\n";
}
foreach ($this->attempted_fields as $field) {
$message .= "{$var_name}->{$field} = \$request->input('{$field}');\n";
}
$message .= "{$var_name}->save();\n";
}
} else {
// Generic example
$message .= "{$var_name} = new {$short_class}();\n";
$message .= "{$var_name}->name = \$request->input('name');\n";
$message .= "{$var_name}->email = \$request->input('email');\n";
$message .= "{$var_name}->status = \$request->input('status');\n";
$message .= "{$var_name}->save();\n";
}
$message .= "\n";
$message .= "For finding and updating:\n";
$message .= "{$var_name} = {$short_class}::where('email', \$email)->first();\n";
$message .= "if (!{$var_name}) {\n";
$message .= " {$var_name} = new {$short_class}();\n";
$message .= " {$var_name}->email = \$email;\n";
$message .= "}\n";
$message .= "{$var_name}->name = \$request->input('name');\n";
$message .= "{$var_name}->save();\n";
$message .= "\n";
$message .= "Benefits of explicit assignment:\n";
$message .= "• Clear visibility of what fields are being set\n";
$message .= "• Protection against unexpected field injection\n";
$message .= "• Easier to debug and maintain\n";
$message .= "• Type safety with IDE autocompletion\n";
$message .= "• No need for \$fillable or \$guarded arrays\n";
$message .= "\n";
$message .= "Note: The \$fillable property is not needed in RSpade models.\n";
$message .= "Remove any \$fillable or \$guarded properties from your models.\n";
return $message;
}
/**
* Get the model class that triggered the exception
*
* @return string
*/
public function get_model_class()
{
return $this->model_class;
}
/**
* Get the fields that were attempted to be mass assigned
*
* @return array
*/
public function get_attempted_fields()
{
return $this->attempted_fields;
}
}

View File

77
app/Http/Kernel.php Executable file
View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
// Custom RSX middleware
\App\Http\Middleware\Gatekeeper::class, // Must run early to protect all routes
\App\Http\Middleware\CheckMigrationMode::class,
\App\Http\Middleware\PlaywrightTestMode::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
// Session middleware removed for custom session handler
// \Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\View\Middleware\ShareErrorsFromSession::class,
// \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Jqhtml\LaravelBridge\Middleware\JqhtmlErrorMiddleware::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's middleware aliases.
*
* Aliases may be used instead of class names to conveniently assign middleware to routes and groups.
*
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
// 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, // Removed for custom session handler
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
// DISABLED 2025-09-14: RsxMiddleware removed as RSX routing now handled via 404 exception handler
// To re-enable: uncomment the line below and rename RsxMiddleware.php.disabled back to .php
// 'rsx' => \App\Http\Middleware\RsxMiddleware::class,
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
class Authenticate extends Middleware
{
/**
* Get the path the user should be redirected to when they are not authenticated.
*/
protected function redirectTo(Request $request): ?string
{
return $request->expectsJson() ? null : route('login');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
class CheckMigrationMode
{
protected $flag_file = '/var/www/html/.migrating';
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
// Only check migration mode in development environment
if (!app()->environment('production') && file_exists($this->flag_file)) {
$session_info = json_decode(file_get_contents($this->flag_file), true);
$started_at = $session_info['started_at'] ?? 'unknown';
// Create a detailed error message
$message = "🚧 Database Migration in Progress\n\n";
$message .= "A database migration session is currently active.\n";
$message .= "Started at: {$started_at}\n\n";
$message .= "The application is temporarily unavailable to ensure data integrity.\n\n";
$message .= "To complete the migration session, run one of these commands:\n";
$message .= " • php artisan migrate:commit - Keep the changes\n";
$message .= " • php artisan migrate:rollback - Revert to snapshot\n\n";
$message .= "For status: php artisan migrate:status";
// Throw service unavailable exception
throw new ServiceUnavailableHttpException(
null,
$message
);
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Gatekeeper
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Check if gatekeeper is enabled
if (!config('rsx.gatekeeper.enabled')) {
return $next($request);
}
// Always allow CLI requests
if (php_sapi_name() === 'cli') {
return $next($request);
}
// Always allow IDE helper endpoints for VS Code extension integration
if (str_starts_with($request->path(), '_idehelper')) {
return $next($request);
}
// Check if request is whitelisted (localhost without reverse proxy headers)
if ($this->is_whitelisted($request)) {
return $next($request);
}
// Check if user has valid authentication cookie
$cookie_name = config('rsx.gatekeeper.cookie_name', 'gatekeeper_auth');
$password = config('rsx.gatekeeper.password');
if (!$password) {
throw new \Exception('Gatekeeper enabled but no password configured. Set GATEKEEPER_PASSWORD in .env');
}
$cookie_value = $request->cookie($cookie_name);
$expected_hash = hash('sha256', $password);
// If authenticated, renew cookie and continue
if ($cookie_value === $expected_hash) {
$lifetime_hours = config('rsx.gatekeeper.cookie_lifetime_hours', 12);
$cookie = cookie($cookie_name, $expected_hash, 60 * $lifetime_hours);
$response = $next($request);
// Only add cookie to regular responses, not binary file responses
if (method_exists($response, 'withCookie')) {
return $response->withCookie($cookie);
}
return $response;
}
// Handle login POST request
if ($request->isMethod('POST') && $request->path() === '_gatekeeper/login') {
return $this->handle_login($request);
}
// Show login page
return $this->show_login_page($request);
}
/**
* Check if the request is whitelisted (localhost without reverse proxy headers)
*/
private function is_whitelisted(Request $request): bool
{
// Get the client IP
$ip = $request->ip();
// List of localhost IPs
$localhost_ips = [
'127.0.0.1',
'localhost',
'::1',
'0.0.0.0',
];
// Check if IP matches localhost patterns
$is_localhost = in_array($ip, $localhost_ips) ||
str_starts_with($ip, '127.') ||
$ip === '::1';
if (!$is_localhost) {
return false;
}
// Check for reverse proxy headers - if present, this is NOT a true localhost request
$proxy_headers = [
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED_HOST',
'HTTP_X_FORWARDED_PORT',
'HTTP_X_FORWARDED_PROTO',
'HTTP_X_FORWARDED_SERVER',
'HTTP_X_REAL_IP',
'HTTP_X_ORIGINAL_URL',
'HTTP_FORWARDED',
'HTTP_CLIENT_IP',
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_TRUE_CLIENT_IP', // Cloudflare Enterprise
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_X_FORWARDED',
'HTTP_FORWARDED_FOR',
'HTTP_VIA',
];
foreach ($proxy_headers as $header) {
if (!empty($_SERVER[$header])) {
// Reverse proxy header detected - force authentication
return false;
}
}
// Also check Laravel's request headers
if ($request->headers->has('X-Forwarded-For') ||
$request->headers->has('X-Forwarded-Host') ||
$request->headers->has('X-Forwarded-Proto') ||
$request->headers->has('X-Real-IP') ||
$request->headers->has('Forwarded')) {
return false;
}
// True localhost request without proxy headers
return true;
}
/**
* Handle login POST request
*/
private function handle_login(Request $request): Response
{
$password = config('rsx.gatekeeper.password');
$submitted = $request->input('password');
if ($submitted === $password) {
// Authentication successful
$cookie_name = config('rsx.gatekeeper.cookie_name', 'gatekeeper_auth');
$lifetime_hours = config('rsx.gatekeeper.cookie_lifetime_hours', 12);
$cookie_value = hash('sha256', $password);
$cookie = cookie($cookie_name, $cookie_value, 60 * $lifetime_hours);
// Redirect to originally requested URL or home
$redirect = $request->input('redirect', '/');
return redirect($redirect)->withCookie($cookie);
}
// Authentication failed - show login page with error
return $this->show_login_page($request, 'Invalid password. Please try again.');
}
/**
* Show the gatekeeper login page
*/
private function show_login_page(Request $request, string $error = null): Response
{
$data = [
'title' => config('rsx.gatekeeper.title', 'Development Preview'),
'subtitle' => config('rsx.gatekeeper.subtitle', 'This is a restricted development preview site. Please enter the access password to continue.'),
'logo' => config('rsx.gatekeeper.logo'),
'error' => $error,
'redirect' => $request->fullUrl(),
];
return response()->view('gatekeeper.login', $data, 403);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\RSpade\Core\Debug\Debugger;
class PlaywrightTestMode
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
// Only process Playwright headers in non-production environments
// and only from loopback addresses (no proxy headers)
if (app()->environment('production') || !is_loopback_ip()) {
return $next($request);
}
// If this is a Playwright test request, configure console debug from headers
if ($request->hasHeader('X-Playwright-Test')) {
// Configure console debug settings from headers
Debugger::configure_from_headers();
$response = $next($request);
// If this is a redirect response, modify the Location header to be relative
if ($response->isRedirect()) {
$location = $response->headers->get('Location');
// Parse the URL and extract just the path and query
$parsed = parse_url($location);
if (isset($parsed['path'])) {
$relative_url = $parsed['path'];
if (isset($parsed['query'])) {
$relative_url .= '?' . $parsed['query'];
}
if (isset($parsed['fragment'])) {
$relative_url .= '#' . $parsed['fragment'];
}
// Only modify if it's a local redirect (not external)
if (!isset($parsed['host']) || $parsed['host'] === $request->getHost() || $parsed['host'] === '127.0.0.1' || $parsed['host'] === 'localhost') {
$response->headers->set('Location', $relative_url);
}
}
}
return $response;
}
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance as Middleware;
class PreventRequestsDuringMaintenance extends Middleware
{
/**
* The URIs that should be reachable while maintenance mode is enabled.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
if (Auth::guard($guard)->check()) {
return redirect(RouteServiceProvider::HOME);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,195 @@
<?php
/**
* DISABLED: 2025-09-14
* This middleware was disabled as it appears to be a leftover from an earlier failed routing attempt.
* RSX routing is now handled through the 404 exception handler in app/Exceptions/Handler.php.
*
* This file is kept for observation in case any key functionality needs to be restored:
* - Automatic cache clearing in development mode
* - Path exclusion for certain prefixes (_debugbar, horizon, telescope, etc.)
* - Session initialization for RSX requests
* - RSX debugging headers
*
* To re-enable this middleware:
* 1. Rename file back to RsxMiddleware.php (remove .disabled extension)
* 2. Re-add to app/Http/Kernel.php:
* protected $routeMiddleware = [
* 'rsx' => \App\Http\Middleware\RsxMiddleware::class,
* ];
*
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
/**
* RsxMiddleware processes requests before RSX dispatch
*
* This middleware:
* - Checks if request should be handled by RSX
* - Maintains session and auth state
* - Adds RSX-specific headers
*/
class RsxMiddleware
{
/**
* Handle an incoming request
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
// In development mode, automatically disable all Laravel caches
if (!app()->environment('production')) {
$this->ensure_caches_disabled();
}
// Note: Asset requests are handled by the RSX AssetHandler in the Dispatcher
// No need to block them here
// Check if request path should be excluded from RSX
if ($this->is_excluded_path($request)) {
abort(404);
}
// Add RSX indicator to request
$request->attributes->set('rsx_request', true);
// Ensure session is started for RSX requests
if ($request->hasSession()) {
$request->session()->start();
}
// Process the request
$response = $next($request);
// Add RSX response headers if configured
if (config('rsx.development.show_route_details', false)) {
$response->headers->set('X-RSX-Dispatch', 'true');
// Add dispatch time if available
if ($request->attributes->has('rsx_dispatch_time')) {
$response->headers->set(
'X-RSX-Dispatch-Time',
$request->attributes->get('rsx_dispatch_time') . 'ms'
);
}
}
return $response;
}
/**
* Check if path should be excluded from RSX routing
*
* @param Request $request
* @return bool
*/
protected function is_excluded_path(Request $request)
{
$path = $request->path();
// Never handle Laravel's default routes
$excluded_prefixes = [
'_debugbar',
'horizon',
'telescope',
'nova',
'livewire',
'sanctum',
'broadcasting'
];
foreach ($excluded_prefixes as $prefix) {
if (str_starts_with($path, $prefix . '/') || $path === $prefix) {
return true;
}
}
// Check custom excluded paths from config
$custom_excluded = config('rsx.routing.excluded_paths', []);
foreach ($custom_excluded as $excluded) {
if (str_starts_with($path, $excluded)) {
return true;
}
}
return false;
}
/**
* Ensure all Laravel caches are disabled in development mode
*
* @return void
*/
protected function ensure_caches_disabled()
{
static $caches_checked = false;
// Only check once per request lifecycle
if ($caches_checked) {
return;
}
$caches_checked = true;
// Check if any caches exist that shouldn't in development
$cache_files = [
'config' => app()->getCachedConfigPath(),
'routes' => app()->getCachedRoutesPath(),
'events' => app()->getCachedEventsPath(),
];
$files = app('files');
foreach ($cache_files as $type => $path) {
if ($files->exists($path)) {
// Clear the cache silently using output buffering
ob_start();
try {
switch ($type) {
case 'config':
\Artisan::call('config:clear');
break;
case 'routes':
\Artisan::call('route:clear');
break;
case 'events':
\Artisan::call('event:clear');
break;
}
} catch (\Exception $e) {
// Silently ignore any errors - we're just trying to clear caches
} finally {
ob_end_clean();
}
}
}
// Also check compiled views and clear if needed
$compiled_path = config('view.compiled');
if ($files->isDirectory($compiled_path)) {
$compiled_views = $files->glob("{$compiled_path}/*");
if (count($compiled_views) > 100) { // Only clear if there are many compiled views
ob_start();
try {
\Artisan::call('view:clear');
} catch (\Exception $e) {
// Silently ignore
} finally {
ob_end_clean();
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
class TrimStrings extends Middleware
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array<int, string>
*/
protected $except = [
'current_password',
'password',
'password_confirmation',
];
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustProxies as Middleware;
use Illuminate\Http\Request;
class TrustProxies extends Middleware
{
/**
* The trusted proxies for this application.
*
* @var array<int, string>|string|null
*/
protected $proxies;
/**
* The headers that should be used to detect proxies.
*
* @var int
*/
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Routing\Middleware\ValidateSignature as Middleware;
class ValidateSignature extends Middleware
{
/**
* The names of the query string parameters that should be ignored.
*
* @var array<int, string>
*/
protected $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',
// 'utm_medium',
// 'utm_source',
// 'utm_term',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array<int, string>
*/
protected $except = [
//
];
}

0
app/Jobs/.placeholder Executable file
View File

66
app/Mail/VerificationCode.php Executable file
View File

@@ -0,0 +1,66 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class VerificationCode extends Mailable
{
use Queueable, SerializesModels;
/**
* The user instance.
*
* @var \App\Models\User
*/
public $user;
/**
* The verification code.
*
* @var string
*/
public $code;
/**
* The code expiration time in minutes.
*
* @var int
*/
public $expiresInMinutes;
/**
* Create a new message instance.
*
* @param \App\Models\User $user
* @param string $code
* @return void
*/
public function __construct(User $user, $code)
{
$this->user = $user;
$this->code = $code;
$this->expiresInMinutes = config('authentication.two_factor.sms.code_lifetime', 10);
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->subject('Your Verification Code')
->view('emails.verification-code');
}
}

74
app/Mail/VerifyEmail.php Executable file
View File

@@ -0,0 +1,74 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\Mail;
use App\Models\PendingRegistration;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class VerifyEmail extends Mailable
{
use Queueable, SerializesModels;
/**
* The pending registration instance.
*
* @var \App\Models\PendingRegistration
*/
public $registration;
/**
* The verification URL.
*
* @var string
*/
public $url;
/**
* The expiration time in hours.
*
* @var int
*/
public $expiresInHours;
/**
* Create a new message instance.
*
* @param \App\Models\PendingRegistration $registration
* @return void
*/
public function __construct(PendingRegistration $registration)
{
$this->registration = $registration;
$this->url = URL::temporarySignedRoute(
'auth.verify-email',
now()->addHours(24),
['token' => $registration->verification_token]
);
$this->expiresInHours = ceil(
$registration->expires_at->diffInMinutes(now()) / 60
);
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->subject('Verify Your Email Address')
->view('emails.verify-email');
}
}

173
app/Models/File_Hash.php Executable file
View File

@@ -0,0 +1,173 @@
<?php
namespace App\Models;
/**
* File_Hash model representing unique physical files
*
* This model enables deduplication of file storage - multiple logical files
* can reference the same physical file if they have the same content hash.
*/
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use Rsx\Models\File_Model;
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2025-09-28 10:36:08
* Table: file_hashes
*
* @property int $id
* @property mixed $hash
* @property mixed $mime_type
* @property int $size
* @property string $created_at
* @property string $updated_at
* @property int $created_by
* @property int $updated_by
*
* @mixin \Eloquent
*/
class File_Hash extends Rsx_Model_Abstract
{
// Required static properties from parent abstract class
public static $enums = [];
public static $rel = [];
/**
* _AUTO_GENERATED_ Date columns for Carbon casting
*/
protected $dates = [
'created_at',
'updated_at',
];
/**
* The table associated with the model
*
* @var string
*/
protected $table = 'file_hashes';
/**
* Column metadata for special handling
*
* @var array
*/
protected $columnMeta = [
// No special metadata needed for file_hashes table columns yet
];
/**
* Get all logical files that reference this physical file
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function files()
{
return $this->hasMany(File_Model::class, 'file_hash_id');
}
/**
* Get the storage path for this file
* Based on the hash for efficient file system distribution
*
* @return string
*/
public function get_storage_path()
{
// Split hash into subdirectories for better file system performance
// e.g., hash "abc123..." becomes "storage/files/ab/c1/abc123..."
$hash = $this->hash;
$dir1 = substr($hash, 0, 2);
$dir2 = substr($hash, 2, 2);
return "storage/files/{$dir1}/{$dir2}/{$hash}";
}
/**
* Get the full file system path
*
* @return string
*/
public function get_full_path()
{
return storage_path($this->get_storage_path());
}
/**
* Check if the physical file exists on disk
*
* @return bool
*/
public function file_exists()
{
return file_exists($this->get_full_path());
}
/**
* Get human-readable file size
*
* @return string
*/
public function get_human_size()
{
$bytes = $this->size;
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Find or create a file hash record
*
* @param string $hash
* @param string $mime_type
* @param int $size
* @return static
*/
public static function find_or_create($hash, $mime_type, $size)
{
$file_hash = static::where('hash', $hash)->first();
if (!$file_hash) {
$file_hash = new static();
$file_hash->hash = $hash;
$file_hash->mime_type = $mime_type;
$file_hash->size = $size;
$file_hash->save();
}
return $file_hash;
}
/**
* Calculate hash for file content
*
* @param string $content
* @return string
*/
public static function calculate_hash($content)
{
return hash('sha256', $content);
}
/**
* Calculate hash for a file path
*
* @param string $file_path
* @return string|false
*/
public static function calculate_file_hash($file_path)
{
if (!file_exists($file_path)) {
return false;
}
return hash_file('sha256', $file_path);
}
}

47
app/Models/FlashAlert.php Executable file
View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2025-09-28 10:36:08
* Table: flash_alerts
*
* @property int $id
* @property mixed $session_id
* @property string $message
* @property mixed $class_attribute
* @property string $created_at
* @property int $created_by
* @property int $updated_by
* @property string $updated_at
*
* @mixin \Eloquent
*/
class FlashAlert extends Rsx_Model_Abstract
{
// Required static properties from parent abstract class
public static $enums = [];
public static $rel = [];
/**
* _AUTO_GENERATED_ Date columns for Carbon casting
*/
protected $dates = [
'created_at',
'updated_at',
];
protected $table = 'flash_alerts';
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'created_at' => 'datetime',
];
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class SystemNotification extends Notification implements ShouldQueue
{
use Queueable;
protected $title;
protected $message;
protected $actionUrl;
protected $actionText;
protected $includeGreeting;
/**
* Create a new notification instance.
*
* @param string $title
* @param string $message
* @param string|null $actionUrl
* @param string|null $actionText
* @param bool $includeGreeting
* @return void
*/
public function __construct(
string $title,
string $message,
string $actionUrl = null,
string $actionText = null,
bool $includeGreeting = true
) {
$this->title = $title;
$this->message = $message;
$this->actionUrl = $actionUrl;
$this->actionText = $actionText;
$this->includeGreeting = $includeGreeting;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
$mail = (new MailMessage)
->subject($this->title)
->line($this->message);
if ($this->includeGreeting) {
$mail->greeting('Hello ' . $notifiable->name . '!');
}
if ($this->actionUrl && $this->actionText) {
$mail->action($this->actionText, $this->actionUrl);
}
$mail->line('Thank you for using DMR Bridge!');
return $mail;
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
'title' => $this->title,
'message' => $this->message,
'action_url' => $this->actionUrl,
'action_text' => $this->actionText,
];
}
}

0
app/Policies/.placeholder Executable file
View File

View File

@@ -0,0 +1,215 @@
<?php
namespace App\Providers;
use App\RSpade\Core\Providers\Rsx_Preboot_Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\ViewErrorBag;
use Log;
class AppServiceProvider extends ServiceProvider
{
/**
* Query logging modes
*/
public const QUERY_LOG_NONE = 0; // Default - no logging
public const QUERY_LOG_ALL_STDOUT = 1; // Log all queries to stdout
public const QUERY_LOG_DESTRUCTIVE_STDOUT = 2; // Log only destructive queries to stdout
public const QUERY_LOG_ALL_LARAVEL = 3; // Log all queries to Laravel log
public const QUERY_LOG_DESTRUCTIVE_LARAVEL = 4; // Log only destructive queries to Laravel log
/**
* Current query logging mode
*
* @var int
*/
protected static $query_log_mode = self::QUERY_LOG_NONE;
/**
* Enable query echoing (backwards compatibility - sets ALL_STDOUT mode)
*
* @return void
*/
public static function enable_query_echo()
{
self::$query_log_mode = self::QUERY_LOG_ALL_STDOUT;
}
/**
* Disable query echoing
*
* @return void
*/
public static function disable_query_echo()
{
self::$query_log_mode = self::QUERY_LOG_NONE;
}
/**
* Set query logging mode
*
* @param int $mode One of the QUERY_LOG_* constants
* @return void
*/
public static function set_query_log_mode(int $mode)
{
self::$query_log_mode = $mode;
}
/**
* Check if a query is destructive (modifies data)
*
* @param string $sql
* @return bool
*/
protected static function is_destructive_query(string $sql): bool
{
$sql_upper = strtoupper(trim($sql));
// Check for destructive SQL operations
$destructive_patterns = [
'INSERT ',
'UPDATE ',
'DELETE ',
'ALTER ',
'CREATE ',
'DROP ',
'TRUNCATE ',
'REPLACE ',
'RENAME ',
'MODIFY ',
'ADD COLUMN',
'DROP COLUMN',
'ADD INDEX',
'DROP INDEX',
'ADD CONSTRAINT',
'DROP CONSTRAINT',
];
foreach ($destructive_patterns as $pattern) {
if (strpos($sql_upper, $pattern) === 0) {
return true;
}
}
return false;
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
// Initialize RSpade pre-boot services (debug locks, cache clearing, trace mode)
Rsx_Preboot_Service::init();
// Load JQHTML Laravel Bridge from node_modules
$jqhtmlBridge = base_path('node_modules/@jqhtml/core/laravel-bridge/autoload.php');
if (file_exists($jqhtmlBridge)) {
require_once $jqhtmlBridge;
}
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// Override Ignition's exception renderer with our custom one that fixes multi-line display
if (!app()->environment('production')) {
$this->app->bind(
'Illuminate\Contracts\Foundation\ExceptionRenderer',
fn ($app) => $app->make(\App\RSpade\Integrations\Jqhtml\JqhtmlExceptionRenderer::class)
);
}
// Configure MySQL connection to use custom grammar with millisecond precision
$connection = DB::connection();
if ($connection->getDriverName() === 'mysql') {
$connection->setQueryGrammar(new \App\RSpade\Core\Database\Query\Grammars\Query_MySqlGrammar());
$connection->setSchemaGrammar(new \App\RSpade\Core\Database\Schema\Grammars\Schema_MySqlGrammar());
}
// Set up query listener for migration/normalize debugging
DB::listen(function ($query) {
if (self::$query_log_mode === self::QUERY_LOG_NONE) {
return;
}
$sql = $query->sql;
$is_destructive = self::is_destructive_query($sql);
switch (self::$query_log_mode) {
case self::QUERY_LOG_ALL_STDOUT:
echo $sql . "\n";
break;
case self::QUERY_LOG_DESTRUCTIVE_STDOUT:
if ($is_destructive) {
echo $sql . "\n";
}
break;
case self::QUERY_LOG_ALL_LARAVEL:
Log::debug('SQL Query: ' . $sql);
break;
case self::QUERY_LOG_DESTRUCTIVE_LARAVEL:
if ($is_destructive) {
Log::debug('SQL Query (Destructive): ' . $sql);
}
break;
}
});
// Share $errors variable with all views for @error directive
// Disabled for custom session handler
// View::composer('*', function ($view) {
// $view->with('errors', session()->get('errors', new ViewErrorBag));
// });
// Register custom database session handler to preserve user_id and site_id columns
// Disabled for custom session handler
// Session::extend('database', function ($app) {
// $connection = $app['db']->connection($app['config']['session.connection']);
// $table = $app['config']['session.table'];
// $lifetime = $app['config']['session.lifetime'];
//
// return new \App\RSpade\Core\Session\Custom_DatabaseSessionHandler(
// $connection,
// $table,
// $lifetime,
// $app
// );
// });
// Override cache:clear to integrate RSX clearing
if ($this->app->runningInConsole()) {
$this->override_cache_clear();
}
}
/**
* Override Laravel's cache:clear command to integrate with RSX
*
* @return void
*/
protected function override_cache_clear()
{
// Only override cache:clear to also clear RSX caches
$this->app->extend('command.cache.clear', function ($command, $app) {
return new \App\Console\Commands\CacheClearCommand();
});
}
}

26
app/Providers/CLAUDE.md Executable file
View File

@@ -0,0 +1,26 @@
# Providers Directory
This CLAUDE.md file contains a brief synopsis of the purpose of this directory, then a list of files in this directory with the file sizes of each file, and a short description and relevant key points of information for every file which is important in this directory. Unimportant files like images or temporary data directories are not listed in this file. When visiting this directory, the AI agent is instructed to do an ls on the directory to get the directory contents and file sizes - and if the file size diverges from the size in CLAUDE.md, that means the file has changed, and the description in CLAUDE.md is not up to date. This doesn't trigger this to be regenerated immediately, but let's say we wanted to know about a specific file by viewing CLAUDE.md and we discovered it was out of date, we would need to reread and update the documentation for that file in the CLAUDE.md at that time before we considered any details about it. CLAUDE.md might also contain other bits of information that is critical to know if you are looking at notes in the directory where the CLAUDE.md file lives.
## Directory Purpose
The Providers directory contains Laravel service providers which are the central configuration mechanism in Laravel applications. Service providers bootstrap the application by binding services in the service container, registering events, middleware, routes, and configuration.
## File Index
| File | Size | Description |
|------|------|-------------|
| AppServiceProvider.php | 940 bytes | General application service provider that refreshes session lifetime with each page request and shares constants (states and date formats) with all views. |
| AuthServiceProvider.php | 751 bytes | Authentication provider that defines model-to-policy mappings for authorization, registers Bridge and Talkgroup policies, and calls registerPolicies() to load these mappings. |
| BroadcastServiceProvider.php | 380 bytes | Broadcasting provider that registers routes for broadcasting and loads the routes/channels.php file for channel authorization. |
| EventServiceProvider.php | 926 bytes | Event handler provider that maps the Registered event to SendEmailVerificationNotification listener and disables automatic event discovery. |
| RecaptchaServiceProvider.php | 1,383 bytes | Custom provider for Google reCAPTCHA integration that adds a 'recaptcha' validator, skips validation in testing environment, and verifies reCAPTCHA responses via Google's API. |
| RouteServiceProvider.php | 1,452 bytes | Route configuration provider that defines HOME constant for redirection after authentication, configures rate limiting for API routes, and loads API, multi-tenant, and RSpade routes with appropriate middleware. |
## Implementation Notes
1. All service providers follow the Laravel provider pattern with register() and boot() methods.
2. The register() method happens before all providers are loaded, while boot() happens after all providers are loaded.
3. Custom providers like RecaptchaServiceProvider extend Laravel's base ServiceProvider.
4. The RouteServiceProvider has been extended to support multi-tenant and RSpade route files.
5. AppServiceProvider handles session management and constants sharing with views.

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
{
$this->routes(function () {
Route::middleware('web')
->group(base_path('routes/web.php'));
});
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Bootstrap 5 CDN Bundle
*
* Provides Bootstrap 5 CSS and JavaScript via CDN.
*/
class Bootstrap5_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [], // No local files
'cdn_assets' => [
'css' => [
[
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/css/bootstrap.min.css',
'integrity' => 'sha512-SbiR/eusphKoMVVXysTKG/7VseWii+Y3FdHrt0EpKgpToZeemhqHeZeLWLhJutz/2ut2Vw1uQEj2MbRF+TVBUA==',
],
],
'js' => [
[
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/js/bootstrap.bundle.min.js',
'integrity' => 'sha512-i9cEfJwUwViEPFKdC1enz4ZRGBj8YQo6QByFTF92YXHi7waCqyexvRD75S5NVTsSiTv7rKWqG9Y5eFxmRsOn0A==',
],
],
],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* jQuery CDN Bundle
*
* Provides jQuery library via CDN. This bundle is automatically included
* in all other bundles as a required dependency.
*/
class Jquery_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [], // No local files
'cdn_assets' => [
'js' => [
[
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js',
'integrity' => 'sha512-pumBsjNRGGqkPzKHndZMaAG+bir374sORyzM3uulLV14lN5LyykqNk8eEeUlUkB3U0M4FApyaHraT65ihJhDpQ==',
],
],
],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\RSpade\Bundles;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Lodash CDN Bundle
*
* Provides Lodash utility library via CDN. This bundle is automatically included
* in all other bundles as a required dependency.
*/
class Lodash_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [], // No local files
'cdn_assets' => [
'js' => [
[
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js',
'integrity' => 'sha512-WFN04846sdKMIP5LKNphMaWzU7YpMyCU245etK3g/2ARYbPK9Ub18eG+ljU96qKRCWh+quCY7yefSmlkQw1ANQ==',
],
],
],
];
}
}

325
app/RSpade/CodeQuality/CLAUDE.md Executable file
View File

@@ -0,0 +1,325 @@
# RSpade Code Quality System
## Overview
The Code Quality system is a modular, extensible framework for enforcing coding standards and best practices across the RSpade codebase. It replaces a monolithic 1921-line checker with a clean, maintainable architecture using Manifest-based auto-discovery.
## Architecture
### Core Components
1. **CodeQualityChecker** (`CodeQualityChecker.php`)
- Main orchestrator that discovers and runs all rules
- Auto-discovers rules via RuleDiscovery::discover_rules()
- Handles file scanning, caching, and violation collection
- Performs syntax linting for PHP, JavaScript, and JSON files
2. **CodeQualityRule_Abstract** (`Rules/CodeQualityRule_Abstract.php`)
- Base class for all code quality rules
- Defines the interface: `get_id()`, `get_name()`, `check()`, etc.
- Provides `add_violation()` helper method
- Rules self-register by extending this class
3. **Violation** (`Violation.php`)
- Data class representing a code violation
- Contains: rule_id, file_path, line_number, message, severity, code_snippet, suggestion
- Provides `to_array()` for serialization
### Support Classes
- **ViolationCollector** - Aggregates violations from all rules
- **CacheManager** - Caches sanitized file contents to improve performance
- **FileSanitizer** - Removes comments and strings for accurate code analysis
## Rule Categories
### PHP Rules (`Rules/PHP/`)
1. **NamingConventionRule** (PHP-NAMING-01)
- Enforces underscore_case for methods and variables
- Excludes Laravel framework methods (toArray, firstOrCreate, etc.)
- Severity: Medium
2. **MassAssignmentRule** (PHP-MASS-01)
- Prohibits use of $fillable property
- Ensures $guarded = ['*'] or removal
- Severity: High
3. **PhpFallbackLegacyRule** (PHP-FALLBACK-01)
- Detects "fallback" or "legacy" in comments/function names
- Enforces fail-loud principle
- Severity: Critical
4. **DbTableUsageRule** (PHP-DB-01)
- Prohibits DB::table() usage
- Requires ORM models for database access
- Severity: High
5. **FunctionExistsRule** (PHP-FUNC-01)
- Prohibits function_exists() checks
- Enforces predictable runtime environment
- Severity: High
### Jqhtml Rules (`Rules/Jqhtml/`)
1. **JqhtmlInlineScriptRule** (JQHTML-INLINE-01)
- Prohibits inline <script> and <style> tags in .jqhtml templates
- Enforces component class pattern with Jqhtml_Component
- Requires separate .js and .scss files
- Severity: Critical
- Runs at manifest-time
### JavaScript Rules (`Rules/JavaScript/`)
1. **VarUsageRule** (JS-VAR-01)
- Prohibits 'var' keyword, requires let/const
- Severity: Medium
2. **DefensiveCodingRule** (JS-DEFENSIVE-01)
- Prohibits typeof checks for core classes
- Core classes always exist in runtime
- Severity: High
3. **InstanceMethodsRule** (JS-STATIC-01)
- Enforces static methods in JavaScript classes
- Exceptions allowed with @instance-class comment
- Severity: Medium
4. **JQueryUsageRule** (JS-JQUERY-01)
- Enforces $ over jQuery
- Detects deprecated methods (live, die, bind, etc.)
- Severity: Medium
5. **ThisUsageRule** (JS-THIS-01)
- Detects problematic 'this' usage
- Suggests class reference pattern
- Severity: Medium
6. **DocumentReadyRule** (JS-READY-01)
- Prohibits jQuery ready patterns
- Requires ES6 class with static init()
- Severity: High
7. **JsFallbackLegacyRule** (JS-FALLBACK-01)
- JavaScript version of fallback/legacy detection
- Severity: Critical
### Common Rules (`Rules/Common/`)
1. **FilenameCaseRule** (FILE-CASE-01)
- Enforces lowercase filenames
- Severity: Low
2. **FilenameEnhancedRule** (FILE-NAME-01)
- Validates controller/model naming conventions
- Checks file-class name consistency
- Severity: Medium
3. **RootFilesRule** (FILE-ROOT-01)
- Restricts files in project root
- Maintains clean project structure
- Severity: Medium
4. **RsxTestFilesRule** (FILE-RSX-01)
- Prevents test files directly in rsx/
- Enforces proper test organization
- Severity: Medium
5. **RouteExistsRule** (ROUTE-EXISTS-01)
- Validates Rsx::Route() calls reference existing routes
- Checks controller/method combinations exist in manifest
- Suggests placeholder URLs for unimplemented routes
- Severity: High
### Sanity Check Rules (`Rules/SanityChecks/`)
1. **PhpSanityCheckRule** (PHP-SC-001)
- Complex pattern detection (currently disabled)
- Detects suspicious code patterns
- Severity: Critical
## Configuration
### Config File (`config/rsx.php`)
```php
'code_quality' => [
'enabled' => env('CODE_QUALITY_ENABLED', true),
'cache_enabled' => true,
'parallel_processing' => false,
'excluded_directories' => [
'vendor',
'node_modules',
'storage',
'bootstrap/cache',
'CodeQuality', // Exclude checker itself
],
'rsx_test_whitelist' => [
// Files allowed in rsx/ directory
'main.php',
'routes.php',
],
],
```
### Disabling Rules
Rules can be disabled by adding them to the disabled list:
```php
'disabled_rules' => [
'PHP-SC-001', // Temporarily disabled
],
```
## Usage
### Command Line
```bash
# Run all checks
php artisan rsx:check
# Check specific directory
php artisan rsx:check rsx/
# Check specific file
php artisan rsx:check app/Models/User.php
```
### Exception Comments
Add exception comments to bypass specific violations:
```php
// @RULE-ID-EXCEPTION (e.g., @PHP-NAMING-01-EXCEPTION)
// Code that would normally violate rules
```
## Development
### Creating New Rules
1. Create a new class extending `CodeQualityRule_Abstract`
2. Place in appropriate Rules subdirectory
3. Implement required methods:
- `get_id()` - Unique rule identifier
- `get_name()` - Human-readable name
- `check()` - Violation detection logic
4. Add to Manifest scan directories if needed
Example:
```php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class MyNew_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-NEW-01';
}
public function get_name(): string
{
return 'My New Rule';
}
public function get_description(): string
{
return 'Description of what this rule checks';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Always false unless explicitly approved
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Detection logic
if ($violation_found) {
$this->add_violation(
$file_path,
$line_number,
"Violation message",
$code_snippet,
"How to fix",
'medium'
);
}
}
}
```
### Testing Rules
1. Create a temporary test file with violations
2. Run `php artisan rsx:check`
3. Verify violations are detected correctly
4. Clean up test files
## Migration from Monolith
The original 1921-line `CodeStandardsChecker.php` has been:
1. Archived to `/archived/CodeStandardsChecker.old.php`
2. Split into modular rule classes
3. Enhanced with auto-discovery via Manifest
4. Improved with better caching and performance
All original rule logic has been preserved exactly, ensuring no regression in code quality checks.
## Performance
- **Caching**: Sanitized file contents are cached to avoid repeated processing
- **Incremental Linting**: Files are only linted if changed since last check
- **Efficient Scanning**: Smart directory traversal skips excluded paths
## Manifest-Time Checking
By default, code quality rules run only when `php artisan rsx:check` is executed. However, certain critical rules can be configured to run during manifest builds to provide immediate feedback.
### When to Enable Manifest-Time Checking
**DO NOT** enable manifest-time checking unless you have explicit approval. Rules should only run at manifest-time if they:
1. Enforce critical framework conventions that would break the application
2. Need to provide immediate feedback before code execution
3. Have been specifically requested by framework maintainers
### Current Manifest-Time Rules
Only the following rules are approved for manifest-time execution:
- **BLADE-SCRIPT-01** (InlineScriptRule): Prevents inline JavaScript in Blade files (critical architecture violation)
- **JQHTML-INLINE-01** (JqhtmlInlineScriptRule): Prevents inline scripts/styles in Jqhtml template files (critical architecture violation)
All other rules should return `false` from `is_called_during_manifest_scan()`.
## Severity Levels
- **Critical**: Must fix immediately (e.g., fallback code)
- **High**: Should fix soon (e.g., mass assignment)
- **Medium**: Fix when convenient (e.g., naming conventions)
- **Low**: Minor issues (e.g., filename case)

View File

@@ -0,0 +1,553 @@
<?php
namespace App\RSpade\CodeQuality;
use App\RSpade\CodeQuality\CodeQuality_Violation;
use App\RSpade\CodeQuality\Support\CacheManager;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\ViolationCollector;
use App\RSpade\Core\Manifest\Manifest;
class CodeQualityChecker
{
protected static ?ViolationCollector $collector = null;
protected static ?CacheManager $cache_manager = null;
protected static array $rules = [];
protected static array $config = [];
public static function init(array $config = []): void
{
static::$collector = new ViolationCollector();
static::$cache_manager = new CacheManager();
static::$config = $config;
// Load all rules via auto-discovery
static::load_rules();
// Clean up old NPM bundle files on initialization
static::_cleanup_old_npm_bundles();
}
/**
* Clean up old NPM bundle files
* NPM bundles are cached based on package-lock.json + npm array + CWD
* Old bundles from different cache keys should be removed
*/
protected static function _cleanup_old_npm_bundles(): void
{
$bundle_dir = storage_path('rsx-build/bundles');
// Skip if directory doesn't exist yet
if (!is_dir($bundle_dir)) {
return;
}
// Find all npm_*.js files
$npm_bundles = glob($bundle_dir . '/npm_*.js');
if (empty($npm_bundles)) {
return;
}
// Keep the most recent 5 npm bundle files per bundle name
// Group by bundle name (npm_<bundlename>_<hash>.js)
$bundles_by_name = [];
foreach ($npm_bundles as $file) {
$filename = basename($file);
// Extract bundle name from npm_<bundlename>_<hash>.js
if (preg_match('/^npm_([^_]+)_[a-f0-9]{32}\.js$/', $filename, $matches)) {
$bundle_name = $matches[1];
if (!isset($bundles_by_name[$bundle_name])) {
$bundles_by_name[$bundle_name] = [];
}
$bundles_by_name[$bundle_name][] = [
'file' => $file,
'mtime' => filemtime($file)
];
}
}
// For each bundle name, keep only the 5 most recent files
foreach ($bundles_by_name as $bundle_name => $files) {
// Sort by modification time, newest first
usort($files, function($a, $b) {
return $b['mtime'] - $a['mtime'];
});
// Delete all but the most recent 5
$to_keep = 5;
for ($i = $to_keep; $i < count($files); $i++) {
@unlink($files[$i]['file']);
}
}
}
/**
* Load all rules via shared discovery logic
*/
protected static function load_rules(): void
{
// Check if we should exclude manifest-time rules (e.g., when running from rsx:check)
$exclude_manifest_time_rules = static::$config['exclude_manifest_time_rules'] ?? false;
// Use shared rule discovery that doesn't require manifest
static::$rules = Support\RuleDiscovery::discover_rules(
static::$collector,
static::$config,
false, // Get all rules, not just manifest scan ones
$exclude_manifest_time_rules // Exclude manifest-time rules if requested
);
}
/**
* Check a single file
*/
public static function check_file(string $file_path): void
{
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Check if file is in any excluded directory
foreach ($excluded_dirs as $excluded_dir) {
if (str_contains($file_path, '/' . $excluded_dir . '/')) {
return;
}
}
// Skip CodeQuality infrastructure files, but allow checking Rules directory
// This enables meta rules to check other rules for code quality violations
if (str_contains($file_path, '/app/RSpade/CodeQuality/') &&
!str_contains($file_path, '/app/RSpade/CodeQuality/Rules/')) {
return;
}
// Get file extension
$extension = pathinfo($file_path, PATHINFO_EXTENSION);
// Check for syntax errors first
if ($extension === 'php') {
if (static::lint_php_file($file_path)) {
// Syntax error found, don't run other checks
return;
}
} elseif (in_array($extension, ['js', 'jsx', 'ts', 'tsx'])) {
if (static::lint_javascript_file($file_path)) {
// Syntax error found, don't run other checks
return;
}
} elseif ($extension === 'json') {
if (static::lint_json_file($file_path)) {
// Syntax error found, don't run other checks
return;
}
}
// Get cached sanitized file if available
$cached_data = static::$cache_manager->get_sanitized_file($file_path);
if ($cached_data === null) {
// Sanitize the file
$sanitized_data = FileSanitizer::sanitize($file_path);
// Cache the sanitized data
static::$cache_manager->set_sanitized_file($file_path, $sanitized_data);
} else {
$sanitized_data = $cached_data;
}
// Get metadata from manifest if available
try {
$metadata = Manifest::get_file($file_path) ?? [];
} catch (\Exception $e) {
$metadata = [];
}
// Check if this is a Console Command file
$is_console_command = str_contains($file_path, '/app/Console/Commands/');
// Run each rule on the file
foreach (static::$rules as $rule) {
// If this is a Console Command, only run rules that support them
if ($is_console_command && !$rule->supports_console_commands()) {
continue;
}
// Check if this rule applies to this file type
$applies = false;
foreach ($rule->get_file_patterns() as $pattern) {
if (static::matches_pattern($file_path, $pattern)) {
$applies = true;
break;
}
}
if (!$applies) {
continue;
}
// Check for rule-specific exception comment in original file content
$rule_id = $rule->get_id();
$exception_pattern = '@' . $rule_id . '-EXCEPTION';
$original_content = file_get_contents($file_path);
if (str_contains($original_content, $exception_pattern)) {
// Skip this rule for this file
continue;
}
// Run the rule
$rule->check($file_path, $sanitized_data['content'], $metadata);
}
}
/**
* Check multiple files
*/
public static function check_files(array $file_paths): void
{
// First run special directory-level checks for rules that need them
foreach (static::$rules as $rule) {
// Check for special check_root method (RootFilesRule)
if (method_exists($rule, 'check_root')) {
$rule->check_root();
}
// Check for special check_rsx method (RsxTestFilesRule)
if (method_exists($rule, 'check_rsx')) {
$rule->check_rsx();
}
// Check for special check_required_models method (RequiredModelsRule)
if (method_exists($rule, 'check_required_models')) {
$rule->check_required_models();
}
// Check for special check_rsx_commands method (RsxCommandsDeprecatedRule)
if (method_exists($rule, 'check_rsx_commands')) {
$rule->check_rsx_commands();
}
// Check for special check_commands method (CommandOrganizationRule)
if (method_exists($rule, 'check_commands')) {
$rule->check_commands();
}
}
// Then check individual files
foreach ($file_paths as $file_path) {
static::check_file($file_path);
}
}
/**
* Check all files in a directory
*/
public static function check_directory(string $directory, bool $recursive = true): void
{
// First run special directory-level checks for rules that need them
foreach (static::$rules as $rule) {
// Check for special check_root method (RootFilesRule)
if (method_exists($rule, 'check_root')) {
$rule->check_root();
}
// Check for special check_rsx method (RsxTestFilesRule)
if (method_exists($rule, 'check_rsx')) {
$rule->check_rsx();
}
// Check for special check_required_models method (RequiredModelsRule)
if (method_exists($rule, 'check_required_models')) {
$rule->check_required_models();
}
}
// Get all files - let rules filter by extension
$files = [];
if ($recursive) {
// Use RecursiveIteratorIterator for recursive scanning
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
} else {
// Non-recursive - just scan immediate directory
$items = glob($directory . '/*');
$files = array_filter($items, 'is_file');
}
foreach ($files as $file) {
static::check_file($file);
}
}
/**
* Get the violation collector
*/
public static function get_collector(): ViolationCollector
{
return static::$collector;
}
/**
* Get all violations
*/
public static function get_violations(): array
{
return static::$collector->get_violations_as_arrays();
}
/**
* Clear cache
*/
public static function clear_cache(): void
{
static::$cache_manager->clear();
}
/**
* Check if a file path matches a pattern
*/
protected static function matches_pattern(string $file_path, string $pattern): bool
{
// Simple glob matching for file patterns like *.php, *.js
if (strpos($pattern, '*') === 0) {
// Pattern like *.php - check file extension
$extension = substr($pattern, 1); // Remove the *
return str_ends_with($file_path, $extension);
}
// For more complex patterns, use fnmatch if available
if (function_exists('fnmatch')) {
return fnmatch($pattern, basename($file_path));
}
// Fallback to simple string matching
return str_contains($file_path, $pattern);
}
/**
* Lint PHP file (from monolith line 536)
* Returns true if syntax error found
*/
protected static function lint_php_file(string $file_path): bool
{
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Check if file is in any excluded directory
foreach ($excluded_dirs as $excluded_dir) {
if (str_contains($file_path, '/' . $excluded_dir . '/')) {
return false;
}
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return false;
}
// Create cache directory for lint flags
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/php-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/php-lint-passed';
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
// Generate flag file path (no .php extension to avoid IDE detection)
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$relative_path = str_replace($base_path . '/', '', $file_path);
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
// Check if lint was already passed
if (file_exists($flag_path)) {
$source_mtime = filemtime($file_path);
$flag_mtime = filemtime($flag_path);
if ($flag_mtime >= $source_mtime) {
// File hasn't changed since last successful lint
return false; // No errors
}
}
// Run PHP lint check
$command = sprintf('php -l %s 2>&1', escapeshellarg($file_path));
$output = shell_exec($command);
// Check if there's a syntax error
if (!str_contains($output, 'No syntax errors detected')) {
// Delete flag file if it exists (file now has errors)
if (file_exists($flag_path)) {
unlink($flag_path);
}
// Just capture the error as-is
static::$collector->add(
new CodeQuality_Violation(
'PHP-SYNTAX',
$file_path,
0,
trim($output),
'critical',
null,
'Fix the PHP syntax error before running other checks.'
)
);
return true; // Error found
}
// Create flag file to indicate successful lint
touch($flag_path);
return false; // No errors
}
/**
* Lint JavaScript file (from monolith line 602)
* Returns true if syntax error found
*/
protected static function lint_javascript_file(string $file_path): bool
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return false;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return false;
}
// Skip VS Code extension directory
if (str_contains($file_path, '/resource/vscode_extension/')) {
return false;
}
// Create cache directory for lint flags
$cache_dir = function_exists('storage_path') ? storage_path('rsx-tmp/cache/js-lint-passed') : '/var/www/html/storage/rsx-tmp/cache/js-lint-passed';
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
// Generate flag file path (no .js extension to avoid IDE detection)
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$relative_path = str_replace($base_path . '/', '', $file_path);
$flag_path = $cache_dir . '/' . str_replace('/', '_', $relative_path) . '.lintpass';
// Check if lint was already passed
if (file_exists($flag_path)) {
$source_mtime = filemtime($file_path);
$flag_mtime = filemtime($flag_path);
if ($flag_mtime >= $source_mtime) {
// File hasn't changed since last successful lint
return false; // No errors
}
}
// Run JavaScript syntax check using Node.js
$linter_path = $base_path . '/bin/js-linter.js';
if (!file_exists($linter_path)) {
// Linter script not found, skip linting
return false;
}
$command = sprintf('node %s %s 2>&1', escapeshellarg($linter_path), escapeshellarg($file_path));
$output = shell_exec($command);
// Check if there's a syntax error
if ($output && trim($output) !== '') {
// Delete flag file if it exists (file now has errors)
if (file_exists($flag_path)) {
unlink($flag_path);
}
// Parse error message for line number if available
$line_number = 0;
if (preg_match('/Line (\d+)/', $output, $matches)) {
$line_number = (int)$matches[1];
}
static::$collector->add(
new CodeQuality_Violation(
'JS-SYNTAX',
$file_path,
$line_number,
trim($output),
'critical',
null,
'Fix the JavaScript syntax error before running other checks.'
)
);
return true; // Error found
}
// Create flag file to indicate successful lint
touch($flag_path);
return false; // No errors
}
/**
* Lint JSON file (from monolith line 684)
* Returns true if syntax error found
*/
protected static function lint_json_file(string $file_path): bool
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return false;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return false;
}
// Skip VS Code extension directory
if (str_contains($file_path, '/resource/vscode_extension/')) {
return false;
}
$content = file_get_contents($file_path);
// Try to decode the JSON
json_decode($content);
// Check for JSON errors
if (json_last_error() !== JSON_ERROR_NONE) {
$error_message = json_last_error_msg();
// Try to find line number for common errors
$line_number = 0;
if (str_contains($error_message, 'Syntax error')) {
// Count lines up to the error position if possible
$lines = explode("\n", $content);
$line_number = count($lines); // Default to last line
}
static::$collector->add(
new CodeQuality_Violation(
'JSON-SYNTAX',
$file_path,
$line_number,
"JSON parse error: {$error_message}",
'critical',
null,
'Fix the JSON syntax error. Common issues: missing commas, trailing commas, unquoted keys.'
)
);
return true; // Error found
}
return false; // No errors
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\RSpade\CodeQuality;
#[Instantiatable]
class CodeQuality_Violation
{
public function __construct(
public readonly string $rule_id,
public readonly string $file_path,
public readonly int $line_number,
public readonly string $message,
public readonly string $severity,
public readonly ?string $code_snippet = null,
public readonly ?string $suggestion = null
) {}
public function to_array(): array
{
// Return in format expected by InspectCommand
return [
'file' => $this->file_path,
'line' => $this->line_number,
'type' => $this->rule_id,
'message' => $this->message,
'resolution' => $this->suggestion,
'code' => $this->code_snippet,
'severity' => $this->severity,
];
}
public function get_severity_weight(): int
{
return match($this->severity) {
'critical' => 4,
'high' => 3,
'medium' => 2,
'low' => 1,
'convention' => 0,
default => 2,
};
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace App\RSpade\CodeQuality\Parsers;
/**
* Lightweight SCSS context parser for code quality rules
*
* PURPOSE: This is NOT a full SCSS compiler/parser. It only tracks:
* - Selector nesting and building full selector paths
* - Property declarations within each selector context
* - Pseudo-state detection (:hover, :focus, :active)
*
* DESIGN: Simple state machine that builds selector context by tracking braces
* and nesting. Designed for code quality rules that need to understand what
* properties are set in hover/focus states vs base states.
*
* USAGE:
* $contexts = ScssContextParser::parse_contexts($scss_content);
* foreach ($contexts as $context) {
* if (ScssContextParser::is_in_hover_context($context['selector'])) {
* // Check properties...
* }
* }
*
* FUTURE: Can be extended to track @media queries, mixins, or other SCSS
* features as needed by new rules. Each code quality rule documents what
* parsing features it requires.
*/
class ScssContextParser
{
/**
* Parse SCSS content into selector contexts with their properties
*
* @param string $scss Raw SCSS content
* @return array Array of contexts, each with:
* - 'line': Line number where selector starts
* - 'selector': Full selector path (e.g., '.nav-link:hover')
* - 'properties': Associative array of property => value
* - 'is_hover': Boolean if selector contains :hover
* - 'is_focus': Boolean if selector contains :focus
* - 'is_active': Boolean if selector contains :active
*/
public static function parse_contexts(string $scss): array
{
$lines = explode("\n", $scss);
$contexts = [];
$selector_stack = [];
$current_context = null;
$brace_depth = 0;
$in_comment = false;
for ($i = 0; $i < count($lines); $i++) {
$line = $lines[$i];
$line_num = $i + 1;
$trimmed = trim($line);
// Skip empty lines
if (empty($trimmed)) {
continue;
}
// Handle multi-line comments
if (str_contains($line, '/*')) {
$in_comment = true;
}
if ($in_comment) {
if (str_contains($line, '*/')) {
$in_comment = false;
}
continue;
}
// Skip single-line comments
if (str_starts_with($trimmed, '//')) {
continue;
}
// Remove inline comments for processing
$clean_line = preg_replace('/\/\/.*$/', '', $line);
$clean_line = preg_replace('/\/\*.*?\*\//', '', $clean_line);
$trimmed_clean = trim($clean_line);
// Count braces before processing
$open_braces = substr_count($clean_line, '{');
$close_braces = substr_count($clean_line, '}');
// Handle closing braces - pop from selector stack
for ($j = 0; $j < $close_braces; $j++) {
if (!empty($selector_stack)) {
array_pop($selector_stack);
}
$brace_depth--;
// Save current context when closing its block
if ($current_context && $brace_depth < $current_context['depth']) {
$contexts[] = $current_context;
$current_context = null;
}
}
// Check if this line starts a new selector block
if ($open_braces > 0 && !empty($trimmed_clean)) {
// Extract the selector part (before the {)
$selector_part = trim(str_replace('{', '', $trimmed_clean));
// Skip @keyframes, @media, @import etc
if (str_starts_with($selector_part, '@')) {
$brace_depth += $open_braces;
continue;
}
// Build full selector path
$full_selector = self::build_selector_path($selector_stack, $selector_part);
// Push to stack for nested selectors
$selector_stack[] = $selector_part;
$brace_depth += $open_braces;
// Create new context
$current_context = [
'line' => $line_num,
'selector' => $full_selector,
'properties' => [],
'depth' => $brace_depth,
'is_hover' => str_contains($full_selector, ':hover'),
'is_focus' => str_contains($full_selector, ':focus'),
'is_active' => str_contains($full_selector, ':active')
];
} elseif ($open_braces > 0) {
// Opening brace without selector (continuation from previous line)
$brace_depth += $open_braces;
}
// Parse property declarations within current context
if ($current_context && $brace_depth === $current_context['depth']) {
if (preg_match('/^\s*([a-z-]+)\s*:\s*(.+?);?\s*$/i', $trimmed_clean, $matches)) {
$property = $matches[1];
$value = trim($matches[2], '; ');
$current_context['properties'][$property] = $value;
}
}
}
// Save any remaining context
if ($current_context) {
$contexts[] = $current_context;
}
return $contexts;
}
/**
* Build full selector path from selector stack
* Handles SCSS & parent reference properly
*/
private static function build_selector_path(array $stack, string $current): string
{
if (empty($stack)) {
return $current;
}
$parent = implode(' ', $stack);
// Handle & parent reference
if (str_starts_with($current, '&')) {
// Replace & with the immediate parent (last item in stack)
$immediate_parent = end($stack);
$current = str_replace('&', '', $current);
// Remove last item and rebuild
$stack_without_last = array_slice($stack, 0, -1);
if (empty($stack_without_last)) {
return $immediate_parent . $current;
}
return implode(' ', $stack_without_last) . ' ' . $immediate_parent . $current;
}
// Handle nested selectors without &
return $parent . ' ' . $current;
}
/**
* Check if a selector represents a hover/focus/active state
*/
public static function is_in_hover_context(string $selector): bool
{
return str_contains($selector, ':hover') ||
str_contains($selector, ':focus') ||
str_contains($selector, ':active');
}
/**
* Get the base selector without pseudo-states
* Example: '.btn:hover' => '.btn'
*/
public static function get_base_selector(string $selector): string
{
return preg_replace('/:(hover|focus|active|visited|disabled)/', '', $selector);
}
/**
* Compare properties between two contexts to find differences
* Useful for detecting redundant declarations or actual changes
*/
public static function compare_properties(array $base_props, array $state_props): array
{
$differences = [
'added' => [],
'changed' => [],
'same' => [],
'removed' => []
];
foreach ($state_props as $prop => $value) {
if (!isset($base_props[$prop])) {
$differences['added'][$prop] = $value;
} elseif ($base_props[$prop] !== $value) {
$differences['changed'][$prop] = [
'from' => $base_props[$prop],
'to' => $value
];
} else {
$differences['same'][$prop] = $value;
}
}
foreach ($base_props as $prop => $value) {
if (!isset($state_props[$prop])) {
$differences['removed'][$prop] = $value;
}
}
return $differences;
}
/**
* Check if a property is position/size related
* These are typically prohibited in hover states
*/
public static function is_position_property(string $property): bool
{
$position_properties = [
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'top', 'right', 'bottom', 'left',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'font-size', 'line-height', 'letter-spacing', 'word-spacing'
];
return in_array($property, $position_properties);
}
/**
* Check if a property is visual-only (safe for hover)
* These don't affect layout or position
*/
public static function is_visual_only_property(string $property): bool
{
$visual_properties = [
'color', 'background-color', 'background', 'background-image',
'opacity', 'visibility',
'border-color', 'outline', 'outline-color',
'text-decoration', 'text-decoration-color',
'box-shadow', 'text-shadow',
'filter', 'backdrop-filter',
'cursor'
];
// Check for exact match or if property starts with one of these
foreach ($visual_properties as $visual_prop) {
if ($property === $visual_prop || str_starts_with($property, $visual_prop . '-')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class BladeExtends_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-EXTENDS-01';
}
public function get_name(): string
{
return 'Blade @extends Syntax Check';
}
public function get_description(): string
{
return "Detects incorrect @extends('rsx:: usage - should use @rsx_extends instead";
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check blade files for incorrect @extends('rsx:: usage
* The correct syntax is @rsx_extends('layout_name'), not @extends('rsx::...)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in rsx/ directory
if (!str_contains($file_path, '/rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for @extends('rsx:: pattern (with single or double quotes)
if (preg_match('/@extends\s*\(\s*[\'"]rsx::([^\'"]+)[\'"]/', $line, $matches)) {
$layout_reference = $matches[1] ?? '';
// Build suggestion
$suggestion = "The @extends directive with 'rsx::' namespace is incorrect.\n";
$suggestion .= "Use the @rsx_extends directive instead:\n";
$suggestion .= " Replace: @extends('rsx::{$layout_reference}')\n";
$suggestion .= " With: @rsx_extends('{$layout_reference}')\n\n";
$suggestion .= "The @rsx_extends directive uses the RSX ID system to locate layouts ";
$suggestion .= "by their RSX ID rather than file paths.";
$this->add_violation(
$file_path,
$line_number,
"Incorrect use of @extends('rsx::...'). Use @rsx_extends instead.",
trim($line),
$suggestion,
'high'
);
}
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Enforces path-agnostic class references in Blade templates by preventing direct FQCN usage
*
* Blade templates should reference RSX classes by simple name only, not by FQCN.
* Direct references like \Rsx\Models\User_Model or \App\RSpade\Core\Session\Session::method()
* are not allowed - use User_Model or Session instead.
*
* Note: use statements for Rsx\ classes ARE allowed in PHP blocks within Blade files,
* though they are unnecessary due to the autoloader.
*/
class BladeFqcnUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-RSX-FQCN-01';
}
public function get_name(): string
{
return 'Blade RSX FQCN Usage Validator';
}
public function get_description(): string
{
return 'Prevents direct FQCN references to Rsx classes in Blade templates';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'high';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Pattern to match \Rsx\... FQCN references
// Looks for \Rsx\ followed by class path components
// Must be preceded and followed by non-alphanumeric characters (except \ for namespace)
$pattern = '/(?<![a-zA-Z0-9])\\\\Rsx\\\\[a-zA-Z0-9_\\\\]+/';
if (!preg_match_all($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
return;
}
// Get manifest data once for lookups
$manifest = Manifest::get_all();
foreach ($matches[0] as $match) {
$fqcn = $match[0];
$offset = $match[1];
// Clean up the FQCN - remove leading backslash and normalize
$clean_fqcn = ltrim($fqcn, '\\');
// Extract simple class name from FQCN
$parts = explode('\\', $clean_fqcn);
$simple_name = end($parts);
// Check if this FQCN actually exists in the manifest
// This prevents false positives for non-existent classes
$class_exists = false;
try {
// Try to find the class by simple name
$class_metadata = Manifest::php_get_metadata_by_class($simple_name);
// Verify the FQCN matches
if ($class_metadata && isset($class_metadata['fqcn'])) {
$manifest_fqcn = $class_metadata['fqcn'];
// Compare without leading backslash
if (ltrim($manifest_fqcn, '\\') === $clean_fqcn) {
$class_exists = true;
}
}
} catch (\RuntimeException $e) {
// Class not found in manifest - not a real class reference
continue;
}
// Only report violation if the class actually exists
if (!$class_exists) {
continue;
}
// Calculate line number from offset
$line = 1;
for ($i = 0; $i < $offset; $i++) {
if ($contents[$i] === "\n") {
$line++;
}
}
// Extract the line containing the violation for context
$lines = explode("\n", $contents);
$code_snippet = '';
if ($line > 0 && $line <= count($lines)) {
$code_snippet = trim($lines[$line - 1]);
}
$message = "Direct FQCN reference '{$fqcn}' in Blade template is not allowed. RSX classes are path-agnostic and should be referenced by simple name only.";
$suggestion = "Replace '{$fqcn}' with '{$simple_name}'. The autoloader will automatically resolve the class.";
$this->add_violation(
$file_path,
$line,
$message,
$code_snippet,
$suggestion,
'high'
);
}
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class InlineScript_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-SCRIPT-01';
}
public function get_name(): string
{
return 'Blade Inline Script Check';
}
public function get_description(): string
{
return 'Enforces no inline JavaScript in blade views - all JavaScript must be in separate ES6 class files';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* This rule should run during manifest scan to provide immediate feedback
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* inline scripts in Blade files violate critical framework architecture patterns.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update to extract inline script violations
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip layouts (they can have script tags for loading external scripts)
if (str_contains($file_path, 'layout') || str_contains($file_path, 'Layout')) {
return null;
}
$lines = explode("\n", $contents);
$violations = [];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for <script> tags
if (preg_match('/<script\b[^>]*>(?!.*src=)/i', $line)) {
$violations[] = [
'type' => 'inline_script',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need to find first violation
}
// Check for jQuery ready patterns
if (preg_match('/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line) ||
preg_match('/\$\s*\(\s*function\s*\(/', $line) ||
preg_match('/jQuery\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line)) {
$violations[] = [
'type' => 'jquery_ready',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need to find first violation
}
}
if (!empty($violations)) {
return ['inline_script_violations' => $violations];
}
return null;
}
/**
* Check blade file for inline script violations stored in metadata
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip layouts
if (str_contains($file_path, 'layout') || str_contains($file_path, 'Layout')) {
return;
}
// Check for violations in code quality metadata
if (isset($metadata['code_quality_metadata']['BLADE-SCRIPT-01']['inline_script_violations'])) {
$violations = $metadata['code_quality_metadata']['BLADE-SCRIPT-01']['inline_script_violations'];
// Throw on first violation
foreach ($violations as $violation) {
$rsx_id = $this->extract_rsx_id($contents);
if ($violation['type'] === 'inline_script') {
$error_message = "Code Quality Violation (BLADE-SCRIPT-01) - Inline Script in Blade View\n\n";
$error_message .= "CRITICAL: Inline <script> tags are not allowed in blade views\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$violation['line']}\n";
$error_message .= "Code: {$violation['code']}\n\n";
$error_message .= $this->get_detailed_remediation($file_path, $rsx_id);
} else {
$error_message = "Code Quality Violation (BLADE-SCRIPT-01) - jQuery Ready Pattern in Blade View\n\n";
$error_message .= "CRITICAL: jQuery ready patterns are not allowed - use ES6 class with on_app_ready()\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$violation['line']}\n";
$error_message .= "Code: {$violation['code']}\n\n";
$error_message .= $this->get_detailed_remediation($file_path, $rsx_id);
}
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$violation['line']
);
}
}
}
/**
* Extract RSX ID from blade file content
*/
private function extract_rsx_id(string $contents): ?string
{
if (preg_match('/@rsx_id\s*\(\s*[\'"]([^\'"]+)[\'"]/', $contents, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get detailed remediation instructions
*/
private function get_detailed_remediation(string $file_path, ?string $rsx_id): string
{
// Determine the JS filename and class name
$path_parts = pathinfo($file_path);
$blade_name = str_replace('.blade', '', $path_parts['filename']);
// If we have an RSX ID, use it as the class name
if ($rsx_id) {
$class_name = str_replace('.', '_', $rsx_id);
$js_filename = strtolower(str_replace('_', '_', $class_name)) . '.js';
} else {
// Fallback to blade filename
$class_name = ucfirst($blade_name);
$js_filename = $blade_name . '.js';
}
$js_path = dirname($file_path) . '/' . $js_filename;
return "FRAMEWORK CONVENTION: JavaScript for blade views must be in separate ES6 class files.
REQUIRED STEPS:
1. Create a JavaScript file: {$js_path}
2. Name the ES6 class exactly: {$class_name}
3. Use the on_app_ready() lifecycle method for initialization
4. Check for the view's presence using the RSX ID class selector
EXAMPLE IMPLEMENTATION for {$js_filename}:
/**
* JavaScript for {$class_name} view
*/
class {$class_name} {
/**
* Initialize when app is ready
* This method is automatically called by RSX framework
* No manual registration is required
*/
static on_app_ready() {
// CRITICAL: Only initialize if we're on this specific view
// The RSX framework adds the RSX ID as a class to the body tag
if (!\$('.{$class_name}').exists()) {
return;
}
console.log('{$class_name} view initialized');
// Add your view-specific JavaScript here
// Example: bind events, initialize components, etc.
// If you need to bind events to dynamically loaded components:
\$('#load-component-btn').on('click', function() {
\$('#dynamic-component-target').component('User_Card', {
data: {
name: 'Dynamic User',
email: 'dynamic@example.com'
}
});
});
}
}
KEY CONVENTIONS:
- Class name MUST match the RSX ID (with dots replaced by underscores)
- MUST use static on_app_ready() method (called automatically by framework)
- MUST check for view presence using \$('.{$class_name}').exists()
- MUST return early if view is not present (prevents code from running on wrong pages)
- NO manual registration needed - framework auto-discovers and calls on_app_ready()
- NO $(document).ready() or jQuery ready patterns allowed
- NO window.onload or DOMContentLoaded events allowed
WHY THIS MATTERS:
- Separation of concerns: HTML structure separate from behavior
- Framework integration: Automatic lifecycle management
- Performance: JavaScript only loads and runs where needed
- Maintainability: Clear file organization and naming conventions
- LLM-friendly: Predictable patterns that AI assistants can follow
The RSX framework will automatically:
1. Discover your ES6 class via the manifest
2. Call on_app_ready() after all components are initialized
3. Ensure proper execution order through lifecycle phases";
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Blade;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class UnbalancedTags_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'BLADE-TAGS-01';
}
public function get_name(): string
{
return 'Balanced HTML Tags in Control Flow';
}
public function get_description(): string
{
return 'Enforces that HTML tags opened within control flow blocks (@if/@foreach/etc) must be closed within the same block';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
public function is_called_during_manifest_scan(): bool
{
return false;
}
/**
* Check blade file for unbalanced HTML tags in control flow blocks
*
* Simpler rule: If a block's first non-whitespace content is an opening HTML tag,
* that tag must be closed before the block ends.
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Parse the blade file into control flow blocks
$blocks = $this->parse_control_flow_blocks($contents);
foreach ($blocks as $block) {
$violation = $this->check_first_tag_closed($block['content'], $block['type']);
if ($violation !== null) {
$error_message = "Code Quality Violation (BLADE-TAGS-01) - Unbalanced HTML Tags in Control Flow\n\n";
$error_message .= "CRITICAL: Opening tag in control flow block must be closed within the same block\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Block: {$block['type']} at line {$block['start_line']}\n";
$error_message .= "First tag: <{$violation['tag']}> at line {$violation['opening_line']}\n";
$error_message .= "Problem: {$violation['message']}\n\n";
$error_message .= $this->get_remediation($block['type']);
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$block['start_line'] + $violation['opening_line'] - 1
);
}
}
}
/**
* Parse control flow blocks from blade content
*/
private function parse_control_flow_blocks(string $contents): array
{
$lines = explode("\n", $contents);
$blocks = [];
$stack = [];
// Control flow patterns
$start_patterns = [
'@if' => '@endif',
'@elseif' => '@endif',
'@else' => '@endif',
'@foreach' => '@endforeach',
'@for' => '@endfor',
'@while' => '@endwhile',
'@unless' => '@endunless',
'<?php if' => '<?php endif',
'<?php foreach' => '<?php endforeach',
'<?php for' => '<?php endfor',
'<?php while' => '<?php endwhile',
];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Check for block start
foreach ($start_patterns as $start => $end) {
if (preg_match('/^\s*' . preg_quote($start, '/') . '\b/', $line)) {
// Special handling for @else and @elseif - close previous block first
if (in_array($start, ['@elseif', '@else']) && !empty($stack)) {
$prev_block = array_pop($stack);
$prev_block['end_line'] = $line_number - 1;
$blocks[] = $prev_block;
}
$stack[] = [
'type' => $start,
'start_line' => $line_number,
'content' => '',
'lines' => [],
];
continue 2;
}
}
// Check for block end
foreach ($start_patterns as $start => $end) {
if (preg_match('/^\s*' . preg_quote($end, '/') . '\b/', $line)) {
if (!empty($stack)) {
$block = array_pop($stack);
$block['end_line'] = $line_number;
$blocks[] = $block;
}
continue 2;
}
}
// Add line to current block
if (!empty($stack)) {
$stack[count($stack) - 1]['content'] .= $line . "\n";
$stack[count($stack) - 1]['lines'][$line_number] = $line;
}
}
return $blocks;
}
/**
* Check if the first HTML tag in a block is closed within that block
* Returns null if OK, or array with violation details if not
*/
private function check_first_tag_closed(string $content, string $block_type): ?array
{
$lines = explode("\n", $content);
// Void elements that don't need closing tags
$void_elements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr'];
// Find the first non-whitespace line with an opening HTML tag
$first_tag = null;
$first_tag_line = null;
foreach ($lines as $line_num => $line) {
$trimmed = trim($line);
// Skip empty lines and blade comments
if (empty($trimmed) || str_starts_with($trimmed, '{{--')) {
continue;
}
// Look for opening HTML tag at start of line (after whitespace)
if (preg_match('/^<([a-zA-Z][a-zA-Z0-9_-]*)[^>]*>/i', $trimmed, $match)) {
$tag_name = strtolower($match[1]);
// Skip void elements
if (in_array($tag_name, $void_elements)) {
continue;
}
// Skip self-closing tags
if (str_ends_with(trim($match[0]), '/>')) {
continue;
}
// Found first opening tag
$first_tag = $tag_name;
$first_tag_line = $line_num + 1;
break;
}
// If we hit non-tag content first, no violation possible
if (!empty($trimmed)) {
return null;
}
}
// If no opening tag found, nothing to check
if ($first_tag === null) {
return null;
}
// Now check if the closing tag appears in the block
$closing_tag = "</{$first_tag}>";
foreach ($lines as $line) {
if (stripos($line, $closing_tag) !== false) {
// Found the closing tag - all good
return null;
}
}
// Closing tag not found in block
return [
'tag' => $first_tag,
'opening_line' => $first_tag_line,
'message' => "Closing tag </{$first_tag}> not found before end of {$block_type} block",
];
}
/**
* Get detailed remediation instructions
*/
private function get_remediation(string $block_type): string
{
return "FRAMEWORK CONVENTION: HTML tags must be balanced within control flow blocks.
This antipattern is commonly called \"Split Tag Conditionals\" or \"Broken HTML Nesting\".
PROBLEM: Control flow statements split what should be a single lexical unit (a complete HTML element).
WHY THIS IS FORBIDDEN:
1. Parser confusion - Syntax highlighters and formatters can't parse it correctly
2. Maintainability nightmare - Hard to see complete element structure
3. Error-prone - Easy to create invalid HTML when modifying
4. Mental overhead - Reader must mentally reconstruct tags across control flow
CORRECT ALTERNATIVES:
Option 1: Inline ternary (best for simple cases)
────────────────────────────────────────────────
@if(\$active)
<div class=\"card active\">Content</div>
@else
<div class=\"card inactive\">Content</div>
@endif
Option 2: Pre-compute classes (best for complex attributes)
────────────────────────────────────────────────────────────
@php
\$card_class = \$active ? 'active' : 'inactive';
@endphp
<div class=\"card {{ \$card_class }}\">
Content
</div>
Option 3: Duplicate complete tags (best when tags differ significantly)
────────────────────────────────────────────────────────────────────────
@if(\$show_form)
<form action=\"/submit\" method=\"POST\">
<input type=\"text\" name=\"value\">
<button>Submit</button>
</form>
@else
<div class=\"info-message\">
Form is disabled
</div>
@endif
NEVER DO THIS:
────────────────
@if(\$required)
<select required>
@else
<select>
@endif
<option>Value</option>
</select>
CORRECT:
───────────
@if(\$required)
<select required>
<option>Value</option>
</select>
@else
<select>
<option>Value</option>
</select>
@endif
PRINCIPLE: Control flow should NEVER split lexical units. HTML elements are atomic structures - keep them whole.";
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\RSpade\CodeQuality\Rules;
use App\RSpade\CodeQuality\CodeQuality_Violation;
use App\RSpade\CodeQuality\Support\ViolationCollector;
#[Instantiatable]
abstract class CodeQualityRule_Abstract
{
protected ViolationCollector $collector;
protected array $config = [];
protected bool $enabled = true;
public function __construct(ViolationCollector $collector, array $config = [])
{
$this->collector = $collector;
$this->config = $config;
$this->enabled = $config['enabled'] ?? true;
}
/**
* Get the unique rule identifier (e.g., 'PHP-SC-001')
*/
abstract public function get_id(): string;
/**
* Get human-readable rule name
*/
abstract public function get_name(): string;
/**
* Get rule description
*/
abstract public function get_description(): string;
/**
* Get file patterns this rule applies to (e.g., ['*.php', '*.js'])
*/
abstract public function get_file_patterns(): array;
/**
* Check a file for violations
*
* @param string $file_path Absolute path to file
* @param string $contents File contents (may be sanitized)
* @param array $metadata Additional metadata from manifest
*/
abstract public function check(string $file_path, string $contents, array $metadata = []): void;
/**
* Whether this rule supports checking Console Commands
*
* Rules that return true here will be given Console Command files to check
* when using default paths or when a specific Console Command file is provided.
* Rules supporting Console Commands MUST NOT rely on manifest metadata as
* Console Commands are not indexed in the manifest.
*
* @return bool
*/
public function supports_console_commands(): bool
{
return false;
}
/**
* Check if this rule is enabled
*/
public function is_enabled(): bool
{
return $this->enabled;
}
/**
* Check if this rule should be called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*
* @return bool
*/
public function is_called_during_manifest_scan(): bool
{
return false;
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'medium';
}
/**
* Add a violation
*/
protected function add_violation(
string $file_path,
int $line_number,
string $message,
?string $code_snippet = null,
?string $suggestion = null,
?string $severity = null
): void {
$violation = new CodeQuality_Violation(
rule_id: $this->get_id(),
file_path: $file_path,
line_number: $line_number,
message: $message,
severity: $severity ?? $this->get_default_severity(),
code_snippet: $code_snippet,
suggestion: $suggestion
);
$this->collector->add($violation);
}
/**
* Extract code snippet around a line
*/
protected function get_code_snippet(array $lines, int $line_index, int $context = 2): string
{
$start = max(0, $line_index - $context);
$end = min(count($lines) - 1, $line_index + $context);
$snippet = [];
for ($i = $start; $i <= $end; $i++) {
$prefix = $i === $line_index ? '>>> ' : ' ';
$snippet[] = $prefix . ($i + 1) . ': ' . $lines[$i];
}
return implode("\n", $snippet);
}
/**
* Get all PHP files in the Console Commands directory
*
* This helper allows rules to optionally include Console Commands in their checks
* without requiring these files to be in the manifest. Rules using this helper
* MUST NOT rely on manifest metadata since Console Commands are not indexed.
*
* @return array Array of absolute file paths to PHP files in app/Console/Commands
*/
protected static function get_console_command_files(): array
{
$commands_dir = base_path('app/Console/Commands');
if (!is_dir($commands_dir)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($commands_dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* Check if a file is a Console Command
*
* @param string $file_path The file path to check
* @return bool True if the file is in app/Console/Commands
*/
protected static function is_console_command(string $file_path): bool
{
$commands_dir = base_path('app/Console/Commands');
$normalized_path = str_replace('\\', '/', $file_path);
$normalized_commands_dir = str_replace('\\', '/', $commands_dir);
return str_starts_with($normalized_path, $normalized_commands_dir);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class AbstractClassNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'ABSTRACT-CLASS-01';
}
public function get_name(): string
{
return 'Abstract Class Naming Convention';
}
public function get_description(): string
{
return 'Ensures abstract classes follow RSX naming conventions with _Abstract suffix';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js'];
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip -temp files
if (str_contains($file_path, '-temp.')) {
return;
}
// Only check files in ./rsx and ./app/RSpade
$is_rsx = str_contains($file_path, '/rsx/');
$is_rspade = str_contains($file_path, '/app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if ($is_rspade && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
$filename = basename($file_path);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
// Handle PHP files
if ($extension === 'php') {
$this->check_php_abstract_class($file_path, $contents, $metadata);
}
// Handle JavaScript files
elseif ($extension === 'js') {
$this->check_js_abstract_class($file_path, $contents, $metadata);
}
}
private function check_php_abstract_class(string $file_path, string $contents, array $metadata): void
{
// Check if this is an abstract class
if (!preg_match('/^\s*abstract\s+class\s+(\w+)/m', $contents, $matches)) {
return; // Not an abstract class
}
$class_name = $matches[1];
$filename = basename($file_path);
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// Check class name ends with _Abstract
if (!str_ends_with($class_name, '_Abstract')) {
$this->add_violation(
$file_path,
0,
"Abstract class '$class_name' must end with '_Abstract'",
"abstract class $class_name",
$this->get_abstract_class_remediation($class_name, $filename, true),
'high'
);
return; // Don't check filename if class name is wrong
}
// Check filename ends with _abstract.php or Abstract.php
if (!str_ends_with($filename, '_abstract.php') && !str_ends_with($filename, 'Abstract.php')) {
$this->add_violation(
$file_path,
0,
"Abstract class file '$filename' must end with '_abstract.php' or 'Abstract.php'",
"File: $filename",
$this->get_abstract_filename_remediation($class_name, $filename),
'medium'
);
}
}
private function check_js_abstract_class(string $file_path, string $contents, array $metadata): void
{
// Check for classes ending with _Abstract
if (!isset($metadata['class'])) {
return;
}
$class_name = $metadata['class'];
// Only check classes that end with _Abstract
if (!str_ends_with($class_name, '_Abstract')) {
return;
}
$filename = basename($file_path);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
// Check filename ends with _abstract.js or Abstract.js
$valid_endings = ["_abstract.$extension", "Abstract.$extension"];
$valid = false;
foreach ($valid_endings as $ending) {
if (str_ends_with($filename, $ending)) {
$valid = true;
break;
}
}
if (!$valid) {
$this->add_violation(
$file_path,
0,
"Abstract class file '$filename' must end with '_abstract.$extension' or 'Abstract.$extension'",
"File: $filename",
$this->get_abstract_filename_remediation($class_name, $filename),
'medium'
);
}
}
private function get_abstract_class_remediation(string $class_name, string $filename, bool $is_php): string
{
// Determine suggested class name based on current naming
$suggested_class = $this->suggest_abstract_class_name($class_name);
$is_rsx = str_contains($filename, '_');
return "ABSTRACT CLASS NAMING CONVENTION
Abstract classes must follow RSX naming patterns:
- Class name must end with '_Abstract'
- Filename must end with '_abstract.php' or 'Abstract.php'
CURRENT CLASS: $class_name
SUGGESTED CLASS: $suggested_class
SUGGESTED FIXES:
1. Rename class from '$class_name' to '$suggested_class'
2. Rename file to match:
- For /rsx: Use lowercase convention (e.g., " . strtolower(str_replace('_', '_', $suggested_class)) . ".php)
- For /app/RSpade: Use exact match (e.g., $suggested_class.php)
3. Update all references to this class
WHY THIS MATTERS:
- Makes abstract classes immediately identifiable
- Enables framework introspection and auto-discovery
- Maintains consistent naming patterns across the codebase";
}
private function get_abstract_filename_remediation(string $class_name, string $filename): string
{
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$lowercase_suggestion = strtolower(str_replace('_', '_', $class_name)) . ".$extension";
$exact_suggestion = $class_name . ".$extension";
return "ABSTRACT CLASS FILENAME CONVENTION
Abstract class files must end with '_abstract.$extension' or 'Abstract.$extension'
CURRENT FILE: $filename
CLASS NAME: $class_name
VALID FILENAME PATTERNS:
- Lowercase with underscore: *_abstract.$extension
- Uppercase suffix: *Abstract.$extension
SUGGESTED FILENAMES:
- For /rsx: $lowercase_suggestion
- For /app/RSpade: $exact_suggestion
Note: Both patterns are valid in either directory.";
}
private function suggest_abstract_class_name(string $current_name): string
{
// If class name contains 'Abstract' but not at the end
if (stripos($current_name, 'Abstract') !== false && !str_ends_with($current_name, '_Abstract') && !str_ends_with($current_name, 'Abstract')) {
// Remove 'Abstract' from wherever it is
$without_abstract = preg_replace('/Abstract/i', '', $current_name);
$without_abstract = trim($without_abstract, '_');
// If the class has underscores, add _Abstract
if (str_contains($without_abstract, '_')) {
return $without_abstract . '_Abstract';
} else {
// For non-underscore classes, add Abstract at the end
return $without_abstract . 'Abstract';
}
}
// If class doesn't contain 'Abstract' at all
if (str_contains($current_name, '_')) {
return $current_name . '_Abstract';
} else {
return $current_name . 'Abstract';
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class Assignment_Comparison_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'COMMON-ASSIGN-01';
}
public function get_name(): string
{
return 'Assignment vs Comparison Check';
}
public function get_description(): string
{
return 'Detects assignment operator (=) used where comparison (== or ===) expected';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.jsx', '*.ts', '*.tsx'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check if file is in allowed directories using same logic as rsx:check command
*/
private function is_file_in_allowed_directories(string $file_path): bool
{
// Get scan directories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Special case: Allow Console Command files
if (str_starts_with($relative_path, 'app/Console/Commands/')) {
return true;
}
// Check against configured scan directories
foreach ($scan_directories as $scan_path) {
// Skip specific file entries in scan_directories
if (str_contains($scan_path, '.')) {
// This is a specific file, check exact match
if ($relative_path === $scan_path) {
return true;
}
} else {
// This is a directory, check if file is within it
if (str_starts_with($relative_path, rtrim($scan_path, '/') . '/') ||
rtrim($relative_path, '/') === rtrim($scan_path, '/')) {
return true;
}
}
}
return false;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Use the same directory filtering logic as rsx:check command
if (!$this->is_file_in_allowed_directories($file_path)) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Determine file type
$is_php = str_ends_with($file_path, '.php');
$is_js = str_ends_with($file_path, '.js') || str_ends_with($file_path, '.jsx') ||
str_ends_with($file_path, '.ts') || str_ends_with($file_path, '.tsx');
if (!$is_php && !$is_js) {
return;
}
// Use original file content directly (no sanitization)
$original_content = file_get_contents($file_path);
$lines = explode("\n", $original_content);
// Process each line individually
// Note: We're letting multi-line conditions slide for simplicity - this catches 99% of violations
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip empty lines
if (trim($line) === '') {
continue;
}
// Skip lines that are just comments (start with //)
$trimmed = trim($line);
if (str_starts_with($trimmed, '//')) {
continue;
}
// Check for single = in if statement condition (must have complete condition on same line)
if (preg_match('/\bif\s*\(([^)]+)\)/', $line, $match)) {
$condition = $match[1];
// Skip if there's a // comment before the if statement
if (preg_match('/\/\/.*\bif\s*\(/', $line)) {
continue;
}
// Remove quoted strings to avoid false positives from regex patterns
// This prevents flagging preg_match('/pattern=value/', $var)
$condition_no_quotes = preg_replace('/([\'"])[^\\1]*?\\1/', '""', $condition);
// Check for single = that's not part of ==, ===, !=, !==, <=, >=
// Must have non-equals char before and after the single =
if (preg_match('/[^=!<>]\s*=\s*[^=]/', $condition_no_quotes)) {
// Double-check it's not a comparison operator
if (!preg_match('/(?:==|===|!=|!==|<=|>=)/', $condition_no_quotes)) {
$this->add_violation(
$file_path,
$line_number,
"Assignment operator (=) used in if statement where comparison expected.",
trim($line),
"Assignment and truthiness checks must be on separate lines. " .
"The pattern 'if (\$var = function())' is not acceptable in RSpade code. " .
"Split into two lines: '\$var = function(); if (\$var) { ... }'. " .
"If you meant comparison, use == or === instead of =. " .
"This rule enforces code clarity by separating assignment from condition evaluation.",
'high'
);
}
}
}
// Skip while statements - assignment in while is acceptable
// The pattern while ($var = function()) is allowed for iteration
// Check for single = in for loop condition (middle part)
if (preg_match('/\bfor\s*\(([^;]*);([^;]*);([^)]*)\)/', $line, $match)) {
$condition = $match[2]; // The middle part is the condition
// Skip if there's a // comment before the for statement
if (preg_match('/\/\/.*\bfor\s*\(/', $line)) {
continue;
}
// Remove quoted strings to avoid false positives
$condition_no_quotes = preg_replace('/([\'"])[^\\1]*?\\1/', '""', $condition);
if (trim($condition_no_quotes) && preg_match('/[^=!<>]\s*=\s*[^=]/', $condition_no_quotes)) {
if (!preg_match('/(?:==|===|!=|!==|<=|>=)/', $condition_no_quotes)) {
$this->add_violation(
$file_path,
$line_number,
"Assignment operator (=) used in for loop condition where comparison expected.",
trim($line),
"Use == or === for comparison in the for loop condition (second part). " .
"Assignment in the condition will always evaluate to the assigned value, not perform a comparison. " .
"Example: change 'for(i=0; i=5; i++)' to 'for(i=0; i==5; i++)'.",
'high'
);
}
}
}
// Skip do...while statements - assignment in while is acceptable
// The pattern } while ($var = function()) is allowed for iteration
// PHP-specific: Check for single = in elseif statement
if ($is_php && preg_match('/\belseif\s*\(([^)]+)\)/', $line, $match)) {
$condition = $match[1];
// Skip if there's a // comment before the elseif
if (preg_match('/\/\/.*\belseif\s*\(/', $line)) {
continue;
}
// Remove quoted strings to avoid false positives
$condition_no_quotes = preg_replace('/([\'"])[^\\1]*?\\1/', '""', $condition);
if (preg_match('/[^=!<>]\s*=\s*[^=]/', $condition_no_quotes)) {
if (!preg_match('/(?:==|===|!=|!==|<=|>=)/', $condition_no_quotes)) {
$this->add_violation(
$file_path,
$line_number,
"Assignment operator (=) used in elseif statement where comparison expected.",
trim($line),
"Use == or === for comparison in elseif statements. " .
"Assignment in an elseif condition will execute the assignment and evaluate the assigned value, not perform a comparison. " .
"Example: change 'elseif(x = 5)' to 'elseif(x == 5)' or 'elseif(x === 5)'.",
'high'
);
}
}
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class CommandOrganization_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CMD-ORG-01';
}
public function get_name(): string
{
return 'Command Organization Check';
}
public function get_description(): string
{
return 'Ensures commands are organized in proper subdirectories based on signature';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Special method for checking commands organization
* Only runs when using default paths
*/
public function check_commands(): void
{
// Only run when using default paths
if (!($this->config['using_default_paths'] ?? false)) {
return;
}
$commands_dir = base_path('app/Console/Commands');
if (!is_dir($commands_dir)) {
return;
}
// Get files in root Commands directory only (not subdirectories)
$files = glob($commands_dir . '/*.php');
foreach ($files as $file) {
$filename = basename($file);
$content = file_get_contents($file);
// Check for rsx: commands
if (preg_match('/\$signature\s*=\s*[\'"]rsx:/i', $content)) {
$this->add_violation(
$file,
0,
"RSX command found in root Commands directory",
"File: $filename contains 'rsx:' command signature",
"COMMAND ORGANIZATION VIOLATION\n\n" .
"This command has signature starting with 'rsx:' but is not in the Rsx subdirectory.\n\n" .
"REQUIRED ACTION:\n" .
"Move this file to app/RSpade/Commands/Rsx/ or one of its subdirectories.\n\n" .
"WHY THIS MATTERS:\n" .
"- All RSX framework commands must be organized in the Rsx subdirectory\n" .
"- This keeps framework commands separate from application commands\n" .
"- Maintains consistent command organization\n" .
"- Makes it easier to find related commands\n\n" .
"STEPS TO FIX:\n" .
"1. Move the file: mv $file " . base_path('app/RSpade/Commands/Rsx/') . "\n" .
"2. Update the namespace to include \\Rsx\n" .
"3. Run 'composer dump-autoload' to update class mappings",
'high'
);
}
// Check for migrate: commands
if (preg_match('/\$signature\s*=\s*[\'"]migrate:/i', $content)) {
$this->add_violation(
$file,
0,
"Migration command found in root Commands directory",
"File: $filename contains 'migrate:' command signature",
"COMMAND ORGANIZATION VIOLATION\n\n" .
"This command has signature starting with 'migrate:' but is not in the Migrate subdirectory.\n\n" .
"REQUIRED ACTION:\n" .
"Move this file to app/Console/Commands/Migrate/ or one of its subdirectories.\n\n" .
"WHY THIS MATTERS:\n" .
"- All migration-related commands must be organized in the Migrate subdirectory\n" .
"- This groups database migration tools together\n" .
"- Maintains consistent command organization\n" .
"- Makes it easier to find migration-related commands\n\n" .
"STEPS TO FIX:\n" .
"1. Move the file: mv $file " . $commands_dir . "/Migrate/\n" .
"2. Update the namespace to include \\Migrate\n" .
"3. Run 'composer dump-autoload' to update class mappings",
'high'
);
}
}
}
/**
* Standard check method - not used for this rule
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule uses check_commands instead
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class DuplicateCaseFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
private static bool $checked = false;
public function get_id(): string
{
return 'FILE-CASE-DUP-01';
}
public function get_name(): string
{
return 'Duplicate Files with Different Case';
}
public function get_description(): string
{
return 'Detects files with same name but different case - breaks Windows/macOS compatibility';
}
public function get_file_patterns(): array
{
return ['*']; // Check all files
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Whether this rule is called during manifest scan
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* duplicate case files break Windows/macOS compatibility and must be caught immediately.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Check for duplicate files with different case
* This checks the entire manifest once rather than per-file
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only run this check once for the entire manifest
if (self::$checked) {
return;
}
self::$checked = true;
// Get all files from the manifest
$all_files = Manifest::get_all();
// Build map of directories to files
$files_by_dir = [];
foreach ($all_files as $file => $file_metadata) {
// Skip vendor and node_modules
if (str_contains($file, '/vendor/') || str_contains($file, '/node_modules/')) {
continue;
}
$dir = dirname($file);
$filename = basename($file);
if (!isset($files_by_dir[$dir])) {
$files_by_dir[$dir] = [];
}
$files_by_dir[$dir][] = $filename;
}
// Check each directory for case-insensitive duplicates
foreach ($files_by_dir as $dir => $filenames) {
$seen_lowercase = [];
foreach ($filenames as $filename) {
$filename_lower = strtolower($filename);
if (isset($seen_lowercase[$filename_lower])) {
$existing = $seen_lowercase[$filename_lower];
// Only report if actual case is different
if ($existing !== $filename) {
$file1 = $dir . '/' . $existing;
$file2 = $dir . '/' . $filename;
// Count uppercase characters to determine which file to favor
$uppercase_count1 = preg_match_all('/[A-Z]/', $existing);
$uppercase_count2 = preg_match_all('/[A-Z]/', $filename);
// Favor the file with more uppercase characters
$preferred_file = ($uppercase_count2 > $uppercase_count1) ? $file2 : $file1;
$error_message = "Code Quality Violation (FILE-CASE-DUP-01) - Duplicate files with different case\n\n";
$error_message .= "CRITICAL: This BREAKS Windows/macOS compatibility!\n\n";
$error_message .= "Directory: {$dir}\n";
$error_message .= "File 1: {$existing}\n";
$error_message .= "File 2: {$filename}\n\n";
$error_message .= "Resolution:\n";
$error_message .= "1. Run: diff -u '{$file1}' '{$file2}' to compare\n";
$error_message .= "2. Determine which file has the correct functionality\n";
$error_message .= "3. Remove the incorrect/older file\n";
$error_message .= "4. Update all references to use the correct filename\n";
$error_message .= "5. Test thoroughly - IDE may have been using wrong file!";
// Throw immediately on first duplicate found
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($preferred_file),
1
);
}
} else {
$seen_lowercase[$filename_lower] = $filename;
}
}
}
// Reset for next manifest build
self::$checked = false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class FilenameCase_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-CASE-01';
}
public function get_name(): string
{
return 'Filename Case Check';
}
public function get_description(): string
{
return 'All files in rsx/ should be lowercase';
}
public function get_file_patterns(): array
{
return ['*.*']; // All files
}
public function get_default_severity(): string
{
return 'low';
}
/**
* Check if a filename contains uppercase characters (for RSX files) - from line 1706
* Excludes vendor and resource directories, and .md files
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and resource directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/resource/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in rsx/ directory
if (!str_contains($file_path, '/rsx/')) {
return;
}
// Get just the filename without the directory path
$filename = basename($file_path);
// Skip .md files
if (str_ends_with($filename, '.md')) {
return;
}
// Check if filename contains uppercase characters
if (preg_match('/[A-Z]/', $filename)) {
// Convert to lowercase for suggestion
$suggested = strtolower($filename);
$this->add_violation(
$file_path,
0,
"Filename '{$filename}' contains uppercase characters. All files in rsx/ should be lowercase.",
$filename,
"Rename file to '{$suggested}'. Remember: class names should still use First_Letter_Uppercase format.",
'low'
);
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class FilenameEnhanced_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-ENHANCED-01';
}
public function get_name(): string
{
return 'Enhanced Filename Check';
}
public function get_description(): string
{
return "Detects 'enhanced' in filenames which indicates parallel implementation - a critical code smell";
}
public function get_file_patterns(): array
{
return ['*.*']; // All files
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check if a filename contains 'enhanced' which indicates parallel implementation (from line 1741)
* This is a critical code smell indicating technical debt
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get just the filename without the directory path
$filename = basename($file_path);
// Check if filename contains 'enhanced' (case insensitive)
if (stripos($filename, 'enhanced') !== false) {
// Check if file has exemption marker
$content = file_get_contents($file_path);
if (str_contains($content, '//@enhanced_filename_allowed')) {
return; // File is explicitly exempted
}
// Extract the base name without 'enhanced' for suggestion
$suggested = preg_replace('/[_\-]?enhanced[_\-]?/i', '', $filename);
$resolution = "CRITICAL: Filename contains 'enhanced' which indicates parallel implementation of existing functionality.\n\n";
$resolution .= "INVESTIGATION REQUIRED - Follow this procedure thoroughly (may take several hours):\n\n";
$resolution .= "1. IDENTIFY THE RELATIONSHIP:\n";
$resolution .= " - Search for similarly named files without 'enhanced' (e.g., if this is 'UserEnhanced.php', look for 'User.php')\n";
$resolution .= " - Use grep/search to find: '{$suggested}' and variations\n";
$resolution .= " - Check git history to understand when and why the 'enhanced' version was created\n\n";
$resolution .= "2. ANALYZE THE INVOCATION:\n";
$resolution .= " - Search the codebase for references to '{$filename}' to see how it's used\n";
$resolution .= " - Look for conditional logic, switches, or configuration that chooses between versions\n";
$resolution .= " - Identify any fallback patterns where one version is tried before another\n\n";
$resolution .= "3. COMPARE FUNCTIONALITY:\n";
$resolution .= " - Diff the enhanced file against the original (if found)\n";
$resolution .= " - Identify what improvements were made in the enhanced version\n";
$resolution .= " - Check if the original has any functionality missing from enhanced\n";
$resolution .= " - Document the differences and determine completeness of enhanced version\n\n";
$resolution .= "4. DETERMINE MIGRATION PATH:\n";
$resolution .= " If enhanced version is a complete replacement:\n";
$resolution .= " - Verify enhanced version has ALL necessary functionality from original\n";
$resolution .= " - Remove the old/original file\n";
$resolution .= " - Rename enhanced file to the original name (e.g., 'UserEnhanced.php' → 'User.php')\n";
$resolution .= " - Update all references to use the single, renamed file\n";
$resolution .= " - Remove any conditional/switching/fallback code\n\n";
$resolution .= " If versions serve different purposes:\n";
$resolution .= " - Rename the enhanced file to better describe its specific purpose\n";
$resolution .= " - Update documentation to clarify the distinct roles\n\n";
$resolution .= "5. IF UNCLEAR:\n";
$resolution .= " Present findings to the user including:\n";
$resolution .= " - List of similar files found\n";
$resolution .= " - How each version is invoked\n";
$resolution .= " - Key differences between versions\n";
$resolution .= " - Request guidance on consolidation strategy\n\n";
$resolution .= "IMPORTANT: The goal is to eliminate dual implementations. Having both 'document_parser.php' and 'enhanced_document_parser.php' creates:\n";
$resolution .= "- Confusion about which to use\n";
$resolution .= "- Maintenance burden keeping both in sync\n";
$resolution .= "- Potential bugs from inconsistent behavior\n";
$resolution .= "- Technical debt that compounds over time\n\n";
$resolution .= "To mark legitimate use (extremely rare): Add '//@enhanced_filename_allowed' comment to the file.\n";
$resolution .= "This should only be done when 'enhanced' is genuinely part of the domain language, not indicating an upgrade.";
$this->add_violation(
$file_path,
0,
"CRITICAL: Filename contains 'enhanced' which indicates parallel implementation of existing functionality.",
$filename,
$resolution,
'critical'
);
}
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class FilenameSpaces_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-SPACE-01';
}
public function get_name(): string
{
return 'Filename Spaces Check';
}
public function get_description(): string
{
return 'Filenames and directory paths must not contain spaces';
}
public function get_file_patterns(): array
{
// Return multiple common patterns to match all files
// The checker uses these with matches_pattern which does simple extension checking
return ['*.php', '*.js', '*.css', '*.scss', '*.blade.php', '*.json', '*.xml', '*.md', '*.txt', '*.yml', '*.yaml', '*.sql', '*.sh', '*.jqhtml', '*.ts', '*.tsx', '*.jsx', '*'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Whether this rule is called during manifest scan
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* spaces in filenames break shell commands and framework operations.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update - throws immediately on violation
* This is more efficient than checking later
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return null;
}
// Get relative path from absolute
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Check for spaces in the entire path
if (str_contains($relative_path, ' ')) {
// Get just the filename
$filename = basename($relative_path);
$dirname = dirname($relative_path);
// Build error message
if (str_contains($filename, ' ')) {
$suggested = str_replace(' ', '_', $filename);
$error_message = "Code Quality Violation (FILE-SPACE-01) - Filename '{$filename}' contains spaces\n\n";
$error_message .= "File: {$relative_path}\n\n";
$error_message .= "Spaces in filenames break shell commands, URLs, and tooling.\n\n";
$error_message .= "Resolution:\nRename file to '{$suggested}' (replace spaces with underscores or remove them).";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
1
);
}
if (str_contains($dirname, ' ')) {
// Find which directory has the space
$path_parts = explode('/', $dirname);
$problematic_dirs = array_filter($path_parts, fn($part) => str_contains($part, ' '));
$problematic_str = implode(', ', $problematic_dirs);
$error_message = "Code Quality Violation (FILE-SPACE-01) - Directory path contains spaces\n\n";
$error_message .= "File: {$relative_path}\n\n";
$error_message .= "Directories with spaces: {$problematic_str}\n\n";
$error_message .= "Resolution:\nRename directories to remove spaces. This is critical as spaces in paths break shell commands, git operations, and various build tools.";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
1
);
}
}
// No metadata needed - we throw on violation
return null;
}
/**
* Check if a filename or any directory in its path contains spaces
* This method is now just a fallback - on_manifest_file_update handles the real work
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Check for spaces in the entire path
if (str_contains($file_path, ' ')) {
// Get just the filename
$filename = basename($file_path);
$dirname = dirname($file_path);
// Determine if the space is in the filename or directory
if (str_contains($filename, ' ')) {
$suggested = str_replace(' ', '_', $filename);
$this->add_violation(
$file_path,
0,
"Filename '{$filename}' contains spaces which will cause issues with shell commands, URLs, and tooling.",
$filename,
"Rename file to '{$suggested}' (replace spaces with underscores or remove them).",
'critical'
);
}
if (str_contains($dirname, ' ')) {
// Find which directory has the space
$path_parts = explode('/', $dirname);
$problematic_dirs = array_filter($path_parts, fn($part) => str_contains($part, ' '));
$problematic_str = implode(', ', $problematic_dirs);
$this->add_violation(
$file_path,
0,
"Directory path contains spaces in: {$problematic_str}",
$dirname,
"Rename directories to remove spaces. This is critical as spaces in paths break shell commands, git operations, and various build tools.",
'critical'
);
}
}
}
}

View File

@@ -0,0 +1,358 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*
* @ROUTE-EXISTS-01-EXCEPTION - This file generates code templates with placeholder route names
*/
namespace App\RSpade\CodeQuality\Rules\Common;
use Illuminate\Support\Facades\Route;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Dispatch\Dispatcher;
use App\RSpade\Core\Manifest\Manifest;
/**
* HardcodedInternalUrlRule - Detect hardcoded internal URLs in href attributes
*
* This rule scans .blade.php and .jqhtml files for href attributes containing
* hardcoded internal routes (URLs starting with "/" without file extensions)
* and suggests using the proper route generation methods instead.
*/
class HardcodedInternalUrl_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique identifier for this rule
*
* @return string
*/
public function get_id(): string
{
return 'URL-HARDCODE-01';
}
/**
* Get the default severity level
*
* @return string One of: critical, high, medium, low, convention
*/
public function get_default_severity(): string
{
return 'medium';
}
/**
* Get the file patterns this rule applies to
*
* @return array
*/
public function get_file_patterns(): array
{
return ['*.blade.php', '*.jqhtml'];
}
/**
* Get the display name for this rule
*
* @return string
*/
public function get_name(): string
{
return 'Hardcoded Internal URL Detection';
}
/**
* Get the description of what this rule checks
*
* @return string
*/
public function get_description(): string
{
return 'Detects hardcoded internal URLs in href attributes and suggests using route generation methods';
}
/**
* Check the file contents for violations
*
* @param string $file_path The path to the file being checked
* @param string $contents The contents of the file
* @param array $metadata Additional metadata about the file
* @return void
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Initialize manifest to ensure routes are available
try {
Manifest::init();
} catch (\Exception $e) {
// If manifest fails to initialize, we can't check routes
return;
}
$is_jqhtml = str_ends_with($file_path, '.jqhtml');
$lines = explode("\n", $contents);
foreach ($lines as $line_num => $line) {
// Find all href attributes in the line
// Match href="..." or href='...'
if (preg_match_all('/href\s*=\s*["\']([^"\']+)["\']/', $line, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[1] as $match) {
$url = $match[0];
$position = $match[1];
// Check if this is a likely internal route
if (!$this->_is_likely_internal_route($url)) {
continue;
}
// Extract base URL and query params
$url_parts = parse_url($url);
$base_url = $url_parts['path'] ?? '/';
$query_string = $url_parts['query'] ?? '';
// Try to resolve the URL to a route
$route_info = null;
try {
$route_info = Dispatcher::resolve_url_to_route($base_url, 'GET');
} catch (\Exception $e) {
// URL doesn't resolve to a known route
continue;
}
$suggested_code = '';
if ($route_info) {
// Found RSX route
$controller_class = $route_info['class'] ?? '';
$method_name = $route_info['method'] ?? '';
$route_params = $route_info['params'] ?? [];
// Parse query string params
$query_params = [];
if ($query_string) {
parse_str($query_string, $query_params);
}
// Merge all params (route params take precedence)
$all_params = array_merge($query_params, $route_params);
// Extract just the class name without namespace
$class_parts = explode('\\', $controller_class);
$class_name = end($class_parts);
// Generate the suggested replacement code
$suggested_code = $this->_generate_suggested_code(
$class_name,
$method_name,
$all_params,
$is_jqhtml
);
} else {
// Check if it's a Laravel route
$laravel_route = $this->_find_laravel_route($base_url);
if ($laravel_route) {
$suggested_code = $this->_generate_laravel_suggestion($laravel_route, $query_string, $is_jqhtml);
} else {
// No route found, skip
continue;
}
}
// Add violation
$this->add_violation(
$line_num + 1,
$position,
"Hardcoded internal URL detected: {$url}",
$line,
"Use route generation instead:\n{$suggested_code}"
);
}
}
}
}
/**
* Check if a URL is likely an internal route
*
* @param string $url
* @return bool
*/
protected function _is_likely_internal_route(string $url): bool
{
// Must start with /
if (!str_starts_with($url, '/')) {
return false;
}
// Skip absolute URLs (with protocol)
if (preg_match('#^//#', $url)) {
return false;
}
// Extract path before query string
$path = strtok($url, '?');
// Get the last segment of the path
$segments = explode('/', trim($path, '/'));
$last_segment = end($segments);
// If last segment has a dot (file extension), it's likely a file not a route
if ($last_segment && str_contains($last_segment, '.')) {
return false;
}
// Skip common static asset paths
$static_prefixes = ['/assets/', '/css/', '/js/', '/images/', '/img/', '/fonts/', '/storage/'];
foreach ($static_prefixes as $prefix) {
if (str_starts_with($path, $prefix)) {
return false;
}
}
return true;
}
/**
* Generate suggested replacement code
*
* @param string $class_name
* @param string $method_name
* @param array $params
* @param bool $is_jqhtml
* @return string
*/
protected function _generate_suggested_code(string $class_name, string $method_name, array $params, bool $is_jqhtml): string
{
if ($is_jqhtml) {
// JavaScript version for .jqhtml files using <%= %> syntax
if (empty($params)) {
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url() %>";
} else {
$params_json = json_encode($params, JSON_UNESCAPED_SLASHES);
return "<%= Rsx.Route('{$class_name}', '{$method_name}').url({$params_json}) %>";
}
} else {
// PHP version for .blade.php files
if (empty($params)) {
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url() }}";
} else {
$params_str = $this->_format_php_array($params);
return "{{ Rsx::Route('{$class_name}', '{$method_name}')->url({$params_str}) }}";
}
}
}
/**
* Format a PHP array for display
*
* @param array $params
* @return string
*/
protected function _format_php_array(array $params): string
{
$items = [];
foreach ($params as $key => $value) {
$key_str = var_export($key, true);
$value_str = var_export($value, true);
$items[] = "{$key_str} => {$value_str}";
}
return '[' . implode(', ', $items) . ']';
}
/**
* Find Laravel route by URL
*
* @param string $url
* @return string|null Route name if found
*/
protected function _find_laravel_route(string $url): ?string
{
// Get all Laravel routes
$routes = Route::getRoutes();
foreach ($routes as $route) {
// Check if URL matches this route's URI
if ($route->uri() === ltrim($url, '/')) {
// Get the route name if it has one
$name = $route->getName();
if ($name) {
return $name;
}
// No name, but route exists - return the URI for direct use
return $url;
}
}
return null;
}
/**
* Generate Laravel route suggestion
*
* @param string $route_name
* @param string $query_string
* @param bool $is_jqhtml
* @return string
*/
protected function _generate_laravel_suggestion(string $route_name, string $query_string, bool $is_jqhtml): string
{
// If route_name starts with /, it means no named route exists
if (str_starts_with($route_name, '/')) {
// Suggest adding a name to the route
$suggested_name = $this->_suggest_route_name($route_name);
if ($is_jqhtml) {
return "<%= '{$route_name}' %> <!-- Add name to route: ->name('{$suggested_name}'), then use route('{$suggested_name}') -->";
} else {
return "{{ route('{$suggested_name}') }}\n// First add ->name('{$suggested_name}') to the route definition in routes/web.php";
}
}
// Route has a name, use it
if ($is_jqhtml) {
// JavaScript version for .jqhtml files
if ($query_string) {
$query_params = [];
parse_str($query_string, $query_params);
$params_json = json_encode($query_params, JSON_UNESCAPED_SLASHES);
// Note: jqhtml would need a custom helper for Laravel routes
return "<%= route('{$route_name}', {$params_json}) %> <!-- Requires custom route() helper -->";
} else {
return "<%= route('{$route_name}') %> <!-- Requires custom route() helper -->";
}
} else {
// PHP version for .blade.php files
if ($query_string) {
$query_params = [];
parse_str($query_string, $query_params);
$params_str = $this->_format_php_array($query_params);
return "{{ route('{$route_name}', {$params_str}) }}";
} else {
return "{{ route('{$route_name}') }}";
}
}
}
/**
* Suggest a route name based on the URL path
*
* @param string $url
* @return string
*/
protected function _suggest_route_name(string $url): string
{
// Remove leading slash and convert to dot notation
$path = ltrim($url, '/');
// Convert path segments to route name
// /test-bundle-facade => test.bundle.facade
// /_idehelper => idehelper
$path = str_replace('_', '', $path); // Remove leading underscores
$path = str_replace('-', '.', $path); // Convert dashes to dots
$path = str_replace('/', '.', $path); // Convert slashes to dots
return $path ?: 'home';
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Ensures SCSS manifest module has lower priority than modules it depends on
*
* The SCSS module needs to run after Blade, JavaScript, and Jqhtml modules
* because it checks their output to determine if SCSS files should have an ID
* based on matching class selectors.
*/
class ManifestModulePriority_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'MANIFEST-PRIORITY-01';
}
public function get_name(): string
{
return 'Manifest Module Priority Order';
}
public function get_description(): string
{
return 'Ensures SCSS manifest module runs after modules it depends on';
}
public function get_file_patterns(): array
{
// We'll check specific module files
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check the specific manifest module files
$module_files = [
'app/RSpade/Modules/Scss_ManifestModule.php',
'app/RSpade/Modules/Blade_ManifestModule.php',
'app/RSpade/Modules/JavaScript_ManifestModule.php',
'app/RSpade/Integrations/Jqhtml/Jqhtml_ManifestModule.php'
];
$is_module_file = false;
foreach ($module_files as $module_file) {
if (str_ends_with($file_path, $module_file)) {
$is_module_file = true;
break;
}
}
if (!$is_module_file) {
return;
}
// Extract priority from this file
$current_priority = $this->extract_priority($contents);
if ($current_priority === null) {
return; // Can't find priority method
}
// If this is the SCSS module, check all others
if (str_ends_with($file_path, 'Scss_ManifestModule.php')) {
$this->check_scss_priority($file_path, $current_priority);
}
}
/**
* Extract the priority value from module contents
*/
private function extract_priority(string $contents): ?int
{
// Look for: public function priority(): int { return NUMBER; }
if (preg_match('/public\s+function\s+priority\s*\(\s*\)\s*:\s*int\s*\{[^}]*return\s+(\d+)\s*;/s', $contents, $matches)) {
return (int)$matches[1];
}
return null;
}
/**
* Check that SCSS priority is lower (higher number) than all dependencies
*/
private function check_scss_priority(string $scss_file_path, int $scss_priority): void
{
$base_path = base_path();
$dependencies = [
'Blade_ManifestModule' => $base_path . '/app/RSpade/Modules/Blade_ManifestModule.php',
'JavaScript_ManifestModule' => $base_path . '/app/RSpade/Modules/JavaScript_ManifestModule.php',
'Jqhtml_ManifestModule' => $base_path . '/app/RSpade/Integrations/Jqhtml/Jqhtml_ManifestModule.php'
];
$violations = [];
foreach ($dependencies as $name => $path) {
if (!file_exists($path)) {
continue; // Module might not exist (e.g., Jqhtml is optional)
}
$contents = file_get_contents($path);
$priority = $this->extract_priority($contents);
if ($priority === null) {
continue; // Can't find priority
}
// SCSS priority should be higher number (lower priority) than dependencies
if ($scss_priority <= $priority) {
$violations[] = " - {$name}: priority={$priority} (SCSS has {$scss_priority})";
}
}
if (!empty($violations)) {
$violation_list = implode("\n", $violations);
$this->add_violation(
$scss_file_path,
0,
"SCSS manifest module priority must be lower than modules it depends on",
null,
"The SCSS manifest module depends on output from Blade, JavaScript, and Jqhtml modules\n" .
"to determine which SCSS files should have an ID based on matching class selectors.\n\n" .
"Priority violations found:\n" .
$violation_list . "\n\n" .
"Fix: Change Scss_ManifestModule priority() to return a value higher than all dependencies.\n" .
"Remember: Higher number = lower priority (runs later).\n\n" .
"Example: If Blade=15, JavaScript=20, Jqhtml=25, then SCSS should be >25 (e.g., 100).",
'critical'
);
}
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Rule to detect .old. files that should not be committed
* Only runs during pre-commit checks
*/
class OldFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-OLD-01';
}
public function get_name(): string
{
return 'Old Files Detection';
}
public function get_description(): string
{
return 'Detects .old files that should not be committed';
}
public function get_file_patterns(): array
{
// Check all files
return ['*'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Only run this rule during pre-commit tests
*/
public function should_run(array $options = []): bool
{
return isset($options['pre-commit-tests']) && $options['pre-commit-tests'] === true;
}
/**
* Check for .old. or .*.old file naming patterns
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip directories outside our scan paths
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
$basename = basename($file_path);
// Check for .old. pattern (e.g., something.old.php)
if (preg_match('/\\.old\\.\\w+$/', $basename)) {
$this->add_violation(
$file_path,
1,
"File uses forbidden .old.(extension) naming pattern",
$basename,
"The .old.(extension) pattern is NOT ALLOWED. Files named like 'file.old.php' " .
"are still treated as .php files and included in bundles as live code.\n\n" .
"SOLUTIONS:\n" .
"1. Rename to '.php.old' or '.js.old' (extension at the end)\n" .
"2. Move to /archived/ directory outside scan paths\n" .
"3. Delete the file if no longer needed",
'critical'
);
}
// Check for .*.old pattern (e.g., something.php.old)
if (preg_match('/\\.\\w+\\.old$/', $basename)) {
$this->add_violation(
$file_path,
1,
"Old file detected - should not be committed",
$basename,
"Files ending in .old should not be committed to the repository.\n\n" .
"SOLUTIONS:\n" .
"1. Move to /archived/ directory outside scan paths\n" .
"2. Delete the file if no longer needed\n" .
"3. Use proper version control (git) to track file history",
'high'
);
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class PackageJson_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PKG-JSON-01';
}
public function get_name(): string
{
return 'Package.json devDependencies Check';
}
public function get_description(): string
{
return 'Ensures package.json files only use dependencies, not devDependencies';
}
public function get_file_patterns(): array
{
return ['package.json'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check package.json files for devDependencies
* RSpade standard: All packages should be in dependencies, not devDependencies
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check package.json files
if (basename($file_path) !== 'package.json') {
return;
}
// Skip node_modules
if (str_contains($file_path, '/node_modules/')) {
return;
}
// Parse JSON
$json = json_decode($contents, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Invalid JSON - skip check (other rules will catch this)
return;
}
// Check for devDependencies
if (isset($json['devDependencies']) && !empty($json['devDependencies'])) {
$dev_count = count($json['devDependencies']);
$packages = array_keys($json['devDependencies']);
$packages_list = implode(', ', array_slice($packages, 0, 5));
if ($dev_count > 5) {
$packages_list .= ', and ' . ($dev_count - 5) . ' more';
}
$this->add_violation(
$file_path,
0, // JSON files don't have meaningful line numbers for this check
"RSpade Standard Violation: package.json contains {$dev_count} devDependencies. " .
"In RSpade, all packages should be in 'dependencies' to ensure consistent installations. " .
"Found packages: {$packages_list}",
'"devDependencies": { ... }',
"Move all packages from 'devDependencies' to 'dependencies' and remove the 'devDependencies' key entirely. " .
"RSpade makes no distinction between dev and production packages - all software needed for the project should be installed.",
'high'
);
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* RedundantIndexActionRule - Checks for unnecessary 'index' action in Route calls
*
* This rule detects when Rsx::Route() or Rsx.Route() is called with 'index' as the
* second parameter, which is redundant since 'index' is the default value.
*
* Example violations:
* - Rsx::Route('Controller') // PHP
* - Rsx.Route('Controller', 'index') // JavaScript
*
* Correct usage:
* - Rsx::Route('Controller') // PHP
* - Rsx.Route('Controller') // JavaScript
*/
class RedundantIndexAction_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'ROUTE-INDEX-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Redundant index Action in Route';
}
/**
* Get rule description
*/
public function get_description(): string
{
return "Detects unnecessary 'index' as second parameter in Route calls since it's the default";
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.blade.php', '*.jqhtml'];
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Only run during rsx:check
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'low'; // This is a style/convention issue, not a functional problem
}
/**
* Check a file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if exception comment is present
if (strpos($contents, '@ROUTE-INDEX-01-EXCEPTION') !== false) {
return;
}
// Pattern to match Rsx::Route() or Rsx.Route() calls with 'index' as second parameter
// Matches both single and double quotes
$pattern = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"],\s*[\'"]index[\'"]\s*\)/';
if (preg_match_all($pattern, $contents, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
$controller = $matches[1][$index][0];
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Build remediation message
$is_php = str_ends_with($file_path, '.php') || str_ends_with($file_path, '.blade.php');
$operator = $is_php ? '::' : '.';
$correct_usage = "Rsx{$operator}Route('{$controller}')";
$remediation = "The 'index' action is the default value and should be omitted.\n\n";
$remediation .= "CURRENT (redundant):\n";
$remediation .= " {$full_match}\n\n";
$remediation .= "CORRECTED (cleaner):\n";
$remediation .= " {$correct_usage}\n\n";
$remediation .= "CONVENTION:\n";
$remediation .= "The second parameter of Rsx{$operator}Route() defaults to 'index'.\n";
$remediation .= "Only specify the action when it's NOT 'index'.\n\n";
$remediation .= "EXAMPLES:\n";
$remediation .= " Rsx{$operator}Route('User_Controller') // Goes to index action\n";
$remediation .= " Rsx{$operator}Route('User_Controller', 'edit') // Goes to edit action\n";
$remediation .= " Rsx{$operator}Route('User_Controller', 'show') // Goes to show action";
$this->add_violation(
$file_path,
$line_number,
"Redundant 'index' action in Route call",
$code_snippet,
$remediation,
'low'
);
}
}
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* Rule to detect incorrect 'resources' directory naming
* Should be 'resource' (singular) not 'resources' (plural)
*/
class ResourceDirectory_CodeQualityRule extends CodeQualityRule_Abstract
{
protected static $checked_directories = [];
public function get_id(): string
{
return 'DIR-RESOURCE-01';
}
public function get_name(): string
{
return 'Resource Directory Naming';
}
public function get_description(): string
{
return 'Enforces singular "resource" directory naming convention';
}
public function get_file_patterns(): array
{
// Check all files to extract directory paths
return ['*'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for 'resources' directory in file path
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip directories outside our scan paths
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip if already inside a 'resource' directory (contents are invisible to framework)
if (preg_match('#/resource/#', $file_path)) {
return;
}
// Check if path contains 'resources' directory
if (preg_match('#/resources/#', $file_path, $matches, PREG_OFFSET_CAPTURE)) {
// Extract the directory path up to and including 'resources'
$offset = $matches[0][1];
$dir_path = substr($file_path, 0, $offset + strlen('/resources'));
// Only report once per directory
if (isset(static::$checked_directories[$dir_path])) {
return;
}
static::$checked_directories[$dir_path] = true;
$this->add_violation(
$file_path,
1,
"Directory named 'resources' (plural) detected - should be 'resource' (singular)",
"Directory: {$dir_path}/",
"The directory name 'resources' is not allowed in RSX.\n\n" .
"USE 'resource' INSTEAD (singular, not plural).\n\n" .
"WHY THIS MATTERS:\n" .
"'resource' is a special directory that is IGNORED by:\n" .
"- The RSpade manifest system\n" .
"- The autoloader\n" .
"- Bundle generation\n" .
"- All Manifest.php functions\n\n" .
"PURPOSE OF resource/ DIRECTORY:\n" .
"Store special-purpose files that are referenced but not executed:\n" .
"- Raw source code (e.g., Bootstrap 5 source)\n" .
"- Supplemental utilities (e.g., Node.js applications)\n" .
"- Documentation files\n" .
"- Assets that should not be bundled\n\n" .
"ACTION REQUIRED:\n" .
"Rename the directory from 'resources' to 'resource'",
'high'
);
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class RootFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-ROOT-01';
}
public function get_name(): string
{
return 'Root Files Check';
}
public function get_description(): string
{
return 'Check for unauthorized files in project root - only whitelisted build configuration files should be in root';
}
public function get_file_patterns(): array
{
// This rule works differently - it checks root directory, not individual files
return [];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check for unauthorized PHP/JS files in project root (from line 1602)
* Only whitelisted build configuration files should be in root
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule needs special handling - it should be called once, not per file
// We'll handle this in the check_root() method instead
}
/**
* Special method to check root files - called once per run
*/
public function check_root(): void
{
$project_root = function_exists('base_path') ? base_path() : '/var/www/html';
$whitelist = function_exists('config') ? config('rsx.code_quality.root_whitelist', []) : [];
// Get all PHP and JS files in root (not subdirectories)
$files = glob($project_root . '/*.{php,js}', GLOB_BRACE);
foreach ($files as $file_path) {
$filename = basename($file_path);
// Skip if whitelisted
if (in_array($filename, $whitelist)) {
continue;
}
$this->add_violation(
$file_path,
0,
"Unauthorized file '{$filename}' found in project root. Only whitelisted build configuration files should exist in the root directory.",
null,
"This file appears to be a one-off test script that should be removed before commit. " .
"LLM agents often create test files in the root directory for testing specific features. " .
"These should be removed or moved to proper test directories. " .
"If this file is legitimately needed in the root, add '{$filename}' to the whitelist in config/rsx.php under 'code_quality.root_whitelist'.",
'medium'
);
}
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* RouteExistsRule - Validates that Rsx::Route() calls reference existing routes
*
* This rule checks both PHP and JavaScript files for Route() calls with literal
* string parameters and validates that the referenced controller and method
* combination actually exists as a route in the manifest.
*
* Example violations:
* - Rsx::Route('NonExistent_Controller')
* - Route('Some_Controller', 'missing_method')
*
* The rule only checks when both parameters are string literals, not variables.
*/
class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
{
/**
* Get the unique rule identifier
*/
public function get_id(): string
{
return 'ROUTE-EXISTS-01';
}
/**
* Get human-readable rule name
*/
public function get_name(): string
{
return 'Route Target Exists Validation';
}
/**
* Get rule description
*/
public function get_description(): string
{
return 'Validates that Rsx::Route() calls reference controller methods that actually exist as routes';
}
/**
* Get file patterns this rule applies to
*/
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.blade.php'];
}
/**
* Whether this rule is called during manifest scan
*
* IMPORTANT: This method should ALWAYS return false unless explicitly requested
* by the framework developer. Manifest-time checks are reserved for critical
* framework convention violations that need immediate developer attention.
*
* Rules executed during manifest scan will run on every file change in development,
* potentially impacting performance. Only enable this for rules that:
* - Enforce critical framework conventions that would break the application
* - Need to provide immediate feedback before code execution
* - Have been specifically requested to run at manifest-time by framework maintainers
*
* DEFAULT: Always return false unless you have explicit permission to do otherwise.
*/
public function is_called_during_manifest_scan(): bool
{
return false; // Only run during rsx:check, not during manifest build
}
/**
* Get default severity for this rule
*/
public function get_default_severity(): string
{
return 'high';
}
/**
* Check if a route exists using the same logic as Rsx::Route()
*/
private function route_exists(string $controller, string $method): bool
{
try {
// Use the same validation logic as Rsx::Route()
// If this doesn't throw an exception, the route exists
\App\RSpade\Core\Rsx::Route($controller, $method);
return true;
} catch (\Exception $e) {
return false;
}
}
/**
* Check a file for violations
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if exception comment is present
if (strpos($contents, '@ROUTE-EXISTS-01-EXCEPTION') !== false) {
return;
}
// Pattern to match Rsx::Route and Rsx.Route calls (NOT plain Route())
// Matches both single and double parameter versions:
// - Rsx::Route('Controller') // PHP, defaults to 'index'
// - Rsx::Route('Controller', 'method') // PHP
// - Rsx.Route('Controller') // JavaScript, defaults to 'index'
// - Rsx.Route('Controller', 'method') // JavaScript
// Pattern for two parameters
$pattern_two_params = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/';
// Pattern for single parameter (defaults to 'index')
$pattern_one_param = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/';
// First check two-parameter calls
if (preg_match_all($pattern_two_params, $contents, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
$controller = $matches[1][$index][0];
$method = $matches[2][$index][0];
// Skip if contains template variables like {$variable}
if (str_contains($controller, '{$') || str_contains($controller, '${') ||
str_contains($method, '{$') || str_contains($method, '${')) {
continue;
}
// Skip if method starts with '#' - indicates unimplemented route
if (str_starts_with($method, '#')) {
continue;
}
// Skip if this route exists
if ($this->route_exists($controller, $method)) {
continue;
}
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Build suggestion
$suggestion = $this->build_suggestion($controller, $method);
$this->add_violation(
$file_path,
$line_number,
"Route target does not exist: {$controller}::{$method}",
$code_snippet,
$suggestion,
'high'
);
}
}
// Then check single-parameter calls (avoiding overlap with two-parameter calls)
if (preg_match_all($pattern_one_param, $contents, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$full_match = $match[0];
$offset = $match[1];
// Skip if this is actually a two-parameter call (has a comma after the first param)
$after_match_pos = $offset + strlen($full_match);
$chars_after = substr($contents, $after_match_pos, 10);
if (preg_match('/^\s*,/', $chars_after)) {
continue; // This is a two-parameter call, already handled above
}
$controller = $matches[1][$index][0];
$method = 'index'; // Default to 'index'
// Skip if contains template variables like {$variable}
if (str_contains($controller, '{$') || str_contains($controller, '${')) {
continue;
}
// Skip if this route exists
if ($this->route_exists($controller, $method)) {
continue;
}
// Calculate line number
$line_number = substr_count(substr($contents, 0, $offset), "\n") + 1;
// Extract the line for snippet
$lines = explode("\n", $contents);
$code_snippet = isset($lines[$line_number - 1]) ? trim($lines[$line_number - 1]) : $full_match;
// Build suggestion
$suggestion = $this->build_suggestion($controller, $method);
$this->add_violation(
$file_path,
$line_number,
"Route target does not exist: {$controller}::{$method}",
$code_snippet,
$suggestion,
'high'
);
}
}
}
/**
* Build suggestion for fixing the violation
*/
private function build_suggestion(string $controller, string $method): string
{
$suggestions = [];
// Simple suggestion since we're using the same validation as Rsx::Route()
$suggestions[] = "Route target does not exist: {$controller}::{$method}";
$suggestions[] = "\nTo fix this issue:";
$suggestions[] = "1. Correct the controller/method names if they're typos";
$suggestions[] = "2. Implement the missing route if it's a new feature:";
$suggestions[] = " - Create the controller if it doesn't exist";
$suggestions[] = " - Add the method with a #[Route] attribute";
$suggestions[] = "3. Use '#' prefix for unimplemented routes (recommended):";
$suggestions[] = " - Use Rsx::Route('Controller', '#index') for unimplemented index methods";
$suggestions[] = " - Use Rsx::Route('Controller', '#method_name') for other unimplemented methods";
$suggestions[] = " - Routes with '#' prefix will generate '#' URLs and bypass this validation";
$suggestions[] = " - Example: Rsx::Route('Backend_Users_Controller', '#index')";
return implode("\n", $suggestions);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
/**
* Check that Route attributes don't use invalid {param} syntax
* RSX uses :param syntax instead of Laravel's {param} syntax
*/
class RouteSyntax_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'ROUTE-SYNTAX-01';
}
public function get_name(): string
{
return 'Route Pattern Syntax Check';
}
public function get_description(): string
{
return 'Ensures route patterns use :param syntax instead of {param}';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip files without public static methods
if (!isset($metadata['public_static_methods']) || !is_array($metadata['public_static_methods'])) {
return;
}
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
if (!isset($method_data['attributes']) || !is_array($method_data['attributes'])) {
continue;
}
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
// Check for Route attribute
if ($attr_name !== 'Route' && !str_ends_with($attr_name, '\\Route')) {
continue;
}
foreach ($attr_instances as $attr_args) {
// Check first argument (the route pattern)
if (!isset($attr_args[0])) {
continue;
}
$pattern = $attr_args[0];
// Check if pattern contains { or }
if (strpos($pattern, '{') !== false || strpos($pattern, '}') !== false) {
$this->throw_invalid_route_syntax($file_path, $method_name, $pattern);
}
}
}
}
}
private function throw_invalid_route_syntax(string $file_path, string $method_name, string $pattern): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Invalid route pattern syntax\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$file_path}\n";
$error_message .= "Method: {$method_name}\n";
$error_message .= "Pattern: {$pattern}\n\n";
$error_message .= "RSX routes use :param syntax, not Laravel's {param} syntax.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "Your route pattern contains curly braces { or } which are not supported.\n";
$error_message .= "RSX uses colon-prefixed parameters like :id, :slug, etc.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " #[Route('/users/{id}')]\n";
$error_message .= " #[Route('/posts/{slug}/edit')]\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " #[Route('/users/:id')]\n";
$error_message .= " #[Route('/posts/:slug/edit')]\n\n";
$error_message .= "WHY THIS MATTERS:\n";
$error_message .= "- RSX routing system specifically uses :param syntax\n";
$error_message .= "- The dispatcher expects colon-prefixed parameters\n";
$error_message .= "- Laravel-style {param} patterns won't be recognized\n\n";
$error_message .= "FIX:\n";
$error_message .= "Replace all {param} with :param in your route pattern.\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class RsxCommandsDeprecated_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'RSX-CMD-DEPRECATED-01';
}
public function get_name(): string
{
return 'RSX Commands Deprecated Features Check';
}
public function get_description(): string
{
return 'Checks RSX commands for deprecated features or references';
}
public function get_file_patterns(): array
{
// This rule doesn't use standard file pattern matching
// It scans its own directory when check_rsx_commands() is called
return [];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Standard check method - not used for this rule
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule uses check_rsx_commands() instead
return;
}
/**
* Special method to check RSX commands directory
* Called only when rsx:check is run with default paths
*/
public function check_rsx_commands(): void
{
// Only run when using default paths
if (!($this->config['using_default_paths'] ?? false)) {
return;
}
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$commands_dir = $base_path . '/app/RSpade/Commands/Rsx';
// Check if directory exists
if (!is_dir($commands_dir)) {
return;
}
// Scan for PHP files in the RSX commands directory
$files = glob($commands_dir . '/*.php');
foreach ($files as $file_path) {
$this->check_file_for_deprecated($file_path);
}
}
/**
* Check a single file for deprecated references
*/
private function check_file_for_deprecated(string $file_path): void
{
$contents = file_get_contents($file_path);
if ($contents === false) {
return;
}
$lines = explode("\n", $contents);
$filename = basename($file_path);
foreach ($lines as $line_number => $line) {
// Check for the word 'deprecated' (case insensitive)
if (stripos($line, 'deprecated') !== false) {
// Get the actual line number (1-indexed)
$actual_line = $line_number + 1;
// Extract context around the deprecated reference
$context_start = max(0, $line_number - 2);
$context_end = min(count($lines) - 1, $line_number + 2);
$context_lines = array_slice($lines, $context_start, $context_end - $context_start + 1);
$context = implode("\n", $context_lines);
$this->add_violation(
$file_path,
$actual_line,
"Command file '{$filename}' contains reference to 'deprecated'",
trim($line),
$this->get_deprecated_remediation($filename, $line),
'high'
);
}
}
}
/**
* Get remediation message for deprecated references
*/
private function get_deprecated_remediation(string $filename, string $line): string
{
return "DEPRECATED FEATURE REFERENCE IN RSX COMMAND
File: {$filename}
Line containing 'deprecated': {$line}
RSX commands should not contain deprecated features or references to deprecated functionality.
REQUIRED ACTIONS:
1. Remove the deprecated feature or functionality from the command
2. Remove any help text or documentation mentioning deprecated features
3. Update command logic to use current recommended approaches
4. If the entire command is deprecated, consider removing it
COMMON DEPRECATED PATTERNS TO REMOVE:
- Old command aliases marked as deprecated
- Legacy options or flags no longer in use
- References to deprecated framework features
- Outdated help text mentioning deprecated usage
WHY THIS MATTERS:
- Prevents confusion about which features are current
- Reduces maintenance burden of legacy code
- Ensures commands reflect current best practices
- Maintains clean and consistent command interface
If this is documentation about deprecation for historical context,
consider moving it to separate documentation rather than keeping it
in the active command code.";
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class RsxTestFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-RSX-01';
}
public function get_name(): string
{
return 'RSX Test Files Check';
}
public function get_description(): string
{
return 'Check for test files in rsx/ directory and rsx/temp - test files should be in proper test directories';
}
public function get_file_patterns(): array
{
// This rule works differently - it checks rsx directory structure, not individual files
return [];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check for test files in rsx/ directory (from line 1636)
* Test files should be in proper test directories, not loose in rsx/
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// This rule needs special handling - it should be called once, not per file
// We'll handle this in the check_rsx() method instead
}
/**
* Special method to check rsx files - called once per run
*/
public function check_rsx(): void
{
$base_path = function_exists('base_path') ? base_path() : '/var/www/html';
$rsx_dir = $base_path . '/rsx';
$whitelist = function_exists('config') ? config('rsx.code_quality.rsx_test_whitelist', []) : [];
// Check for temp directories only if pre-commit-tests is enabled
$pre_commit_tests = $this->config['pre_commit_tests'] ?? false;
if ($pre_commit_tests) {
// Check both rsx/temp and app/RSpade/temp
$temp_dirs = [
$rsx_dir . '/temp' => 'rsx/temp',
$base_path . '/app/RSpade/temp' => 'app/RSpade/temp'
];
foreach ($temp_dirs as $temp_dir => $temp_name) {
if (is_dir($temp_dir)) {
$temp_files = array_merge(
glob($temp_dir . '/*.php'),
glob($temp_dir . '/*.js'),
glob($temp_dir . '/*')
);
// Remove duplicates and filter out directories
$temp_files = array_unique($temp_files);
$temp_files = array_filter($temp_files, 'is_file');
if (!empty($temp_files)) {
// Files exist in temp directory - report violation
foreach ($temp_files as $file) {
$this->add_violation(
$file,
0,
"File found in {$temp_name} directory. All files in {$temp_name} should be removed prior to commit.",
basename($file),
"The {$temp_name} directory is for temporary test files during development. Remove this file before committing.",
'high' // High severity for pre-commit
);
}
} else {
// Directory exists but is empty - silently remove it
@rmdir($temp_dir);
}
}
}
}
// Get all PHP and JS files in rsx/ (not subdirectories)
$php_files = glob($rsx_dir . '/*.php');
$js_files = glob($rsx_dir . '/*.js');
$files = array_merge($php_files, $js_files);
foreach ($files as $file_path) {
$filename = basename($file_path);
// Check if filename contains 'test' (case insensitive)
if (stripos($filename, 'test') === false) {
continue; // Not a test file
}
// Skip if whitelisted
if (in_array($filename, $whitelist)) {
continue;
}
$this->add_violation(
$file_path,
0,
"Test file '{$filename}' found directly in rsx/ directory. Test files should be organized in proper test subdirectories.",
$filename,
"This appears to be a temporary test file that should be removed before commit. " .
"LLM agents often create test files for verifying specific functionality. " .
"Move this file to a proper test directory (e.g., rsx/rsx_tests/ or rsx/app/tests/) or remove it. " .
"If this file is legitimately needed in rsx/, add '{$filename}' to the whitelist in config/rsx.php under 'code_quality.rsx_test_whitelist'.",
'medium'
);
}
}
}

View File

@@ -0,0 +1,627 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class SubclassNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-SUBCLASS-01';
}
public function get_name(): string
{
return 'Subclass Naming Convention';
}
public function get_description(): string
{
return 'Ensures subclasses end with the same suffix as their parent class';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js'];
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip -temp files
if (str_contains($file_path, '-temp.')) {
return;
}
// Only check files in ./rsx and ./app/RSpade
$is_rsx = str_contains($file_path, '/rsx/');
$is_rspade = str_contains($file_path, '/app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if ($is_rspade && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Get class and extends information from metadata
if (!isset($metadata['class']) || !isset($metadata['extends'])) {
return;
}
$class_name = $metadata['class'];
$parent_class = $metadata['extends'];
// Skip if no parent class
if (empty($parent_class)) {
return;
}
// Get suffix exempt classes from config
$suffix_exempt_classes = config('rsx.code_quality.suffix_exempt_classes', []);
// Strip FQCN prefix from parent class if present
$parent_class_simple = ltrim($parent_class, '\\');
if (str_contains($parent_class_simple, '\\')) {
$parts = explode('\\', $parent_class_simple);
$parent_class_simple = end($parts);
} else {
$parent_class_simple = $parent_class_simple;
}
// Check if parent class is in the suffix exempt list
// If it is, this child class doesn't need to follow suffix convention
// But any classes extending THIS class will need to follow convention
if (in_array($parent_class_simple, $suffix_exempt_classes)) {
// Don't check suffix for direct children of exempt classes
return;
}
// Skip if extending built-in PHP classes or Laravel framework classes
$built_in_classes = ['Exception', 'RuntimeException', 'InvalidArgumentException', 'LogicException',
'BadMethodCallException', 'DomainException', 'LengthException', 'OutOfBoundsException',
'OutOfRangeException', 'OverflowException', 'RangeException', 'UnderflowException',
'UnexpectedValueException', 'ErrorException', 'Error', 'TypeError', 'ParseError',
'AssertionError', 'ArithmeticError', 'DivisionByZeroError',
'BaseController', 'Controller', 'ServiceProvider', 'Command', 'Model',
'Seeder', 'Factory', 'Migration', 'Request', 'Resource', 'Policy'];
if (in_array($parent_class_simple, $built_in_classes)) {
return;
}
// Also check if the parent of parent is exempt - for deeper inheritance
// E.g., if Widget extends Jqhtml_Component, and Dynamic_Widget extends Widget
// Dynamic_Widget should match Widget's suffix
$parent_of_parent = $this->get_parent_class($parent_class_simple);
if ($parent_of_parent && !in_array($parent_of_parent, $suffix_exempt_classes)) {
// Parent's parent is not exempt, so check suffix based on parent
// Continue with normal suffix checking
}
// Extract the suffix from parent class (use original for suffix extraction)
$suffix = $this->extract_suffix($parent_class);
if (empty($suffix)) {
// This is a violation - parent class name is malformed
$this->add_violation(
$file_path,
0,
"Cannot extract suffix from parent class '$parent_class' - parent class name may be malformed",
"class $class_name extends $parent_class",
$this->get_parent_class_suffix_error($parent_class),
'high'
);
return;
}
// Check if child class is abstract based on metadata or class name
$child_is_abstract = isset($metadata['is_abstract']) ? $metadata['is_abstract'] : str_ends_with($class_name, '_Abstract');
// CRITICAL LOGIC: If parent suffix contains "Abstract" and child is NOT abstract,
// remove "Abstract" from the expected suffix
// Example: AbstractRule (parent) → *_Rule (child, not *_AbstractRule)
if (!$child_is_abstract && str_contains($suffix, 'Abstract')) {
// Remove "Abstract" from suffix for non-abstract children
$suffix = str_replace('Abstract', '', $suffix);
// Clean up any double underscores or leading/trailing underscores
$suffix = trim($suffix, '_');
if (empty($suffix)) {
// If suffix becomes empty after removing Abstract, skip validation
// This handles edge cases like a class named just "Abstract"
return;
}
}
// Special handling for abstract classes
if ($child_is_abstract) {
// This is an abstract class - it should have the parent suffix as second-to-last term
$result = $this->check_abstract_class_naming($class_name, $suffix);
if (!$result['valid']) {
$this->add_violation(
$file_path,
0,
$result['message'],
"class $class_name extends $parent_class",
$result['remediation'],
'high'
);
}
return; // Don't check filename for abstract classes - different rules apply
}
// Check if child class suffix contains parent suffix (compound suffix issue)
$child_suffix = $this->extract_child_suffix($class_name);
$compound_suffix_issue = false;
if ($child_suffix && $child_suffix !== $suffix && str_ends_with($child_suffix, $suffix)) {
// Child has compound suffix like ServiceProvider when parent is Provider
$compound_suffix_issue = true;
}
// Check 1: Class name must end with appropriate suffix
$class_name_valid = $this->check_class_name_suffix($class_name, $suffix, $is_rsx);
if (!$class_name_valid || $compound_suffix_issue) {
// Determine expected suffix based on location and Rsx prefix
$expected_suffix = $this->get_expected_suffix($suffix, $is_rsx);
$this->add_violation(
$file_path,
0,
"Class '$class_name' extends '$parent_class' but doesn't end with '_{$expected_suffix}'",
"class $class_name extends $parent_class",
$this->get_class_name_remediation($class_name, $parent_class, $suffix, $is_rsx, $compound_suffix_issue, $child_suffix),
'high'
);
return; // Don't check filename if class name is wrong
}
// Check 2: Filename must follow convention (only if class name is valid)
$filename = basename($file_path);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
if (!$this->check_filename_convention($filename, $class_name, $suffix, $extension)) {
$expected_suffix = $this->get_expected_suffix($suffix, $is_rsx);
$this->add_violation(
$file_path,
0,
"Filename '$filename' doesn't follow naming convention for class '$class_name'",
"File: $filename",
$this->get_filename_remediation($class_name, $filename, $expected_suffix, $extension, $is_rsx),
'medium'
);
}
}
private function extract_suffix(string $parent_class): string
{
// Strip FQCN prefix if present
$parent_class = ltrim($parent_class, '\\');
if (str_contains($parent_class, '\\')) {
$parts = explode('\\', $parent_class);
$parent_class = end($parts);
}
// Split by underscores
$parts = explode('_', $parent_class);
// If no underscores, check for special cases
if (count($parts) === 1) {
// For single-word classes, return the whole name as suffix
// This includes cases like "AbstractRule", "BaseController", etc.
return $parent_class;
}
// Find the last part that is NOT "Abstract"
for ($i = count($parts) - 1; $i >= 0; $i--) {
if ($parts[$i] !== 'Abstract') {
// If this is a multi-part suffix like ManifestBundle_Abstract
// we need to get everything from this point backwards until we hit a proper boundary
// Special case: if the parent ends with _Abstract, get everything before it
if (str_ends_with($parent_class, '_Abstract')) {
$pos = strrpos($parent_class, '_Abstract');
$before_abstract = substr($parent_class, 0, $pos);
// Get the last "word" which could be multi-part
$before_parts = explode('_', $before_abstract);
// If it's something like Manifest_Bundle_Abstract, suffix is "Bundle"
// If it's something like ManifestBundle_Abstract, suffix is "ManifestBundle"
if (count($before_parts) > 0) {
return $before_parts[count($before_parts) - 1];
}
}
return $parts[$i];
}
}
// If we couldn't extract a suffix, return empty string
// This will trigger a violation in the check method
return '';
}
private function check_class_name_suffix(string $class_name, string $suffix, bool $is_rsx): bool
{
// Special case: Allow class name to be the same as the suffix
// e.g., Main extending Main_Abstract
if ($class_name === $suffix) {
return true;
}
// Special handling for Rsx-prefixed suffixes
if (str_starts_with($suffix, 'Rsx')) {
// Remove 'Rsx' prefix to get the base suffix
$base_suffix = substr($suffix, 3);
if ($is_rsx) {
// In /rsx/ directory: child class should use suffix WITHOUT 'Rsx'
// e.g., Demo_Bundle extends Rsx_Bundle_Abstract ✓
return str_ends_with($class_name, '_' . $base_suffix);
} else {
// In /app/RSpade/ directory: child class can use suffix WITH or WITHOUT 'Rsx'
// e.g., Cool_Rule extends RsxRule ✓ OR Cool_RsxRule extends RsxRule ✓
return str_ends_with($class_name, '_' . $suffix) ||
str_ends_with($class_name, '_' . $base_suffix);
}
}
// Standard suffix handling (non-Rsx prefixed)
// Class name must end with _{Suffix}
$expected_ending = '_' . $suffix;
return str_ends_with($class_name, $expected_ending);
}
private function check_filename_convention(string $filename, string $class_name, string $suffix, string $extension): bool
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// Special case: When class name equals suffix (e.g., Main extending Main_Abstract)
// Allow either exact match (Main.php) or lowercase (main.php)
if ($class_name === $suffix) {
if ($filename_without_ext === $class_name || $filename_without_ext === strtolower($class_name)) {
return true;
}
}
// Two valid patterns:
// 1. Exact match to class name: User_Model.php
if ($filename_without_ext === $class_name) {
return true;
}
// 2. Ends with underscore + lowercase suffix: anything_model.php
// For Rsx-prefixed suffixes, use the base suffix (without Rsx) in lowercase
$actual_suffix = str_starts_with($suffix, 'Rsx') ? substr($suffix, 3) : $suffix;
$lowercase_suffix = strtolower($actual_suffix);
if (str_ends_with($filename_without_ext, '_' . $lowercase_suffix)) {
return true;
}
return false;
}
private function extract_child_suffix(string $class_name): string
{
$parts = explode('_', $class_name);
if (count($parts) > 1) {
return $parts[count($parts) - 1];
}
return '';
}
private function get_class_name_remediation(string $class_name, string $parent_class, string $suffix, bool $is_rsx, bool $compound_suffix_issue = false, string $child_suffix = ''): string
{
// Check if this is about Abstract suffix handling
$is_abstract_suffix_issue = str_contains($parent_class, 'Abstract') && !str_ends_with($class_name, '_Abstract');
// Try to suggest a better class name
$suggested_class = $this->suggest_class_name($class_name, $suffix, $is_rsx, $compound_suffix_issue, $child_suffix);
$expected_suffix = $this->get_expected_suffix($suffix, $is_rsx);
// Check if this involves Laravel classes
$laravel_classes = ['BaseController', 'Controller', 'ServiceProvider', 'Command', 'Model',
'Seeder', 'Factory', 'Migration', 'Request', 'Resource', 'Policy'];
$is_laravel_involved = false;
foreach ($laravel_classes as $laravel_class) {
if (str_contains($parent_class, $laravel_class) || str_contains($class_name, $laravel_class)) {
$is_laravel_involved = true;
break;
}
}
$compound_suffix_section = '';
if ($compound_suffix_issue && $child_suffix) {
$compound_suffix_section = "\n\nCOMPOUND SUFFIX DETECTED:\nYour class uses '$child_suffix' when the parent uses '$suffix'.\nThis creates ambiguity. The suffix should be split with underscores.\nFor example: 'ServiceProvider' should become 'Service_Provider'\n";
}
$laravel_section = '';
if ($is_laravel_involved) {
$laravel_section = "\n\nLARAVEL CLASS DETECTED:\nEven though this involves Laravel framework classes, the RSX naming convention STILL APPLIES.\nRSX enforces its own conventions uniformly across all code.\nLaravel's PascalCase conventions are overridden by RSX's underscore notation.\n";
}
$abstract_handling_note = '';
if ($is_abstract_suffix_issue) {
$abstract_handling_note = "\n\nABSTRACT SUFFIX HANDLING:\n" .
"When a parent class contains 'Abstract' in its name (like '$parent_class'),\n" .
"non-abstract child classes should use the suffix WITHOUT 'Abstract'.\n" .
"This is because concrete implementations should not have 'Abstract' in their names.\n";
}
return "CLASS NAMING CONVENTION VIOLATION" . $compound_suffix_section . $laravel_section . $abstract_handling_note . "
Class '$class_name' extends '$parent_class' but doesn't follow RSX naming conventions.
REQUIRED SUFFIX: '$expected_suffix'
All classes extending '$parent_class' must end with '_{$expected_suffix}'
RSX NAMING PATTERN:
RSpade uses underscore notation for class names, separating major conceptual parts:
CORRECT: User_Downloads_Model, Site_User_Model, Php_ManifestModule
WRONG: UserDownloadsModel, SiteUserModel, PhpManifestModule
SUFFIX CONVENTION:
The suffix (last part after underscore) can be multi-word without underscores to describe the class type:
- 'Model' suffix for database models
- 'Controller' suffix for controllers
- 'ManifestModule' suffix for manifest module implementations
- 'BundleProcessor' suffix for bundle processors
These multi-word suffixes act as informal type declarations (e.g., Php_ManifestModule indicates a PHP implementation of a manifest module).
RSX PREFIX SPECIAL RULE:
- In /rsx/ directory: If parent class has 'Rsx' prefix (e.g., Rsx_Bundle_Abstract), child uses suffix WITHOUT 'Rsx' (e.g., Demo_Bundle)
- In /app/RSpade/ directory: Child can use suffix WITH or WITHOUT 'Rsx' prefix (e.g., Cool_Rule or Cool_RsxRule extending RsxRule)
SUGGESTED CLASS NAME: $suggested_class
The filename should also end with:
- For files in /rsx: underscore + suffix in lowercase (e.g., user_downloads_model.php)
- For files in /app/RSpade: suffix matching class name case (e.g., User_Downloads_Model.php)
REMEDIATION STEPS:
1. Rename class from '$class_name' to '$suggested_class'
2. Update filename to match convention
3. Update all references to this class throughout the codebase
WHY THIS MATTERS:
- Enables automatic class discovery and loading
- Makes inheritance relationships immediately clear
- Maintains consistency across the entire codebase
- Supports framework introspection capabilities";
}
private function get_filename_remediation(string $class_name, string $filename, string $suffix, string $extension, bool $is_rsx): string
{
// Suggest filenames based on directory
$lowercase_suggestion = strtolower(str_replace('_', '_', $class_name)) . ".$extension";
$exact_suggestion = $class_name . ".$extension";
$recommended = $is_rsx ? $lowercase_suggestion : $exact_suggestion;
return "FILENAME CONVENTION VIOLATION
Filename '$filename' doesn't follow naming convention for class '$class_name'
VALID FILENAME PATTERNS:
1. Underscore + lowercase suffix: *_" . strtolower($suffix) . ".$extension
2. Exact class name match: $class_name.$extension
CURRENT FILE: $filename
CURRENT CLASS: $class_name
RECOMMENDED FIX:
- For /rsx directory: $lowercase_suggestion
- For /app/RSpade directory: $exact_suggestion
Note: Both patterns are valid in either directory, but these are the conventions.
EXAMPLES OF VALID FILENAMES:
- user_model.$extension (underscore + lowercase suffix)
- site_user_model.$extension (underscore + lowercase suffix)
- $class_name.$extension (exact match)
WHY THIS MATTERS:
- Enables predictable file discovery
- Maintains consistency with directory conventions
- Supports autoloading mechanisms";
}
private function suggest_class_name(string $current_name, string $suffix, bool $is_rsx, bool $compound_suffix_issue = false, string $child_suffix = ''): string
{
// Handle compound suffix issue specially
if ($compound_suffix_issue && $child_suffix) {
// Split the compound suffix with underscores
// E.g., ServiceProvider -> Service_Provider
$split_suffix = $this->split_compound_suffix($child_suffix, $suffix);
if ($split_suffix) {
// Replace the compound suffix with the split version
$base = substr($current_name, 0, -strlen($child_suffix));
return $base . $split_suffix;
}
}
// Determine the suffix to use based on location and Rsx prefix
$target_suffix = $this->get_expected_suffix($suffix, $is_rsx);
// If it already ends with the target suffix (but without underscore), add underscore
if (str_ends_with($current_name, $target_suffix) && !str_ends_with($current_name, '_' . $target_suffix)) {
$without_suffix = substr($current_name, 0, -strlen($target_suffix));
// Convert camelCase to snake_case if needed
$snake_case = preg_replace('/([a-z])([A-Z])/', '$1_$2', $without_suffix);
return $snake_case . '_' . $target_suffix;
}
// Otherwise, just append _Suffix
// Convert the current name to proper underscore notation first
$snake_case = preg_replace('/([a-z])([A-Z])/', '$1_$2', $current_name);
return $snake_case . '_' . $target_suffix;
}
private function get_expected_suffix(string $suffix, bool $is_rsx): string
{
// For Rsx-prefixed suffixes in /rsx/ directory, use suffix without 'Rsx'
if (str_starts_with($suffix, 'Rsx') && $is_rsx) {
return substr($suffix, 3);
}
// Otherwise use the full suffix
return $suffix;
}
private function get_parent_class_suffix_error(string $parent_class): string
{
return "PARENT CLASS SUFFIX EXTRACTION ERROR
Unable to extract a valid suffix from parent class '$parent_class'.
This is an unexpected situation that indicates either:
1. The parent class name has an unusual format that the rule doesn't handle
2. The naming rule logic needs to be updated to handle this case
EXPECTED PARENT CLASS FORMATS:
- Classes with underscores: Last part after underscore is the suffix (e.g., 'Rsx_Model_Abstract' suffix 'Model')
- Classes ending with 'Abstract': Remove 'Abstract' to get suffix (e.g., 'RuleAbstract' suffix 'Rule')
- Classes ending with '_Abstract': Part before '_Abstract' is suffix (e.g., 'Model_Abstract' suffix 'Model')
- Multi-word suffixes: 'ManifestModule_Abstract' suffix 'ManifestModule'
PLEASE REVIEW:
1. Check if the parent class name follows RSX naming conventions
2. If the parent class name is valid but unusual, the SubclassNamingRule may need updating
3. Consider renaming the parent class to follow standard patterns
This violation indicates a framework-level issue that needs attention from the development team.";
}
private function split_compound_suffix(string $compound, string $parent_suffix): string
{
// If compound ends with parent suffix, split it
if (str_ends_with($compound, $parent_suffix)) {
$prefix = substr($compound, 0, -strlen($parent_suffix));
if ($prefix) {
return $prefix . '_' . $parent_suffix;
}
}
return '';
}
private function check_abstract_class_naming(string $class_name, string $parent_suffix): array
{
// Strip Base prefix from parent suffix if present
if (str_starts_with($parent_suffix, 'Base')) {
$parent_suffix = substr($parent_suffix, 4);
}
// Get the parts of the abstract class name
$parts = explode('_', $class_name);
// Must have at least 2 parts (Something_Abstract)
if (count($parts) < 2) {
return [
'valid' => false,
'message' => "Abstract class '$class_name' doesn't follow underscore notation",
'remediation' => "Abstract classes must use underscore notation and end with '_Abstract'.\nExample: User_Controller_Abstract, Rsx_Model_Abstract"
];
}
// Last part must be 'Abstract'
if ($parts[count($parts) - 1] !== 'Abstract') {
return [
'valid' => false,
'message' => "Class '$class_name' appears to be abstract but doesn't end with '_Abstract'",
'remediation' => "All abstract classes must end with '_Abstract'.\nSuggested: " . implode('_', array_slice($parts, 0, -1)) . "_Abstract"
];
}
// If only 2 parts (Something_Abstract), that's valid for root abstracts
if (count($parts) == 2) {
return ['valid' => true];
}
// For multi-part names, check that second-to-last term matches parent suffix
$second_to_last = $parts[count($parts) - 2];
if ($second_to_last !== $parent_suffix) {
// Build suggested name
$suggested_parts = array_slice($parts, 0, -2); // Everything except last 2 parts
$suggested_parts[] = $parent_suffix;
$suggested_parts[] = 'Abstract';
$suggested_name = implode('_', $suggested_parts);
return [
'valid' => false,
'message' => "Abstract class '$class_name' doesn't properly indicate it extends a '$parent_suffix' type",
'remediation' => "ABSTRACT CLASS NAMING CONVENTION\n\n" .
"Abstract classes must:\n" .
"1. End with '_Abstract'\n" .
"2. Have the parent type as the second-to-last term\n\n" .
"Current: $class_name\n" .
"Expected pattern: *_{$parent_suffix}_Abstract\n" .
"Suggested: $suggested_name\n\n" .
"This makes the inheritance chain clear:\n" .
"- Parent provides: $parent_suffix functionality\n" .
"- This class: Abstract extension of $parent_suffix\n\n" .
"Note: If the parent class starts with 'Base' (e.g., BaseController),\n" .
"we strip 'Base' to get the actual type (Controller)."
];
}
return ['valid' => true];
}
/**
* Get the parent class of a given class from manifest or other sources
*/
private function get_parent_class(string $class_name): ?string
{
// Try to get from manifest
try {
$metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_class($class_name);
if (!empty($metadata) && isset($metadata['extends'])) {
$parent = $metadata['extends'];
// Strip namespace if present
if (str_contains($parent, '\\')) {
$parts = explode('\\', $parent);
return end($parts);
}
return $parent;
}
} catch (\RuntimeException $e) {
// Class not in manifest (e.g., framework classes like DatabaseSessionHandler)
// Return null since we can't check parent of external classes
return null;
}
return null;
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Common;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class TempFiles_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'FILE-TEMP-01';
}
public function get_name(): string
{
return 'Temporary Files Check';
}
public function get_description(): string
{
return 'Check for temporary files ending in -temp that should be removed before commit';
}
public function get_file_patterns(): array
{
return ['*']; // Check all files
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check for files ending in -temp before their extension
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only run during pre-commit tests
$pre_commit_tests = $this->config['pre_commit_tests'] ?? false;
if (!$pre_commit_tests) {
return;
}
// Check if filename contains -temp before the extension
$filename = basename($file_path);
// Match files like test-temp.php, module-temp.js, etc.
if (preg_match('/^(.+)-temp(\.[^.]+)?$/', $filename, $matches)) {
$this->add_violation(
$file_path,
0,
"Temporary file '{$filename}' detected. Files ending in '-temp' should be removed before commit.",
$filename,
"The '-temp' suffix indicates this is a temporary file for testing or development.\n" .
"These files should not be committed to the repository.\n\n" .
"Options:\n" .
"1. Remove the file if it's no longer needed\n" .
"2. Rename the file without '-temp' if it should be kept\n" .
"3. Move to a proper test directory if it's a test file",
'high'
);
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class BundleIncludePath_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-BUNDLE-02';
}
public function get_name(): string
{
return 'Bundle Include Path Convention';
}
public function get_description(): string
{
return 'Bundles should include __DIR__ or their relative path in their includes';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'low';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a PHP class file
if (!isset($metadata['class'])) {
return;
}
// Only check files in ./rsx/ directory, trust framework authors for app/RSpade
if (!str_starts_with($file_path, base_path() . '/rsx/')) {
return;
}
// Check if class extends Rsx_Bundle_Abstract
$extends = $metadata['extends'] ?? '';
if ($extends !== 'Rsx_Bundle_Abstract' &&
$extends !== 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
$dir_path = dirname($relative_path);
// Check if bundle includes its own directory in the include array
// Look for the define() method
if (!preg_match('/public\s+static\s+function\s+define\s*\(\s*\)\s*:\s*array\s*\{(.*?)\}/s', $contents, $matches)) {
return; // Can't find define method
}
$define_content = $matches[1];
// Look for include array
if (!preg_match("/['\"]include['\"]\s*=>\s*\[(.*?)\]/s", $define_content, $include_matches)) {
return; // No include array found
}
$include_content = $include_matches[1];
// Check if it references __DIR__ or the directory path
$has_dir_reference = false;
// Check for __DIR__ usage
if (str_contains($include_content, '__DIR__')) {
$has_dir_reference = true;
}
// Check for the relative directory path (e.g., 'rsx/app/demo')
if (str_contains($include_content, "'" . $dir_path) ||
str_contains($include_content, '"' . $dir_path)) {
$has_dir_reference = true;
}
if (!$has_dir_reference) {
// Get bundle class name for better message
$class_name = $metadata['class'] ?? basename($file_path, '.php');
$this->add_violation(
$file_path,
0,
"Bundle {$class_name} should include its own directory in the 'include' array",
null,
"Add '__DIR__' or '{$dir_path}' to the bundle's include array to ensure all module files are included.\n" .
"Note: This is a convention rather than a hard requirement. If your bundle intentionally doesn't need " .
"to include its own directory, add the following comment to grant an exception: @CONV-BUNDLE-02-EXCEPTION",
$this->get_default_severity()
);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class BundleLocation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-BUNDLE-01';
}
public function get_name(): string
{
return 'Bundle Location Convention';
}
public function get_description(): string
{
return 'Bundles should be in ./rsx/app or ./rsx/app/(module)/ but not deeper';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'convention';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a PHP class file
if (!isset($metadata['class'])) {
return;
}
// Check if class extends Rsx_Bundle_Abstract
$extends = $metadata['extends'] ?? '';
if ($extends !== 'Rsx_Bundle_Abstract' &&
$extends !== 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Check if it's in rsx/app directory
if (!str_starts_with($relative_path, 'rsx/app/')) {
return; // Bundle is not in rsx/app, that's ok (could be in rsx/lib etc)
}
// Count directory levels after rsx/app/
$after_app = substr($relative_path, strlen('rsx/app/'));
$parts = explode('/', $after_app);
// If more than 2 levels deep (module/feature/file.php), it's a violation
if (count($parts) > 2) {
$this->add_violation(
$file_path,
0,
'Bundle class should be in ./rsx/app/ or ./rsx/app/(module)/ but not in feature subdirectories',
null,
'Move this bundle to ./rsx/app/ if used globally, or to ./rsx/app/' . $parts[0] . '/ if module-specific',
'convention'
);
}
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/**
* FilenameRedundantPrefix_CodeQualityRule - Detects unnecessarily long filenames
*
* Suggests using short filenames when the directory structure already contains the prefix.
*/
class FilenameRedundantPrefix_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-FILENAME-01';
}
public function get_name(): string
{
return 'Filename Redundant Prefix Convention';
}
public function get_description(): string
{
return 'Suggests using short filenames when directory structure contains the prefix';
}
public function get_file_patterns(): array
{
return ['*.php', '*.js', '*.jqhtml', '*.blade.php'];
}
public function get_default_severity(): string
{
return 'convention';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx or ./app/RSpade
$relative_path = str_replace(base_path() . '/', '', $file_path);
$is_rsx = str_starts_with($relative_path, 'rsx/');
$is_rspade = str_starts_with($relative_path, 'app/RSpade/');
if (!$is_rsx && !$is_rspade) {
return;
}
$extension = $metadata['extension'] ?? '';
$filename = basename($file_path);
// Check PHP/JS files with classes
if (isset($metadata['class'])) {
$this->check_class_redundancy($relative_path, $metadata['class'], $extension, $filename, $is_rspade);
}
// Check blade.php files with @rsx_id
if ($extension === 'blade.php' && isset($metadata['id'])) {
$this->check_blade_redundancy($relative_path, $metadata['id'], $filename, $is_rspade);
}
// Check jqhtml files with Define:
if ($extension === 'jqhtml' && isset($metadata['id'])) {
$this->check_jqhtml_redundancy($relative_path, $metadata['id'], $filename, $is_rspade);
}
}
private function check_class_redundancy(string $file, string $class_name, string $extension, string $filename, bool $is_rspade): void
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
$dir_path = dirname($file);
$short_name = $this->extract_short_name($class_name, $dir_path);
if ($short_name === null) {
return; // No short name available
}
// Check if current filename is the full name (redundant)
$is_full_name = $is_rspade
? $filename_without_ext === $class_name
: strtolower($filename_without_ext) === strtolower($class_name);
if (!$is_full_name) {
return; // Not using full name
}
// Check if short filename would be available
$short_filename = $is_rspade ? $short_name . '.' . $extension : strtolower($short_name) . '.' . $extension;
$short_path = dirname(base_path($file)) . '/' . $short_filename;
if (file_exists($short_path)) {
return; // Short name already taken
}
$this->add_violation(
$file,
1,
"Filename contains redundant prefix already represented in directory structure",
"class $class_name",
"Directory structure already contains the class name prefix.\n" .
"Consider using the shorter filename: '$short_filename'\n" .
" mv '$filename' '$short_filename'\n\n" .
"IMPORTANT: Only the filename should be changed. The class name must remain unchanged.\n" .
"The directory structure provides the necessary context for the shorter filename.\n" .
"Example: rsx/app/demo/demo_controller.php → rsx/app/demo/controller.php\n" .
" (but class Demo_Controller remains Demo_Controller)\n\n" .
"This improves readability while maintaining uniqueness.",
'convention'
);
}
private function check_blade_redundancy(string $file, string $rsx_id, string $filename, bool $is_rspade): void
{
$filename_without_blade = str_replace('.blade.php', '', $filename);
$dir_path = dirname($file);
$short_name = $this->extract_short_name($rsx_id, $dir_path);
if ($short_name === null) {
return;
}
$is_full_name = $is_rspade
? $filename_without_blade === $rsx_id
: strtolower($filename_without_blade) === strtolower($rsx_id);
if (!$is_full_name) {
return;
}
$short_filename = $is_rspade ? $short_name . '.blade.php' : strtolower($short_name) . '.blade.php';
$short_path = dirname(base_path($file)) . '/' . $short_filename;
if (file_exists($short_path)) {
return;
}
$this->add_violation(
$file,
1,
"Blade filename contains redundant prefix already represented in directory structure",
"@rsx_id('$rsx_id')",
"Directory structure already contains the @rsx_id prefix.\n" .
"Consider using the shorter filename: '$short_filename'\n" .
" mv '$filename' '$short_filename'\n\n" .
"IMPORTANT: Only the filename should be changed. The @rsx_id must remain unchanged.\n" .
"The directory structure provides the necessary context for the shorter filename.\n" .
"Example: rsx/app/demo/sections/demo_sections_cards.blade.php → cards.blade.php\n" .
" (but @rsx_id('demo.sections.cards') remains demo.sections.cards)\n\n" .
"This improves readability while maintaining uniqueness.",
'convention'
);
}
private function check_jqhtml_redundancy(string $file, string $component_name, string $filename, bool $is_rspade): void
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
$dir_path = dirname($file);
$short_name = $this->extract_short_name($component_name, $dir_path);
if ($short_name === null) {
return;
}
$is_full_name = $is_rspade
? $filename_without_ext === $component_name
: strtolower($filename_without_ext) === strtolower($component_name);
if (!$is_full_name) {
return;
}
$short_filename = $is_rspade ? $short_name . '.jqhtml' : strtolower($short_name) . '.jqhtml';
$short_path = dirname(base_path($file)) . '/' . $short_filename;
if (file_exists($short_path)) {
return;
}
$this->add_violation(
$file,
1,
"Jqhtml filename contains redundant prefix already represented in directory structure",
"<Define:$component_name>",
"Directory structure already contains the component name prefix.\n" .
"Consider using the shorter filename: '$short_filename'\n" .
" mv '$filename' '$short_filename'\n\n" .
"IMPORTANT: Only the filename should be changed. The component name must remain unchanged.\n" .
"The directory structure provides the necessary context for the shorter filename.\n" .
"Example: rsx/app/demo/components/demo_card.jqhtml → card.jqhtml\n" .
" (but <Define:Demo_Card> remains Demo_Card)\n\n" .
"This improves readability while maintaining uniqueness.",
'convention'
);
}
/**
* Extract short name from class/id if directory structure matches prefix
* Returns null if directory structure doesn't match or if short name rules aren't met
*
* Rules:
* - Original name must have 3+ segments for short name to be allowed (2-segment names must use full name)
* - Short name must have 2+ segments (exception: if original was 1 segment, short can be 1 segment)
*/
private function extract_short_name(string $full_name, string $dir_path): ?string
{
$name_parts = explode('_', $full_name);
$original_segment_count = count($name_parts);
// If original name has exactly 2 segments, short name is NOT allowed
if ($original_segment_count === 2) {
return null;
}
// If only 1 segment, no prefix to match
if ($original_segment_count === 1) {
return null;
}
// Split directory path into parts and re-index
$dir_parts = array_values(array_filter(explode('/', $dir_path)));
// Find the maximum number of consecutive matches between end of dir_parts and start of name_parts
$matched_parts = 0;
$max_possible = min(count($dir_parts), count($name_parts) - 1);
// Try to match last N dir parts with first N name parts
for ($num_to_check = $max_possible; $num_to_check > 0; $num_to_check--) {
$all_match = true;
for ($i = 0; $i < $num_to_check; $i++) {
$dir_idx = count($dir_parts) - $num_to_check + $i;
if (strtolower($dir_parts[$dir_idx]) !== strtolower($name_parts[$i])) {
$all_match = false;
break;
}
}
if ($all_match) {
$matched_parts = $num_to_check;
break;
}
}
if ($matched_parts === 0) {
return null;
}
// Calculate the short name
$short_parts = array_slice($name_parts, $matched_parts);
$short_segment_count = count($short_parts);
// Validate short name segment count
// Short name must have 2+ segments (unless original was 1 segment, which we already excluded above)
if ($short_segment_count < 2) {
return null; // Short name would be too short
}
return implode('_', $short_parts);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
class LayoutLocation_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-LAYOUT-01';
}
public function get_name(): string
{
return 'Layout File Location Convention';
}
public function get_description(): string
{
return 'Layout blade files in ./rsx/ must be within a module directory (./rsx/app/(module)/ or deeper)';
}
public function get_file_patterns(): array
{
return ['*.blade.php'];
}
public function get_default_severity(): string
{
return 'convention';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Check if filename ends with layout.blade.php
if (!preg_match('/_layout\.blade\.php$/', $file_path)) {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
// Only check layouts in rsx/ directory (not app/RSpade)
if (!str_starts_with($relative_path, 'rsx/')) {
return;
}
// Check if it's in rsx/app directory
if (!str_starts_with($relative_path, 'rsx/app/')) {
$this->add_violation(
$file_path,
0,
'Layout file must be within ./rsx/app/ directory',
null,
'Move this layout to ./rsx/app/(module)/ or a subdirectory within a module',
'convention'
);
return;
}
// Count directory levels after rsx/app/
$after_app = substr($relative_path, strlen('rsx/app/'));
$parts = explode('/', $after_app);
// Layout must be at least 2 levels deep: module/file.php
if (count($parts) < 2) {
$this->add_violation(
$file_path,
0,
'Layout file must be within a module directory, not directly in ./rsx/app/',
null,
'Move this layout to ./rsx/app/(module)/ or a subdirectory within a module',
'convention'
);
}
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\RSpade\CodeQuality\Rules\Convention;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\Core\Manifest\Manifest;
class OneBundlePerModule_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'CONV-BUNDLE-03';
}
public function get_name(): string
{
return 'One Bundle Per Module Directory Convention';
}
public function get_description(): string
{
return 'Module directories should have only one bundle (./rsx/app root can have multiple)';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Whether this rule is called during manifest scan
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* bundle organization is a critical framework convention for module structure.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip if not a PHP class file
if (!isset($metadata['class'])) {
return;
}
// Check if class extends Rsx_Bundle_Abstract
$extends = $metadata['extends'] ?? '';
if ($extends !== 'Rsx_Bundle_Abstract' &&
$extends !== 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
return;
}
// Get relative path from base
$relative_path = str_replace(base_path() . '/', '', $file_path);
$dir_path = dirname($relative_path);
// Skip if bundle is directly in rsx/app (they can have multiple)
if ($dir_path === 'rsx/app') {
return;
}
// Skip if not in rsx/app
if (!str_starts_with($dir_path, 'rsx/app/')) {
return;
}
// Check the manifest for other bundles in the same directory
$manifest = Manifest::get_all();
$bundles_in_same_dir = [];
foreach ($manifest as $path => $file_metadata) {
// Check if it's a PHP file in the same directory
if (dirname($path) !== $dir_path) {
continue;
}
// Check if it's a bundle class
if (isset($file_metadata['class']) && isset($file_metadata['extends'])) {
$file_extends = $file_metadata['extends'];
if ($file_extends === 'Rsx_Bundle_Abstract' ||
$file_extends === 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
$bundles_in_same_dir[] = basename($path, '.php');
}
}
}
// If there's more than one bundle in this directory, throw an exception
if (count($bundles_in_same_dir) > 1) {
$error_message = "Code Quality Violation (CONV-BUNDLE-03) - Multiple Bundles in Same Directory\n\n";
$error_message .= "Module directory '{$dir_path}' has multiple bundle files:\n";
foreach ($bundles_in_same_dir as $bundle_name) {
$error_message .= " - {$bundle_name}.php\n";
}
$error_message .= "\nCRITICAL: Each module directory should have only ONE bundle.\n\n";
$error_message .= "Resolution:\n";
$error_message .= "1. Consolidate these bundles into a single bundle file\n";
$error_message .= "2. OR move extra bundles to their own module directories\n";
$error_message .= "3. OR move them to ./rsx/app/ (which allows multiple bundles)\n\n";
$error_message .= "This convention ensures clean module organization and predictable bundle loading.";
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
$file_path,
1
);
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class AjaxReturnValue_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-AJAX-02';
}
public function get_name(): string
{
return 'AJAX Return Value Property Check';
}
public function get_description(): string
{
return "Detects unnecessary access to _ajax_return_value property in AJAX responses";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Check JavaScript files for _ajax_return_value property access
* Ajax.call() already unwraps the response, so accessing _ajax_return_value is unnecessary
* Instead of: data._ajax_return_value.user_id
* Should use: data.user_id
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip Ajax.js itself
if (str_ends_with($file_path, '/Ajax.js')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in rsx/ or app/RSpade/
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Check for _ajax_return_value usage
if (str_contains($line, '_ajax_return_value')) {
// Try to extract context for better suggestion
$suggestion = "Ajax.call() automatically unwraps the server response. ";
// Check if we can detect the specific property being accessed
if (preg_match('/(\w+)\._ajax_return_value\.(\w+)/', $line, $matches)) {
$variable = $matches[1];
$property = $matches[2];
$suggestion .= "Instead of '{$variable}._ajax_return_value.{$property}', ";
$suggestion .= "use '{$variable}.{$property}' directly.";
} elseif (preg_match('/(\w+)\._ajax_return_value/', $line, $matches)) {
$variable = $matches[1];
$suggestion .= "Instead of '{$variable}._ajax_return_value', ";
$suggestion .= "use '{$variable}' directly - it already contains the unwrapped response.";
} else {
$suggestion .= "The response from Ajax.call() is already unwrapped, ";
$suggestion .= "so you can access properties directly without the _ajax_return_value wrapper.";
}
$this->add_violation(
$file_path,
$line_number,
"Unnecessary access to '_ajax_return_value' property detected.",
trim($line),
$suggestion,
'medium'
);
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
use App\RSpade\Core\Manifest\Manifest;
/**
* Check decorator usage and enforce whitelisting rules
* - Functions/methods marked with @decorator become whitelisted
* - Global functions can only use @decorator
* - Static and instance methods can only use whitelisted decorators
* - Checks for duplicate global function names
* - Checks for duplicate global const names
* - Checks for conflicts between global function and const names
*/
class DecoratorUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
private array $decorator_whitelist = [];
private array $all_global_functions = [];
private array $all_global_constants = [];
private array $all_global_names = []; // Combined functions and constants for conflict checking
public function get_id(): string
{
return 'JS-DECORATOR-01';
}
public function get_name(): string
{
return 'JavaScript Decorator Usage Check';
}
public function get_description(): string
{
return 'Validates JavaScript decorator usage and whitelisting (static and instance methods)';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
/**
* Run during manifest build for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only process during manifest-time when we have all files
// This rule needs to scan all files first to build the whitelist
// So we run it once at the end of manifest building
static $already_run = false;
if ($already_run) {
return;
}
// On the first JavaScript file, process all files
if (!empty($metadata) && $metadata['extension'] === 'js') {
$this->process_all_files();
$already_run = true;
}
}
private function process_all_files(): void
{
// Get all files from manifest
$files = Manifest::get_all();
// Step 1: Build decorator whitelist and collect all global functions
foreach ($files as $path => $metadata) {
// Skip non-JavaScript files
if (($metadata['extension'] ?? '') !== 'js') {
continue;
}
// Collect global function names for uniqueness check
if (!empty($metadata['global_function_names'])) {
foreach ($metadata['global_function_names'] as $func_name) {
if (isset($this->all_global_functions[$func_name])) {
// Duplicate function name
$existing_file = $this->all_global_functions[$func_name];
$this->throw_duplicate_global($func_name, 'function', $existing_file, $path);
}
$this->all_global_functions[$func_name] = $path;
// Check for conflict with const names
if (isset($this->all_global_constants[$func_name])) {
$existing_file = $this->all_global_constants[$func_name];
$this->throw_name_conflict($func_name, 'function', $path, 'const', $existing_file);
}
$this->all_global_names[$func_name] = ['type' => 'function', 'file' => $path];
}
}
// Collect global const names for uniqueness check
if (!empty($metadata['global_const_names'])) {
foreach ($metadata['global_const_names'] as $const_name) {
if (isset($this->all_global_constants[$const_name])) {
// Duplicate const name
$existing_file = $this->all_global_constants[$const_name];
$this->throw_duplicate_global($const_name, 'const', $existing_file, $path);
}
$this->all_global_constants[$const_name] = $path;
// Check for conflict with function names
if (isset($this->all_global_functions[$const_name])) {
$existing_file = $this->all_global_functions[$const_name];
$this->throw_name_conflict($const_name, 'const', $path, 'function', $existing_file);
}
$this->all_global_names[$const_name] = ['type' => 'const', 'file' => $path];
}
}
// Check global functions for @decorator
if (!empty($metadata['global_functions_with_decorators'])) {
foreach ($metadata['global_functions_with_decorators'] as $func_name => $func_data) {
$decorators = $func_data['decorators'] ?? [];
$line = $func_data['line'] ?? 0;
foreach ($decorators as $decorator) {
$decorator_name = $decorator['name'] ?? $decorator[0] ?? 'unknown';
if ($decorator_name === 'decorator') {
$this->decorator_whitelist[$func_name] = true;
} else {
// Global function with non-@decorator decorator
$this->throw_global_function_decorator($func_name, $path, $line, $decorator_name);
}
}
}
}
// Check static methods for @decorator
if (!empty($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name === 'decorator') {
$this->decorator_whitelist[$method_name] = true;
}
}
}
}
}
// Check instance methods for @decorator
if (!empty($metadata['public_instance_methods'])) {
foreach ($metadata['public_instance_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name === 'decorator') {
$this->decorator_whitelist[$method_name] = true;
}
}
}
}
}
}
// Step 2: Validate static and instance method decorators against whitelist
foreach ($files as $path => $metadata) {
// Skip non-JavaScript files
if (($metadata['extension'] ?? '') !== 'js') {
continue;
}
$class_name = $metadata['class'] ?? 'Unknown';
// Check static methods
if (!empty($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name !== 'decorator' && !isset($this->decorator_whitelist[$decorator_name])) {
$this->throw_unwhitelisted_decorator($decorator_name, $class_name, $method_name, $path);
}
}
}
}
}
// Check instance methods
if (!empty($metadata['public_instance_methods'])) {
foreach ($metadata['public_instance_methods'] as $method_name => $method_data) {
// Check for decorators in compact format
if (!empty($metadata['method_decorators'][$method_name])) {
$compact_decorators = $metadata['method_decorators'][$method_name];
foreach ($compact_decorators as $decorator_data) {
$decorator_name = $decorator_data[0] ?? 'unknown';
if ($decorator_name !== 'decorator' && !isset($this->decorator_whitelist[$decorator_name])) {
$this->throw_unwhitelisted_decorator($decorator_name, $class_name, $method_name, $path);
}
}
}
}
}
}
}
// Note: Duplicate/conflict checking methods removed
// This functionality is now handled by BundleCompiler::_check_js_naming_conflicts()
// which checks only the files being bundled, not all files in the project
private function throw_global_function_decorator(string $func_name, string $path, int $line, string $decorator_name): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Global function cannot use decorator\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$path}\n";
$error_message .= "Line: {$line}\n";
$error_message .= "Function: {$func_name}\n";
$error_message .= "Decorator: @{$decorator_name}\n\n";
$error_message .= "Global functions may only use the @decorator marker.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "The function '{$func_name}' has decorator '@{$decorator_name}'.\n";
$error_message .= "Only the '@decorator' marker is allowed on global functions.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " @memoize\n";
$error_message .= " function myFunction() { ... }\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " @decorator\n";
$error_message .= " function myDecorator(target, key, descriptor) { ... }\n\n";
$error_message .= "WHY THIS RESTRICTION:\n";
$error_message .= "- Global functions are processed differently than class methods\n";
$error_message .= "- The @decorator marker identifies decorator implementations\n";
$error_message .= "- Other decorators can only be used on static class methods\n\n";
$error_message .= "FIX OPTIONS:\n";
$error_message .= "1. Remove the '@{$decorator_name}' decorator\n";
$error_message .= "2. Move the function into a class as a static method\n";
$error_message .= "3. If this IS a decorator implementation, use '@decorator' instead\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
private function throw_unwhitelisted_decorator(string $decorator_name, string $class_name, string $method_name, string $path): void
{
$error_message = "==========================================\n";
$error_message .= "FATAL: Decorator not whitelisted\n";
$error_message .= "==========================================\n\n";
$error_message .= "File: {$path}\n";
$error_message .= "Class: {$class_name}\n";
$error_message .= "Method: {$method_name}\n";
$error_message .= "Decorator: @{$decorator_name}\n\n";
$error_message .= "The decorator '@{$decorator_name}' is not whitelisted.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "Only decorators marked with @decorator can be used.\n";
$error_message .= "The '{$decorator_name}' function/method needs @decorator marker.\n\n";
$error_message .= "EXAMPLE OF WHITELISTING:\n";
$error_message .= " // Mark the decorator implementation:\n";
$error_message .= " @decorator\n";
$error_message .= " function {$decorator_name}(target, key, descriptor) {\n";
$error_message .= " // Decorator implementation\n";
$error_message .= " return descriptor;\n";
$error_message .= " }\n\n";
$error_message .= " // Or in a class:\n";
$error_message .= " class Decorators {\n";
$error_message .= " @decorator\n";
$error_message .= " static {$decorator_name}(target, key, descriptor) {\n";
$error_message .= " return descriptor;\n";
$error_message .= " }\n";
$error_message .= " }\n\n";
$error_message .= "WHY THIS MATTERS:\n";
$error_message .= "- Decorators must be explicitly whitelisted\n";
$error_message .= "- This prevents typos and undefined decorators\n";
$error_message .= "- Ensures decorators are properly implemented\n\n";
$error_message .= "FIX:\n";
$error_message .= "Add @decorator to the '{$decorator_name}' function/method definition.\n";
$error_message .= "==========================================";
throw new YoureDoingItWrongException($error_message);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-DEFENSIVE-01';
}
public function get_name(): string
{
return 'JavaScript Defensive Coding Check';
}
public function get_description(): string
{
return 'Prohibits existence checks - code must fail loudly if dependencies are missing';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript file for defensive coding violations (from line 833)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get sanitized content
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Pattern 1: typeof variable checks (!== undefined, === undefined, == 'function', etc.)
// Match: typeof SomeVar !== 'undefined' or typeof SomeVar == 'function'
if (preg_match('/typeof\s+(\w+)\s*([!=]=+)\s*[\'"]?(undefined|function)[\'"]?/i', $line, $matches)) {
$variable = $matches[1];
// Skip if it's a property check (contains dot)
if (!str_contains($variable, '.')) {
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the existence check. Let the code fail if '{$variable}' is not defined.",
'high'
);
}
}
// Pattern 2: typeof window.variable checks
if (preg_match('/typeof\s+window\.(\w+)\s*([!=]=+)\s*[\'"]?undefined[\'"]?/i', $line, $matches)) {
$variable = 'window.' . $matches[1];
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Checking if '{$variable}' exists. All global variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the existence check. Let the code fail if '{$variable}' is not defined.",
'high'
);
}
// Pattern 3: if (variable) or if (!variable) existence checks (more careful pattern)
// Only match simple variables, not property access
if (preg_match('/if\s*\(\s*(!)?(\w+)\s*\)/', $line, $matches)) {
$variable = $matches[2];
// Skip if it's a property or array access or a boolean-like variable name
if (!str_contains($line, '.' . $variable) &&
!str_contains($line, '[' . $variable) &&
!str_contains($line, $variable . '.') &&
!str_contains($line, $variable . '[') &&
!preg_match('/^(is|has|can|should|will|did|was)[A-Z]/', $variable) && // Skip boolean-named vars
!in_array(strtolower($variable), ['true', 'false', 'null', 'undefined'])) { // Skip literals
// Check if this looks like an existence check by looking at context
if (preg_match('/if\s*\(\s*(!)?typeof\s+' . preg_quote($variable, '/') . '/i', $line) ||
preg_match('/if\s*\(\s*' . preg_quote($variable, '/') . '\s*&&\s*' . preg_quote($variable, '/') . '\./i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the existence check. Let the code fail if '{$variable}' is not defined.",
'high'
);
}
}
}
// Pattern 4: Guard clauses like: Rsx && Rsx.method()
if (preg_match('/(\w+)\s*&&\s*\1\.\w+/i', $line, $matches)) {
$variable = $matches[1];
// Skip common boolean variable patterns
if (!preg_match('/^(is|has|can|should|will|did|was)[A-Z]/', $variable)) {
$this->add_violation(
$file_path,
$line_number,
"Defensive coding violation: Guard clause checking if '{$variable}' exists. All classes and variables must be assumed to exist. Code should fail loudly if something is undefined.",
trim($line),
"Remove the guard clause. Use '{$variable}.method()' directly.",
'high'
);
}
}
// Pattern 5: try/catch used for existence checking (simplified detection)
if (preg_match('/try\s*\{.*?(\w+).*?\}\s*catch/i', $line, $matches)) {
// This is a simplified check - in reality you'd need multi-line parsing
// Skip for now as it's complex to detect intent
}
}
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class DirectAjaxApi_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-AJAX-01';
}
public function get_name(): string
{
return 'Direct AJAX API Call Check';
}
public function get_description(): string
{
return "Detects direct $.ajax calls to /_ajax/ endpoints instead of using JS controller stubs";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript files for direct $.ajax calls to /_ajax/ endpoints
* Instead of:
* await $.ajax({ url: '/_ajax/Controller/action', ... })
* Should use:
* await Controller.action(params)
* Or:
* await Ajax.call('Controller', 'action', params)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip Ajax.js itself
if (str_ends_with($file_path, '/Ajax.js')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Only check files in rsx/ or app/RSpade/
if (!str_contains($file_path, '/rsx/') && !str_contains($file_path, '/app/RSpade/')) {
return;
}
// If in app/RSpade, check if it's in an allowed subdirectory
if (str_contains($file_path, '/app/RSpade/') && !$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
// Pattern to match $.ajax({ url: '/_ajax/Controller/action'
// This handles both single-line and multi-line cases
$full_content = implode("\n", $lines);
// Match $.ajax({ with optional whitespace/newlines, then url: with quotes around /_ajax/
// Capture controller and action names for suggestion
$pattern = '/\$\.ajax\s*\(\s*\{[^}]*?url\s*:\s*[\'"](\/_ajax\/([A-Za-z_][A-Za-z0-9_]*)\/([A-Za-z_][A-Za-z0-9_]*))[^\'"]*[\'"]/s';
if (preg_match_all($pattern, $full_content, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$matched_text = $match[0];
$offset = $match[1];
$url = $matches[1][$index][0];
$controller = $matches[2][$index][0];
$action = $matches[3][$index][0];
// Find line number
$line_number = substr_count(substr($full_content, 0, $offset), "\n") + 1;
// Get the actual line for display
$line = $lines[$line_number - 1] ?? '';
// Build suggestion
$suggestion = "Instead of direct $.ajax() call to '{$url}', use:\n";
$suggestion .= " 1. Preferred: await {$controller}.{$action}(params)\n";
$suggestion .= " 2. Alternative: await Ajax.call('{$controller}', '{$action}', params)\n";
$suggestion .= "The JS stub handles session expiry, notifications, and response unwrapping.";
$this->add_violation(
$file_path,
$line_number,
"Direct $.ajax() call to internal API endpoint '{$url}' detected. Use JS controller stub instead.",
trim($line),
$suggestion,
'high'
);
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\InitializationSuggestions;
class DocumentReady_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-READY-01';
}
public function get_name(): string
{
return 'JavaScript Document Ready Check';
}
public function get_description(): string
{
return 'Enforces use of ES6 class lifecycle methods instead of window.onload or jQuery ready';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* Whether this rule is called during manifest scan
*
* EXCEPTION: This rule has been explicitly approved to run at manifest-time because
* document ready patterns prevent the framework's auto-initialization from functioning correctly.
*/
public function is_called_during_manifest_scan(): bool
{
return true; // Explicitly approved for manifest-time checking
}
/**
* Process file during manifest update to extract document ready violations
*/
public function on_manifest_file_update(string $file_path, string $contents, array $metadata = []): ?array
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return null;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return null;
}
$lines = explode("\n", $contents);
$violations = [];
// Also get sanitized content to skip comments
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments using sanitized version
if (isset($sanitized_lines[$line_num])) {
$sanitized_trimmed = trim($sanitized_lines[$line_num]);
if (empty($sanitized_trimmed)) {
continue; // Skip empty/comment lines
}
}
// Check for window.onload patterns
if (preg_match('/\bwindow\s*\.\s*onload\s*=/', $line)) {
$violations[] = [
'type' => 'window_onload',
'line' => $line_number,
'code' => trim($line)
];
break; // Only need first violation
}
// Check for various jQuery ready patterns and DOMContentLoaded
// Patterns: $().ready, $(document).ready, $("document").ready, $('document').ready, $(function(), DOMContentLoaded
$jquery_ready_patterns = [
'/\$\s*\(\s*\)\s*\.\s*ready\s*\(/', // $().ready(
'/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', // $(document).ready( with spaces
'/\$\s*\(\s*["\']document["\']\s*\)\s*\.\s*ready\s*\(/', // $("document").ready( or $('document').ready(
'/\$\s*\(\s*function\s*\(/', // $(function() - shorthand for $(document).ready
'/jQuery\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', // jQuery(document).ready(
'/jQuery\s*\(\s*["\']document["\']\s*\)\s*\.\s*ready\s*\(/', // jQuery("document").ready( or jQuery('document').ready(
'/jQuery\s*\(\s*function\s*\(/', // jQuery(function() - shorthand
'/document\s*\.\s*addEventListener\s*\(\s*["\']DOMContentLoaded[\"\']/', // document.addEventListener("DOMContentLoaded" or 'DOMContentLoaded'
];
foreach ($jquery_ready_patterns as $pattern) {
if (preg_match($pattern, $line)) {
$violations[] = [
'type' => 'jquery_ready',
'line' => $line_number,
'code' => trim($line)
];
break; // Only report once per line
}
}
// Stop after first violation
if (!empty($violations)) {
break;
}
}
if (!empty($violations)) {
return ['document_ready_violations' => $violations];
}
return null;
}
/**
* Check JavaScript file for document ready violations stored in metadata
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Check for violations in code quality metadata
if (isset($metadata['code_quality_metadata']['JS-READY-01']['document_ready_violations'])) {
$violations = $metadata['code_quality_metadata']['JS-READY-01']['document_ready_violations'];
// Get appropriate suggestion based on code location
$suggestion = InitializationSuggestions::get_suggestion($file_path);
// Throw on first violation
foreach ($violations as $violation) {
$type = $violation['type'];
$line = $violation['line'];
$code = $violation['code'];
if ($type === 'window_onload') {
$error_message = "Code Quality Violation (JS-READY-01) - Prohibited Window Onload Pattern\n\n";
$error_message .= "window.onload is not allowed. Use ES6 class with lifecycle methods instead.\n\n";
} else {
$error_message = "Code Quality Violation (JS-READY-01) - Prohibited jQuery Ready Pattern\n\n";
$error_message .= "jQuery ready/DOMContentLoaded patterns are not allowed. Use ES6 class with lifecycle methods instead.\n\n";
}
$error_message .= "File: {$file_path}\n";
$error_message .= "Line: {$line}\n";
$error_message .= "Code: {$code}\n\n";
$error_message .= $suggestion;
throw new \App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException(
$error_message,
0,
null,
base_path($file_path),
$line
);
}
}
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class DomMethod_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-DOM-01';
}
public function get_name(): string
{
return 'JavaScript DOM Method Usage Check';
}
public function get_description(): string
{
return 'Enforces jQuery instead of native DOM methods';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Check for document.getElementById
if (preg_match('/\bdocument\.getElementById\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.getElementById(id)' with '$('#' + id)' or use a jQuery selector directly like $('#myId'). " .
"jQuery provides a more consistent and powerful API for DOM manipulation that works across all browsers.",
'medium'
);
}
// Check for document.createElement
if (preg_match('/\bdocument\.createElement\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.createElement(tagName)' with '$('<' + tagName + '>')' or use jQuery element creation like $('<div>'). " .
"jQuery provides a more fluent API for creating and manipulating DOM elements.",
'medium'
);
}
// Check for document.getElementsByClassName
if (preg_match('/\bdocument\.getElementsByClassName\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.getElementsByClassName(className)' with $('.' + className) or use a jQuery class selector directly like $('.myClass'). " .
"jQuery provides a more consistent API that returns a jQuery object with many useful methods.",
'medium'
);
}
// Check for document.getElementsByTagName
if (preg_match('/\bdocument\.getElementsByTagName\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.getElementsByTagName(tagName)' with $(tagName) or use a jQuery tag selector like $('div'). " .
"jQuery provides a unified API for element selection.",
'medium'
);
}
// Check for document.querySelector
if (preg_match('/\bdocument\.querySelector\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.querySelector(selector)' with $(selector). " .
"jQuery's selector engine is more powerful and consistent across browsers.",
'medium'
);
}
// Check for document.querySelectorAll
if (preg_match('/\bdocument\.querySelectorAll\s*\(/i', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_number,
"Use jQuery instead of native DOM methods.",
trim($original_line),
"Replace 'document.querySelectorAll(selector)' with $(selector). " .
"jQuery automatically handles collections and provides chainable methods.",
'medium'
);
}
}
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\InitializationSuggestions;
class FrameworkInitialization_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-INIT-FW-01';
}
public function get_name(): string
{
return 'Framework Code Initialization Pattern Check';
}
public function get_description(): string
{
return 'Enforces proper initialization patterns for framework JavaScript code in /app/RSpade directory';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check JavaScript file for proper framework initialization patterns
* Framework code in /app/RSpade should use _on_framework_* methods
* User methods (on_modules_*, on_app_*) are forbidden in framework code
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in /app/RSpade directory
if (!str_contains($file_path, '/app/RSpade/')) {
return;
}
// Check if it's in an allowed subdirectory
if (!$this->is_in_allowed_rspade_directory($file_path)) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Get original content for pattern detection
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Also get sanitized content to skip comments
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($original_lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments using sanitized version
if (isset($sanitized_lines[$line_num])) {
$sanitized_trimmed = trim($sanitized_lines[$line_num]);
if (empty($sanitized_trimmed)) {
continue; // Skip empty/comment lines
}
}
// Check for user code methods (forbidden in framework code)
$user_methods = [
'on_modules_define',
'on_modules_init',
'on_app_define',
'on_app_init',
'on_app_ready'
];
foreach ($user_methods as $method) {
if (preg_match('/\bstatic\s+(async\s+)?' . preg_quote($method) . '\s*\(\s*\)/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"User initialization method '{$method}' cannot be used in framework code.",
trim($line),
InitializationSuggestions::get_framework_suggestion(),
'critical'
);
}
}
// Check for Rsx.on('ready') pattern
if (preg_match('/\bRsx\s*\.\s*on\s*\(\s*[\'\"]ready[\'\"]/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Rsx.on('ready') is deprecated. Use framework lifecycle methods instead.",
trim($line),
InitializationSuggestions::get_framework_suggestion(),
'high'
);
}
// Check for jQuery ready patterns (should not be in framework code)
if (preg_match('/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line) ||
preg_match('/\$\s*\(\s*function\s*\(/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"jQuery ready patterns are not allowed in framework code. Use framework lifecycle methods.",
trim($line),
InitializationSuggestions::get_framework_suggestion(),
'high'
);
}
// Validate correct framework method usage (informational)
$framework_methods = [
'_on_framework_core_define',
'_on_framework_core_init',
'_on_framework_module_define',
'_on_framework_module_init'
];
foreach ($framework_methods as $method) {
if (preg_match('/\bstatic\s+(async\s+)?' . preg_quote($method) . '\s*\(\s*\)/', $line)) {
// This is correct usage - no violation
// Could log this for validation purposes if needed
}
}
}
}
/**
* Check if a file in /app/RSpade/ is in an allowed subdirectory
* Based on scan_directories configuration
*/
private function is_in_allowed_rspade_directory(string $file_path): bool
{
// Get allowed subdirectories from config
$scan_directories = config('rsx.manifest.scan_directories', []);
// Extract allowed RSpade subdirectories
$allowed_subdirs = [];
foreach ($scan_directories as $scan_dir) {
if (str_starts_with($scan_dir, 'app/RSpade/')) {
$subdir = substr($scan_dir, strlen('app/RSpade/'));
if ($subdir) {
$allowed_subdirs[] = $subdir;
}
}
}
// Check if file is in any allowed subdirectory
foreach ($allowed_subdirs as $subdir) {
if (str_contains($file_path, '/app/RSpade/' . $subdir . '/') ||
str_contains($file_path, '/app/RSpade/' . $subdir)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
use App\RSpade\CodeQuality\Support\InitializationSuggestions;
class InitializationPattern_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-INIT-USER-01';
}
public function get_name(): string
{
return 'User Code Initialization Pattern Check';
}
public function get_description(): string
{
return 'Enforces proper initialization patterns for user JavaScript code in /rsx directory';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check JavaScript file for proper initialization patterns
* User code in /rsx should use on_modules_* or on_app_* methods
* Framework methods (_on_framework_*) are forbidden in user code
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in /rsx directory
if (!str_contains($file_path, '/rsx/')) {
return;
}
// Skip vendor and node_modules
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Get original content for pattern detection
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Also get sanitized content to skip comments
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($original_lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments using sanitized version
if (isset($sanitized_lines[$line_num])) {
$sanitized_trimmed = trim($sanitized_lines[$line_num]);
if (empty($sanitized_trimmed)) {
continue; // Skip empty/comment lines
}
}
// Check for Rsx.on('ready') pattern
if (preg_match('/\bRsx\s*\.\s*on\s*\(\s*[\'"]ready[\'"]/i', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Rsx.on('ready') is deprecated. Use ES6 class lifecycle methods instead.",
trim($line),
InitializationSuggestions::get_user_suggestion(),
'high'
);
}
// Check for framework methods (forbidden in user code)
$framework_methods = [
'_on_framework_core_define',
'_on_framework_core_init',
'_on_framework_module_define',
'_on_framework_module_init'
];
foreach ($framework_methods as $method) {
if (preg_match('/\bstatic\s+(async\s+)?' . preg_quote($method) . '\s*\(\s*\)/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Framework initialization method '{$method}' cannot be used in user code.",
trim($line),
InitializationSuggestions::get_user_suggestion(),
'critical'
);
}
}
// Check for jQuery ready patterns (handled by DocumentReadyRule but add context here)
if (preg_match('/\$\s*\(\s*document\s*\)\s*\.\s*ready\s*\(/', $line) ||
preg_match('/\$\s*\(\s*function\s*\(/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"jQuery ready patterns are not allowed. Use ES6 class lifecycle methods.",
trim($line),
InitializationSuggestions::get_user_suggestion(),
'high'
);
}
}
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQueryLengthCheck_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-LENGTH-01';
}
public function get_name(): string
{
return 'jQuery .length Existence Check';
}
public function get_description(): string
{
return 'Enforces use of .exists() instead of .length for jQuery existence checks';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
// Patterns to detect:
// if($(selector).length)
// if(!$(selector).length)
// if($variable.length)
// if(!$variable.length)
// Also within compound conditions
// Check if line contains 'if' and '.length'
if (str_contains($sanitized_line, 'if') && str_contains($sanitized_line, '.length')) {
// Multiple patterns to check
$patterns = [
// Direct jQuery selector patterns
'/if\s*\(\s*!\s*\$\s*\([^)]+\)\.length/', // if(!$(selector).length
'/if\s*\(\s*\$\s*\([^)]+\)\.length/', // if($(selector).length
// jQuery variable patterns
'/if\s*\(\s*!\s*\$[a-zA-Z_][a-zA-Z0-9_]*\.length/', // if(!$variable.length
'/if\s*\(\s*\$[a-zA-Z_][a-zA-Z0-9_]*\.length/', // if($variable.length
// Within compound conditions (with && or ||)
'/if\s*\([^)]*[&|]{2}[^)]*\$\s*\([^)]+\)\.length/', // compound with $(selector).length
'/if\s*\([^)]*\$\s*\([^)]+\)\.length[^)]*[&|]{2}/', // compound with $(selector).length
'/if\s*\([^)]*[&|]{2}[^)]*\$[a-zA-Z_][a-zA-Z0-9_]*\.length/', // compound with $variable.length
'/if\s*\([^)]*\$[a-zA-Z_][a-zA-Z0-9_]*\.length[^)]*[&|]{2}/', // compound with $variable.length
];
$found = false;
foreach ($patterns as $pattern) {
if (preg_match($pattern, $sanitized_line)) {
$found = true;
break;
}
}
if ($found) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$this->add_violation(
$file_path,
$line_number,
"Use .exists() instead of .length for jQuery existence checks.",
trim($original_line),
"Replace .length with .exists() for checking jQuery element existence. " .
"For example: use '$(selector).exists()' instead of '$(selector).length', " .
"or '\$variable.exists()' instead of '\$variable.length'. " .
"The .exists() method is more semantic and clearly indicates the intent of checking for element presence.",
'medium'
);
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQuerySubmitUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQUERY-SUBMIT-01';
}
public function get_name(): string
{
return 'jQuery .submit() Usage Check';
}
public function get_description(): string
{
return "Detects deprecated jQuery .submit() usage and recommends .trigger('submit') or .requestSubmit()";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'low';
}
/**
* Check JavaScript file for .submit() usage
* Recommends .trigger('submit') or .requestSubmit() instead
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Remove comments and strings to avoid false positives
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized = $sanitized_data['content'];
// Pattern matches:
// $variable.submit()
// $(selector).submit()
// that.$anything.submit()
// this.$anything.submit()
$pattern = '/(\$[a-zA-Z_][a-zA-Z0-9_]*|(?:this|that)\.\$[a-zA-Z0-9_.]+|\$\([^)]+\))\.submit\s*\(/';
preg_match_all($pattern, $sanitized, $matches, PREG_OFFSET_CAPTURE);
if (empty($matches[0])) {
return;
}
$lines = explode("\n", $contents);
foreach ($matches[0] as $match) {
$offset = $match[1];
$matched_text = $match[0];
// Find line number from offset
$line_number = 1;
$current_offset = 0;
foreach ($lines as $index => $line) {
$line_length = strlen($line) + 1; // +1 for newline
if ($current_offset + $line_length > $offset) {
$line_number = $index + 1;
break;
}
$current_offset += $line_length;
}
$code_snippet = trim($lines[$line_number - 1] ?? '');
$this->add_violation(
$file_path,
$line_number,
"Use .trigger('submit') or .requestSubmit() instead of deprecated .submit()",
$code_snippet,
"Replace .submit() with .trigger('submit') for event triggering or .requestSubmit() for actual form submission with validation",
'low'
);
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQueryUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQUERY-01';
}
public function get_name(): string
{
return 'JavaScript jQuery Usage Check';
}
public function get_description(): string
{
return "Enforces use of '$' shorthand instead of 'jQuery' for consistency";
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'low';
}
/**
* Check JavaScript file for 'jQuery' usage instead of '$' (from line 1307)
* Enforces use of '$' shorthand for consistency
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$lines = $sanitized_data['lines'];
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
continue;
}
// Check for 'jQuery.' or 'jQuery(' usage
if (preg_match('/\bjQuery\s*[\.\(]/', $line)) {
$this->add_violation(
$file_path,
$line_number,
"Use '$' instead of 'jQuery' for consistency and brevity.",
trim($line),
"Replace 'jQuery' with '$'.",
'low'
);
}
}
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace App\RSpade\CodeQuality\Rules\JavaScript;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class JQueryVariableNaming_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'JS-JQUERY-VAR-01';
}
public function get_name(): string
{
return 'jQuery Variable Naming Convention';
}
public function get_description(): string
{
return 'Enforces $ prefix for variables storing jQuery objects';
}
public function get_file_patterns(): array
{
return ['*.js'];
}
public function get_default_severity(): string
{
return 'medium';
}
/**
* jQuery methods that return jQuery objects
*/
private const JQUERY_OBJECT_METHODS = [
'parent', 'parents', 'parentsUntil', 'closest',
'find', 'children', 'contents',
'next', 'nextAll', 'nextUntil',
'prev', 'prevAll', 'prevUntil',
'siblings', 'add', 'addBack', 'andSelf',
'end', 'filter', 'not', 'has',
'eq', 'first', 'last', 'slice',
'map', 'clone', 'wrap', 'wrapAll', 'wrapInner',
'unwrap', 'replaceWith', 'replaceAll',
'prepend', 'append', 'prependTo', 'appendTo',
'before', 'after', 'insertBefore', 'insertAfter',
'detach', 'empty', 'remove'
];
/**
* jQuery methods that return scalar values (not jQuery objects)
*/
private const SCALAR_METHODS = [
'data', 'attr', 'val', 'text', 'html', 'prop', 'css',
'offset', 'position', 'scrollTop', 'scrollLeft',
'width', 'height', 'innerWidth', 'innerHeight',
'outerWidth', 'outerHeight',
'index', 'size', 'length', 'get', 'toArray',
'serialize', 'serializeArray',
'is', 'hasClass', 'is_visible' // Custom RSpade methods
];
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Only check files in ./rsx/ directory
if (!str_contains($file_path, '/rsx/') && !str_starts_with($file_path, 'rsx/')) {
return;
}
// Skip vendor and node_modules directories
if (str_contains($file_path, '/vendor/') || str_contains($file_path, '/node_modules/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments removed
$sanitized_data = FileSanitizer::sanitize_javascript($file_path);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version
if (trim($sanitized_line) === '') {
continue;
}
$original_line = $original_lines[$line_num] ?? $sanitized_line;
// Pattern to match variable assignments
// Captures: 1=var declaration, 2=variable name, 3=right side expression
$pattern = '/(?:^|\s)((?:let\s+|const\s+|var\s+)?)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(.+?)(?:;|$)/';
if (preg_match($pattern, $sanitized_line, $matches)) {
$var_decl = $matches[1];
$var_name = $matches[2];
$right_side = trim($matches[3]);
$has_dollar = str_starts_with($var_name, '$');
// Analyze the right side to determine if it returns jQuery object or scalar
$expected_type = $this->analyze_expression($right_side);
if ($expected_type === 'jquery') {
// Should have $ prefix
if (!$has_dollar) {
$this->add_violation(
$file_path,
$line_number,
"jQuery object must be stored in variable starting with $.",
trim($original_line),
"Rename variable '{$var_name}' to '\${$var_name}'. " .
"The expression returns a jQuery object and must be stored in a variable with $ prefix. " .
"In RSpade, $ prefix indicates jQuery objects only.",
'medium'
);
}
} elseif ($expected_type === 'scalar') {
// Should NOT have $ prefix
if ($has_dollar) {
$this->add_violation(
$file_path,
$line_number,
"Scalar values should not use $ prefix.",
trim($original_line),
"Remove $ prefix from variable '{$var_name}'. Rename to '" . substr($var_name, 1) . "'. " .
"The expression returns a scalar value (string, number, boolean, or DOM element), not a jQuery object. " .
"In RSpade, $ prefix is reserved for jQuery objects only.",
'medium'
);
}
}
// If expected_type is 'unknown', we don't enforce either way
}
}
}
/**
* Analyze an expression to determine if it returns jQuery object or scalar
* @return string 'jquery', 'scalar', or 'unknown'
*/
private function analyze_expression(string $expr): string
{
$expr = trim($expr);
// Direct jQuery selector: $(...)
if (preg_match('/^\$\s*\(/', $expr)) {
// Check if followed by method chain
if (preg_match('/^\$\s*\([^)]*\)(.*)/', $expr, $matches)) {
$chain = trim($matches[1]);
if ($chain === '') {
return 'jquery'; // Just $(...) with no methods
}
return $this->analyze_method_chain($chain);
}
return 'jquery';
}
// Variable starting with $ (assumed to be jQuery)
if (preg_match('/^\$[a-zA-Z_][a-zA-Z0-9_]*(.*)/', $expr, $matches)) {
$chain = trim($matches[1]);
if ($chain === '') {
return 'jquery'; // Just $variable with no methods
}
if (str_starts_with($chain, '[')) {
// Array access like $element[0]
return 'scalar';
}
return $this->analyze_method_chain($chain);
}
// Everything else is unknown or definitely not jQuery
return 'unknown';
}
/**
* Analyze a method chain to determine final return type
* @param string $chain The method chain starting with . or [
* @return string 'jquery', 'scalar', or 'unknown'
*/
private function analyze_method_chain(string $chain): string
{
if (empty($chain)) {
return 'jquery'; // No methods means original jQuery object
}
// Array access [0] or [index] returns DOM element (scalar)
if (preg_match('/^\[[\d]+\]/', $chain)) {
return 'scalar';
}
// Find the last method call in the chain
// Match patterns like .method() or .method(args)
$methods = [];
preg_match_all('/\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)/', $chain, $methods);
if (empty($methods[1])) {
// No method calls found
return 'unknown';
}
// Check the last method to determine return type
$last_method = end($methods[1]);
if (in_array($last_method, self::JQUERY_OBJECT_METHODS, true)) {
return 'jquery';
}
if (in_array($last_method, self::SCALAR_METHODS, true)) {
return 'scalar';
}
// Unknown method - could be custom plugin
return 'unknown';
}
}

Some files were not shown because too many files have changed in this diff Show More