From fba3fc4f4ba0af051044b4d5ca0d405598d89da1 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 2 Mar 2026 00:34:33 +0000 Subject: [PATCH 1/4] upd --- docs/{CLAUDE.md => _CLAUDE.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{CLAUDE.md => _CLAUDE.md} (100%) diff --git a/docs/CLAUDE.md b/docs/_CLAUDE.md similarity index 100% rename from docs/CLAUDE.md rename to docs/_CLAUDE.md From 52018ad000a52c062663a39fe465a69eb82b3605 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 2 Mar 2026 00:53:48 +0000 Subject: [PATCH 2/4] PHP deep integration: test coverage hooks, security patterns, import fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — coded with Claude Code. New: - languages/php/test_coverage.py — full test coverage hooks (PHPUnit/Pest assertions, use-statement parsing incl group use, has_testable_logic, is_runtime_entrypoint, map_test_to_source) - languages/php/review_data/dimensions.override.json Fixes across 10 files addressing ~20 PHP gaps: - SECRET_NAME_RE: match $variable assignments (#14) - ENV_LOOKUPS: add env(), getenv(), $_ENV[], config() (#11) - LOG_CALLS: add Log::info/error/etc, error_log() (#13) - RANDOM_CALLS: add rand(), mt_rand() (#12) - Import query: group use App\Models\{User, Post} (#5) - resolve_php_import: actually read composer.json PSR-4 (#4) - Unused imports: handle aliased use Foo as Bar (#6) - class_query: add enum_declaration (#8) - _CLOSURE_NODE_TYPES: add anonymous_function_creation_expression (#9) - _BRANCHING_NODE_TYPES: add match_conditional_expression (#10) - Signature _ALLOWLIST: __construct, boot, register, render, etc (#22) - _infer_lang_name: add .php -> php (#2) - generic_lang: entry_patterns parameter (#3) - PHP __init__: wire test_coverage, entry_patterns, zone_rules, exclude storage/bootstrap/cache/IDE helpers (#1,#16,#19,#20,#24) - Remediation text: add random_bytes() for PHP (#15) Deferred: #17 (barrel_names — N/A), #18 (dynamic_import_finder) Co-Authored-By: Claude Opus 4.6 --- .../engine/detectors/coverage/mapping.py | 2 + .../engine/detectors/patterns/security.py | 14 +- desloppify/engine/detectors/security/rules.py | 2 +- desloppify/engine/detectors/signature.py | 19 + desloppify/languages/_framework/generic.py | 3 +- .../_framework/treesitter/_complexity.py | 4 +- .../_framework/treesitter/_imports.py | 76 +++- .../languages/_framework/treesitter/_specs.py | 8 + .../_framework/treesitter/_unused_imports.py | 42 ++- desloppify/languages/php/__init__.py | 61 +++- .../php/review_data/dimensions.override.json | 21 ++ desloppify/languages/php/test_coverage.py | 324 ++++++++++++++++++ 12 files changed, 565 insertions(+), 11 deletions(-) create mode 100644 desloppify/languages/php/review_data/dimensions.override.json create mode 100644 desloppify/languages/php/test_coverage.py diff --git a/desloppify/engine/detectors/coverage/mapping.py b/desloppify/engine/detectors/coverage/mapping.py index 712c8797..6cb78c55 100644 --- a/desloppify/engine/detectors/coverage/mapping.py +++ b/desloppify/engine/detectors/coverage/mapping.py @@ -31,6 +31,8 @@ def _infer_lang_name(test_files: set[str], production_files: set[str]) -> str | ".js": "typescript", ".jsx": "typescript", ".cs": "csharp", + ".php": "php", + ".go": "go", } counts: dict[str, int] = {} for file_path in paths: diff --git a/desloppify/engine/detectors/patterns/security.py b/desloppify/engine/detectors/patterns/security.py index b3947bb5..ef7ec9cc 100644 --- a/desloppify/engine/detectors/patterns/security.py +++ b/desloppify/engine/detectors/patterns/security.py @@ -55,6 +55,7 @@ r"""(?:^|[\s,;(]) # start of line or delimiter (?:const|let|var|export)? # optional JS/TS keyword \s* + \$? # optional PHP $ sigil ([A-Za-z_]\w*) # variable name (captured) \s*[:=]\s* # assignment (['"`]) # opening quote @@ -96,10 +97,19 @@ "import.meta.env", "os.environ.get(", "os.environ[", + # PHP + "env(", + "getenv(", + "$_ENV[", + "$_SERVER[", + "config(", ) # Insecure random usage near security-sensitive contexts. -RANDOM_CALLS = re.compile(r"(?:Math\.random|random\.random|random\.randint)\s*\(") +RANDOM_CALLS = re.compile( + r"(?:Math\.random|random\.random|random\.randint" + r"|(? LangConfig: """Build and register a generic language plugin from tool specs. @@ -247,7 +248,7 @@ def generic_lang( exclusions=exclude or [], default_src=default_src, build_dep_graph=dep_graph_fn, - entry_patterns=[], + entry_patterns=entry_patterns or [], barrel_names=set(), phases=phases, fixers=fixers, diff --git a/desloppify/languages/_framework/treesitter/_complexity.py b/desloppify/languages/_framework/treesitter/_complexity.py index 87796790..5f3df131 100644 --- a/desloppify/languages/_framework/treesitter/_complexity.py +++ b/desloppify/languages/_framework/treesitter/_complexity.py @@ -171,7 +171,7 @@ def compute(content: str, lines: list[str], *, _filepath: str = "") -> tuple[int "for_statement", "for_expression", "for_in_statement", "while_statement", "while_expression", "do_statement", "loop_expression", - "case_clause", "match_arm", + "case_clause", "match_arm", "match_conditional_expression", "catch_clause", "rescue", "except_clause", "ternary_expression", "conditional_expression", # Logical operators (short-circuit evaluation). @@ -306,6 +306,8 @@ def compute(content: str, lines: list[str], *, _filepath: str = "") -> tuple[int "anonymous_function", "block_argument", # Go anonymous functions "func_literal", + # PHP anonymous functions (``function() { ... }``) + "anonymous_function_creation_expression", }) diff --git a/desloppify/languages/_framework/treesitter/_imports.py b/desloppify/languages/_framework/treesitter/_imports.py index 9025cf4b..a524d524 100644 --- a/desloppify/languages/_framework/treesitter/_imports.py +++ b/desloppify/languages/_framework/treesitter/_imports.py @@ -63,6 +63,17 @@ def ts_build_dep_graph( # Strip surrounding quotes if present. import_text = import_text.strip("\"'`") + # Prepend group-use prefix when present (PHP ``use A\B\{C, D}``). + prefix_node = _unwrap_node(captures.get("prefix")) + if prefix_node is not None: + prefix_raw = prefix_node.text + prefix_text = ( + prefix_raw.decode("utf-8", errors="replace") + if isinstance(prefix_raw, bytes) + else str(prefix_raw) + ).strip("\"'`") + import_text = f"{prefix_text}\\{import_text}" + resolved = spec.resolve_import(import_text, filepath, scan_path) if resolved is None: continue @@ -289,17 +300,74 @@ def resolve_cxx_include(import_text: str, source_file: str, scan_path: str) -> s return None +_PHP_COMPOSER_CACHE: dict[str, dict[str, str]] = {} + + +def _read_composer_psr4(scan_path: str) -> dict[str, str]: + """Read PSR-4 autoload mappings from composer.json, cached per scan_path. + + Returns ``{namespace_prefix: directory}`` e.g. ``{"App\\\\": "app/"}``. + """ + if scan_path in _PHP_COMPOSER_CACHE: + return _PHP_COMPOSER_CACHE[scan_path] + + mappings: dict[str, str] = {} + composer_path = os.path.join(scan_path, "composer.json") + try: + import json + + with open(composer_path) as f: + data = json.load(f) + for section in ("autoload", "autoload-dev"): + psr4 = data.get(section, {}).get("psr-4", {}) + for prefix, dirs in psr4.items(): + # dirs can be a string or list of strings + if isinstance(dirs, str): + mappings[prefix] = dirs + elif isinstance(dirs, list) and dirs: + mappings[prefix] = dirs[0] + except (OSError, ValueError, KeyError): + pass + _PHP_COMPOSER_CACHE[scan_path] = mappings + return mappings + + def resolve_php_import(import_text: str, source_file: str, scan_path: str) -> str | None: - """Resolve PHP use statements via PSR-4-like mapping. + """Resolve PHP use statements via PSR-4 mapping. + + 1. Reads composer.json autoload psr-4 mappings (cached). + 2. Falls back to common PSR-4 roots (src/, app/, lib/). - Maps `App\\Models\\User` → src/Models/User.php or app/Models/User.php. + Maps ``App\\Models\\User`` → ``app/Models/User.php``. """ - # Parse composer.json for autoload mapping if available. parts = import_text.replace("\\", "/").split("/") if len(parts) < 2: return None - # Try common PSR-4 roots. + # Try composer.json PSR-4 mappings first. + psr4 = _read_composer_psr4(scan_path) + if psr4: + # Reconstruct backslash-separated namespace for prefix matching. + ns = import_text.replace("/", "\\") + if not ns.endswith("\\"): + ns_lookup = ns + else: + ns_lookup = ns + + for prefix, directory in sorted(psr4.items(), key=lambda x: -len(x[0])): + # Normalize prefix: ensure trailing backslash + norm_prefix = prefix.rstrip("\\") + "\\" + if ns_lookup.startswith(norm_prefix) or ns_lookup + "\\" == norm_prefix: + remainder = ns_lookup[len(norm_prefix):] + if not remainder: + continue + rel_path = remainder.replace("\\", os.sep) + ".php" + candidate = os.path.join(scan_path, directory, rel_path) + candidate = os.path.normpath(candidate) + if os.path.isfile(candidate): + return candidate + + # Fallback: try common PSR-4 roots by stripping namespace prefixes. for prefix_len in range(1, min(3, len(parts))): rel_path = os.path.join(*parts[prefix_len:]) + ".php" for src_root in ["src", "app", "lib", "."]: diff --git a/desloppify/languages/_framework/treesitter/_specs.py b/desloppify/languages/_framework/treesitter/_specs.py index 7c94e09b..fef772f4 100644 --- a/desloppify/languages/_framework/treesitter/_specs.py +++ b/desloppify/languages/_framework/treesitter/_specs.py @@ -248,6 +248,11 @@ (namespace_use_declaration (namespace_use_clause (qualified_name) @path)) @import + (namespace_use_declaration + (namespace_name) @prefix + (namespace_use_group + (namespace_use_clause + (name) @path))) @import """, resolve_import=resolve_php_import, class_query=""" @@ -260,6 +265,9 @@ (trait_declaration name: (name) @name body: (declaration_list) @body) @class + (enum_declaration + name: (name) @name + body: (enum_declaration_list) @body) @class """, log_patterns=( r"^\s*(?:echo |print |var_dump|error_log|Log::)", diff --git a/desloppify/languages/_framework/treesitter/_unused_imports.py b/desloppify/languages/_framework/treesitter/_unused_imports.py index 87fa535a..40868654 100644 --- a/desloppify/languages/_framework/treesitter/_unused_imports.py +++ b/desloppify/languages/_framework/treesitter/_unused_imports.py @@ -61,8 +61,12 @@ def detect_unused_imports( if not raw_path: continue + # Check for alias (e.g. PHP ``use Foo as Bar``, Python ``import X as Y``). + # When an alias is present, search for the alias name instead. + alias_name = _extract_alias(import_node) + # Extract the imported name from the path. - name = _extract_import_name(raw_path) + name = alias_name or _extract_import_name(raw_path) if not name: continue @@ -85,6 +89,42 @@ def detect_unused_imports( return entries +def _extract_alias(import_node) -> str | None: + """Extract alias name from ``use Foo as Bar`` or ``import X as Y``. + + Walks children of the import node looking for an alias/name node + after an ``as`` keyword. Returns the alias text or None. + """ + found_as = False + for child in _iter_children(import_node): + text = _node_text(child) + if text == "as": + found_as = True + continue + # The node immediately after "as" is the alias name. + if found_as and child.type in ("name", "identifier", "namespace_name"): + return _node_text(child) + return None + + +def _iter_children(node): + """Recursively yield terminal-ish children relevant to alias extraction. + + Only descends into namespace_use_clause / import_clause nodes (the + immediate import container) — avoids descending into unrelated subtrees. + """ + for i in range(node.child_count): + child = node.children[i] + # Yield leaf-like nodes (keywords, identifiers). + if child.child_count == 0: + yield child + elif child.type in ( + "namespace_use_clause", "import_clause", + "namespace_alias", "as_pattern", + ): + yield from _iter_children(child) + + def _extract_import_name(import_path: str) -> str: """Extract the usable name from an import path. diff --git a/desloppify/languages/php/__init__.py b/desloppify/languages/php/__init__.py index 290b56b3..33bc3d64 100644 --- a/desloppify/languages/php/__init__.py +++ b/desloppify/languages/php/__init__.py @@ -1,7 +1,58 @@ -"""PHP language plugin — phpstan.""" +"""PHP language plugin — phpstan + tree-sitter + Laravel-aware hooks.""" +from desloppify.engine.policy.zones import COMMON_ZONE_RULES, Zone, ZoneRule from desloppify.languages._framework.generic import generic_lang from desloppify.languages._framework.treesitter import PHP_SPEC +from desloppify.languages.php import test_coverage as _test_coverage_mod + +# ── Zone rules ──────────────────────────────────────────────── + +PHP_ZONE_RULES = [ + ZoneRule(Zone.GENERATED, ["/database/migrations/", "/storage/", "/bootstrap/cache/"]), + ZoneRule(Zone.TEST, [ + "/tests/", "Test.php", "TestCase.php", + "/factories/", "Pest.php", + ]), + ZoneRule(Zone.CONFIG, [ + "/config/", "composer.json", ".env", ".env.example", + "phpunit.xml", "phpstan.neon", "webpack.mix.js", "vite.config.js", + ]), + ZoneRule(Zone.SCRIPT, [ + "artisan", + "/database/seeders/", + ]), +] + COMMON_ZONE_RULES + +# ── Entry patterns (files legitimately having zero importers) ─ + +PHP_ENTRY_PATTERNS = [ + # Laravel runtime entrypoints + "/routes/", + "/app/Http/Controllers/", + "/app/Console/Commands/", + "/app/Http/Middleware/", + "/app/Providers/", + "/app/Jobs/", + "/app/Listeners/", + "/app/Mail/", + "/app/Notifications/", + "/app/Policies/", + "/app/Events/", + "/app/Observers/", + "/app/Rules/", + "/app/Casts/", + # Test files + "/tests/", + "Test.php", + # Config / bootstrap + "/config/", + "/database/migrations/", + "/database/seeders/", + "/database/factories/", + "/resources/views/", +] + +# ── Plugin registration ────────────────────────────────────── generic_lang( name="php", @@ -16,8 +67,14 @@ "fix_cmd": None, }, ], - exclude=["vendor"], + exclude=[ + "vendor", "storage", "bootstrap/cache", "node_modules", + "_ide_helper.php", "_ide_helper_models.php", ".phpstorm.meta.php", + ], depth="shallow", detect_markers=["composer.json"], treesitter_spec=PHP_SPEC, + zone_rules=PHP_ZONE_RULES, + test_coverage_module=_test_coverage_mod, + entry_patterns=PHP_ENTRY_PATTERNS, ) diff --git a/desloppify/languages/php/review_data/dimensions.override.json b/desloppify/languages/php/review_data/dimensions.override.json new file mode 100644 index 00000000..6d7817db --- /dev/null +++ b/desloppify/languages/php/review_data/dimensions.override.json @@ -0,0 +1,21 @@ +{ + "dimension_prompts": { + "abstraction_fitness": { + "description": "PHP abstraction fitness: favor explicit service classes, single-responsibility controllers, and direct model operations over deep inheritance, trait soup, and facade indirection.", + "look_for": [ + "Controllers with business logic that should live in service/action classes", + "Trait-heavy designs where traits share mutable state or override each other", + "God models with dozens of relationships, scopes, and accessors", + "Facade calls scattered through domain logic instead of injected dependencies", + "Repository wrappers that add no query logic beyond forwarding to Eloquent" + ], + "skip": [ + "Laravel framework conventions (service providers, middleware, form requests)", + "Eloquent accessors/mutators/casts on the model they belong to", + "Facade usage in config, routes, and service provider boot methods", + "Trait usage for standard Laravel concerns (HasFactory, SoftDeletes, Notifiable)" + ] + } + }, + "system_prompt_append": "PHP/Laravel anchor checks: fat controllers mixing HTTP and domain logic, god models with 10+ relationships, trait-heavy designs with overlapping state, repository wrappers that only forward to Eloquent, and scattered Facade calls in domain services." +} diff --git a/desloppify/languages/php/test_coverage.py b/desloppify/languages/php/test_coverage.py new file mode 100644 index 00000000..15a751c5 --- /dev/null +++ b/desloppify/languages/php/test_coverage.py @@ -0,0 +1,324 @@ +"""PHP-specific test coverage heuristics and mappings. + +Supports PHPUnit (tests/Feature/, tests/Unit/) and Pest naming conventions. +Handles ``use`` statement parsing for import-based coverage mapping. +""" + +from __future__ import annotations + +import os +import re + +# ── Assertion / mock / snapshot patterns ────────────────────── + +ASSERT_PATTERNS = [ + re.compile(p) + for p in [ + r"\$this->assert\w+\(", + r"\$this->expect\w*\(", + r"\bexpect\s*\(", + r"\bassertTrue\b", + r"\bassertFalse\b", + r"\bassertEquals\b", + r"\bassertSame\b", + r"\bassertCount\b", + r"\bassertNull\b", + r"\bassertNotNull\b", + r"\bassertInstanceOf\b", + r"\bassertContains\b", + r"\bassertThrows\b", + r"->assertStatus\(", + r"->assertJson\(", + r"->assertRedirect\(", + r"->assertSee\(", + r"->assertSessionHas\(", + r"->assertDatabaseHas\(", + r"->assertDatabaseMissing\(", + ] +] + +MOCK_PATTERNS = [ + re.compile(p) + for p in [ + r"\$this->mock\(", + r"\$this->partialMock\(", + r"\bMockery::mock\(", + r"\bMockery::spy\(", + r"->shouldReceive\(", + r"->expects\(\$this->", + r"\$this->createMock\(", + r"\bMock::handler\(", + ] +] + +SNAPSHOT_PATTERNS: list[re.Pattern[str]] = [ + re.compile(r"->assertMatchesSnapshot\("), + re.compile(r"->toMatchSnapshot\("), +] + +TEST_FUNCTION_RE = re.compile( + r"(?m)(?:" + r"^\s*(?:public\s+)?function\s+(test\w+)\s*\(" # PHPUnit method + r"|^\s*/\*\*[^*]*@test[^/]*/\s*\n\s*(?:public\s+)?function\s+" # @test docblock + r"|^\s*(?:it|test)\s*\(" # Pest test/it + r")" +) + +# PHP has no barrel files. +BARREL_BASENAMES: set[str] = set() + + +# ── PHP use-statement parser ────────────────────────────────── + +# Matches: use App\Models\User; +# Matches: use App\Models\{User, Provider}; +# Matches: use App\Models\User as UserModel; +_PHP_USE_RE = re.compile( + r"^\s*use\s+" + r"([\w\\]+)" # namespace prefix + r"(?:" + r"\\\{([^}]+)\}" # group import list + r"|(?:\s+as\s+\w+)?" # optional alias (ignored for resolution) + r")\s*;", + re.MULTILINE, +) + + +def parse_test_import_specs(content: str) -> list[str]: + """Extract fully-qualified class names from PHP ``use`` statements. + + Handles both simple ``use App\\Models\\User;`` and group + ``use App\\Models\\{User, Provider};`` forms. + """ + specs: list[str] = [] + for m in _PHP_USE_RE.finditer(content): + prefix = m.group(1) + group = m.group(2) + if group: + for part in group.split(","): + part = part.strip() + if part: + specs.append(f"{prefix}\\{part}") + else: + specs.append(prefix) + return specs + + +# ── Testable logic heuristic ───────────────────────────────── + +# Files containing only interface/abstract declarations, config arrays, +# migrations with up()/down(), or empty classes lack testable logic. +_PHP_FUNCTION_RE = re.compile( + r"(?m)^\s*(?:public|protected|private|static|\s)*\s*function\s+\w+\s*\(", +) +_PHP_INTERFACE_ONLY_RE = re.compile( + r"(?m)^\s*(?:interface|abstract\s+class)\s+", +) +_PHP_CONFIG_RETURN_RE = re.compile( + r"(?ms)\A\s*<\?php\s*\n\s*(?:/\*.*?\*/\s*\n\s*)?return\s+\[", +) + + +def has_testable_logic(filepath: str, content: str) -> bool: + """Return True when a PHP file contains runtime logic worth testing. + + Returns False for: + - Interface-only files + - Config files (return [...]) + - Migration stubs (only up/down) + - Files with no function definitions + """ + lowered = filepath.replace("\\", "/").lower() + + # Config files: bare return array + if _PHP_CONFIG_RETURN_RE.search(content): + return False + + # Interface/abstract-only: no concrete method bodies + if _PHP_INTERFACE_ONLY_RE.search(content): + # If there's a concrete function body, it's testable + if "function " in content and "{" in content.split("function ", 1)[1][:200]: + pass # has body, keep going + else: + return False + + # Migration stubs: only up() and down() + if "/migrations/" in lowered or "database/migrations" in lowered: + funcs = _PHP_FUNCTION_RE.findall(content) + func_names = { + re.search(r"function\s+(\w+)", f).group(1) # type: ignore[union-attr] + for f in funcs + if re.search(r"function\s+(\w+)", f) + } + if func_names <= {"up", "down"}: + return False + + return bool(_PHP_FUNCTION_RE.search(content)) + + +# ── Runtime entrypoint detection ───────────────────────────── + +_ENTRYPOINT_PATH_PATTERNS = [ + "routes/", + "app/http/controllers/", + "app/console/commands/", + "app/http/middleware/", + "app/providers/", + "app/jobs/", + "app/listeners/", + "app/mail/", + "app/notifications/", + "app/policies/", +] + +_ENTRYPOINT_CONTENT_PATTERNS = [ + re.compile(r"class\s+\w+\s+extends\s+(?:Controller|Command|Job|Mailable|Notification)\b"), + re.compile(r"class\s+\w+\s+implements\s+ShouldQueue\b"), + re.compile(r"Route::\w+\("), + re.compile(r"Artisan::command\("), +] + + +def is_runtime_entrypoint(filepath: str, content: str) -> bool: + """Detect PHP runtime entrypoints (controllers, commands, jobs, routes, etc.).""" + lowered = filepath.replace("\\", "/").lower() + + for pattern in _ENTRYPOINT_PATH_PATTERNS: + if pattern in lowered: + return True + + return any(pat.search(content) for pat in _ENTRYPOINT_CONTENT_PATTERNS) + + +# ── Test-to-source mapping ─────────────────────────────────── + + +def map_test_to_source(test_path: str, production_set: set[str]) -> str | None: + """Map a PHP test file to its production counterpart by naming convention. + + Handles PHPUnit conventions: + - tests/Unit/Models/UserTest.php → app/Models/User.php + - tests/Feature/Http/Controllers/UserControllerTest.php → app/Http/Controllers/UserController.php + """ + basename = os.path.basename(test_path) + if not basename.endswith("Test.php"): + return None + + src_basename = basename[:-8] + ".php" # strip "Test.php", add ".php" + + # Try direct basename match first + for prod in production_set: + if os.path.basename(prod) == src_basename: + return prod + + # Try path-based mapping: tests/Unit/X → app/X, tests/Feature/X → app/X + normalized = test_path.replace("\\", "/") + for test_prefix in ("tests/Unit/", "tests/Feature/", "tests/"): + idx = normalized.find(test_prefix) + if idx == -1: + continue + rel_from_test = normalized[idx + len(test_prefix):] + # Replace Test.php suffix + if rel_from_test.endswith("Test.php"): + rel_from_test = rel_from_test[:-8] + ".php" + for src_prefix in ("app/", "src/", ""): + candidate_suffix = src_prefix + rel_from_test + for prod in production_set: + if prod.replace("\\", "/").endswith(candidate_suffix): + return prod + + return None + + +def strip_test_markers(basename: str) -> str | None: + """Strip PHP test naming markers to derive source basename. + + UserTest.php → User.php + """ + if basename.endswith("Test.php"): + return basename[:-8] + ".php" + return None + + +def resolve_import_spec( + spec: str, test_path: str, production_files: set[str] +) -> str | None: + """Resolve a PHP fully-qualified class name to a production file path. + + Maps ``App\\Models\\User`` → any production file ending in Models/User.php. + """ + # Convert namespace to path segments + parts = spec.replace("\\", "/").split("/") + if len(parts) < 2: + return None + + # Try matching progressively shorter suffixes (skip vendor prefixes) + for prefix_len in range(1, min(3, len(parts))): + suffix = "/".join(parts[prefix_len:]) + ".php" + for prod in production_files: + if prod.replace("\\", "/").endswith(suffix): + return prod + + return None + + +def resolve_barrel_reexports( + _filepath: str, _production_files: set[str] +) -> set[str]: + """PHP has no barrel-file re-export expansion.""" + return set() + + +# ── Comment stripping ──────────────────────────────────────── + + +def strip_comments(content: str) -> str: + """Strip PHP comments (// and /* */) while preserving string literals.""" + out: list[str] = [] + in_block = False + in_string: str | None = None + i = 0 + while i < len(content): + ch = content[i] + nxt = content[i + 1] if i + 1 < len(content) else "" + + if in_block: + if ch == "\n": + out.append("\n") + if ch == "*" and nxt == "/": + in_block = False + i += 2 + continue + i += 1 + continue + + if in_string is not None: + out.append(ch) + if ch == "\\" and i + 1 < len(content): + out.append(content[i + 1]) + i += 2 + continue + if ch == in_string: + in_string = None + i += 1 + continue + + if ch in ('"', "'"): + in_string = ch + out.append(ch) + i += 1 + continue + + if ch == "/" and nxt == "*": + in_block = True + i += 2 + continue + if ch == "/" and nxt == "/": + while i < len(content) and content[i] != "\n": + i += 1 + continue + + out.append(ch) + i += 1 + + return "".join(out) From c845047c179cadf0d123f1d0cf41c26161704cd2 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 2 Mar 2026 04:21:21 +0000 Subject: [PATCH 3/4] PHP: capture trait use declarations in import/dep graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trait `use` inside class bodies (use_declaration) was invisible to the dep graph — trait files only referenced this way appeared orphaned. - Add use_declaration query patterns for both bare names and FQNs - Strip leading backslash from FQN trait uses (\App\Traits\X) - Resolve bare trait names (HasUuid) via filesystem search in app/src/lib - Cache bare-name lookups to avoid repeated walks Co-Authored-By: Claude Opus 4.6 --- .../_framework/treesitter/_imports.py | 33 ++++++++++++++++++- .../languages/_framework/treesitter/_specs.py | 4 +++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/desloppify/languages/_framework/treesitter/_imports.py b/desloppify/languages/_framework/treesitter/_imports.py index a524d524..0eccb139 100644 --- a/desloppify/languages/_framework/treesitter/_imports.py +++ b/desloppify/languages/_framework/treesitter/_imports.py @@ -300,6 +300,27 @@ def resolve_cxx_include(import_text: str, source_file: str, scan_path: str) -> s return None +_PHP_FILE_CACHE: dict[tuple[str, str], str | None] = {} + + +def _find_php_file(filename: str, scan_path: str) -> str | None: + """Search common PHP source roots for *filename*, cached.""" + key = (filename, scan_path) + if key in _PHP_FILE_CACHE: + return _PHP_FILE_CACHE[key] + for root in ("app", "src", "lib"): + root_dir = os.path.join(scan_path, root) + if not os.path.isdir(root_dir): + continue + for dirpath, _dirs, files in os.walk(root_dir): + if filename in files: + result = os.path.join(dirpath, filename) + _PHP_FILE_CACHE[key] = result + return result + _PHP_FILE_CACHE[key] = None + return None + + _PHP_COMPOSER_CACHE: dict[str, dict[str, str]] = {} @@ -337,12 +358,22 @@ def resolve_php_import(import_text: str, source_file: str, scan_path: str) -> st 1. Reads composer.json autoload psr-4 mappings (cached). 2. Falls back to common PSR-4 roots (src/, app/, lib/). + 3. For bare trait names, searches common directories for ``Name.php``. Maps ``App\\Models\\User`` → ``app/Models/User.php``. """ + # Strip leading backslash from FQNs (e.g. ``\App\Traits\HasRoles``). + import_text = import_text.lstrip("\\") + parts = import_text.replace("\\", "/").split("/") + + # Bare name (e.g. trait ``use HasUuid;``) — search common dirs. if len(parts) < 2: - return None + name = parts[0] if parts else "" + if not name or not name[0].isupper(): + return None + return _find_php_file(name + ".php", scan_path) + # Try composer.json PSR-4 mappings first. psr4 = _read_composer_psr4(scan_path) diff --git a/desloppify/languages/_framework/treesitter/_specs.py b/desloppify/languages/_framework/treesitter/_specs.py index fef772f4..404d0daf 100644 --- a/desloppify/languages/_framework/treesitter/_specs.py +++ b/desloppify/languages/_framework/treesitter/_specs.py @@ -253,6 +253,10 @@ (namespace_use_group (namespace_use_clause (name) @path))) @import + (use_declaration + (qualified_name) @path) @import + (use_declaration + (name) @path) @import """, resolve_import=resolve_php_import, class_query=""" From de7c8f67f012a74c392d6bb4b348a1990851e8e1 Mon Sep 17 00:00:00 2001 From: IK Date: Tue, 3 Mar 2026 02:51:21 +0000 Subject: [PATCH 4/4] fix: PHP entry patterns never matched (leading / vs relative paths) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `orphaned.py` checks `any(p in r for p in entry_patterns)` where `r = rel(filepath)` returns relative paths like `app/Http/Controllers/Foo.php`. All 24 PHP entry patterns had leading `/` — substring check always failed, zero patterns matched. Every Laravel file flagged orphaned. Also added missing Laravel convention-loaded directories: Models, Enums, Actions, Filament, Livewire, View/Components, Http/Requests, Exceptions, bootstrap, public, lang. Extended test_coverage entrypoint patterns and content-based detection (FormRequest, Resource). Tested against two real Laravel codebases (sologaming, zalon). Code quality: 51% → 97% on both projects. Co-Authored-By: Claude Opus 4.6 --- desloppify/languages/php/__init__.py | 56 ++++++++++++++--------- desloppify/languages/php/test_coverage.py | 9 +++- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/desloppify/languages/php/__init__.py b/desloppify/languages/php/__init__.py index 33bc3d64..a814c65e 100644 --- a/desloppify/languages/php/__init__.py +++ b/desloppify/languages/php/__init__.py @@ -26,30 +26,42 @@ # ── Entry patterns (files legitimately having zero importers) ─ PHP_ENTRY_PATTERNS = [ - # Laravel runtime entrypoints - "/routes/", - "/app/Http/Controllers/", - "/app/Console/Commands/", - "/app/Http/Middleware/", - "/app/Providers/", - "/app/Jobs/", - "/app/Listeners/", - "/app/Mail/", - "/app/Notifications/", - "/app/Policies/", - "/app/Events/", - "/app/Observers/", - "/app/Rules/", - "/app/Casts/", + # Laravel runtime entrypoints (no leading / — rel() returns relative paths) + "routes/", + "app/Http/Controllers/", + "app/Http/Middleware/", + "app/Http/Requests/", + "app/Console/Commands/", + "app/Providers/", + "app/Jobs/", + "app/Listeners/", + "app/Mail/", + "app/Notifications/", + "app/Policies/", + "app/Events/", + "app/Observers/", + "app/Rules/", + "app/Casts/", + "app/Exceptions/", + # Convention-loaded by Laravel / packages (zero explicit importers) + "app/Models/", + "app/Enums/", + "app/Actions/", + "app/Filament/", + "app/Livewire/", + "app/View/Components/", # Test files - "/tests/", + "tests/", "Test.php", - # Config / bootstrap - "/config/", - "/database/migrations/", - "/database/seeders/", - "/database/factories/", - "/resources/views/", + # Config / bootstrap / public + "config/", + "database/migrations/", + "database/seeders/", + "database/factories/", + "resources/views/", + "bootstrap/", + "public/", + "lang/", ] # ── Plugin registration ────────────────────────────────────── diff --git a/desloppify/languages/php/test_coverage.py b/desloppify/languages/php/test_coverage.py index 15a751c5..35776f2d 100644 --- a/desloppify/languages/php/test_coverage.py +++ b/desloppify/languages/php/test_coverage.py @@ -161,18 +161,23 @@ def has_testable_logic(filepath: str, content: str) -> bool: _ENTRYPOINT_PATH_PATTERNS = [ "routes/", "app/http/controllers/", - "app/console/commands/", "app/http/middleware/", + "app/http/requests/", + "app/console/commands/", "app/providers/", "app/jobs/", "app/listeners/", "app/mail/", "app/notifications/", "app/policies/", + "app/events/", + "app/observers/", + "app/filament/", + "app/livewire/", ] _ENTRYPOINT_CONTENT_PATTERNS = [ - re.compile(r"class\s+\w+\s+extends\s+(?:Controller|Command|Job|Mailable|Notification)\b"), + re.compile(r"class\s+\w+\s+extends\s+(?:Controller|Command|Job|Mailable|Notification|FormRequest|Resource)\b"), re.compile(r"class\s+\w+\s+implements\s+ShouldQueue\b"), re.compile(r"Route::\w+\("), re.compile(r"Artisan::command\("),