Skip to content

Commit ae547cd

Browse files
authored
feat(php): add filesystem walk-up detection for Laravel & WordPress + structured logging
Implements parent-directory walk-up framework detection for deeply nested PHP files: Laravel detection: - Walk UP parent dirs looking for `artisan` or `composer.json` with `laravel/framework` - Results cached per starting directory via `laravelCache` on CodeAnalyzer - maxWalkUpDepth=10 prevents unbounded traversal WordPress detection: - Walk UP parent dirs looking for `wp-config.php`, `wp-content`, `wp-includes`, `wp-admin` - Plugin/theme header detection reads only first 4KB (`readFilePrefix`) - maxWalkUpDepth=10 prevents unbounded traversal Structured logging: - Replaced all `fmt.Fprintf(os.Stderr, ...)` with `logger.Instance.{Debug,Info,Warn,Error}` - Per-file enrichment logs set to Debug to avoid log flooding during indexing - Startup/registration logs remain at Info level Review fixes (PR #44 Copilot review - all 10 comments addressed): - Added maxDepth limit to both WP and Laravel walk-up functions - Limited file reads to 4KB prefix for header detection - Fixed isLaravelByFilesystem caching (cache starting dir, not just root) - Removed unused `wordpressCache` field from CodeAnalyzer - Added 16 unit tests (8 WordPress + 8 Laravel filesystem walk-up) Version bump: 2.1.86
2 parents a976a84 + b80969c commit ae547cd

10 files changed

Lines changed: 675 additions & 24 deletions

File tree

cmd/rag-code-mcp/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
)
1717

1818
var (
19-
Version = "2.1.80"
19+
Version = "2.1.86"
2020
Commit = "none"
2121
Date = "24.10.2025"
2222
)

pkg/parser/php/analyzer.go

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,24 @@ import (
1515
"github.com/VKCOM/php-parser/pkg/visitor"
1616
"github.com/VKCOM/php-parser/pkg/visitor/traverser"
1717

18+
"github.com/doITmagic/rag-code-mcp/internal/logger"
1819
pkgParser "github.com/doITmagic/rag-code-mcp/pkg/parser"
1920
)
2021

2122
// CodeAnalyzer implements PathAnalyzer for PHP
2223
type CodeAnalyzer struct {
2324
currentNamespace string
2425
packages map[string]*PackageInfo
26+
27+
// Cached framework detection results (per directory → result)
28+
laravelCache map[string]bool
2529
}
2630

2731
// NewCodeAnalyzer creates a new PHP code analyzer
2832
func NewCodeAnalyzer() *CodeAnalyzer {
2933
return &CodeAnalyzer{
30-
packages: make(map[string]*PackageInfo),
34+
packages: make(map[string]*PackageInfo),
35+
laravelCache: make(map[string]bool),
3136
}
3237
}
3338

@@ -79,12 +84,12 @@ func (ca *CodeAnalyzer) AnalyzePaths(paths []string) ([]CodeChunk, error) {
7984

8085
content, err := os.ReadFile(path)
8186
if err != nil {
82-
fmt.Fprintf(os.Stderr, "Warning: failed to read %s: %v\n", path, err)
87+
logger.Instance.Warn("[PHP] failed to read %s: %v", path, err)
8388
return nil
8489
}
8590

8691
if err := ca.parseAndCollect(path, content); err != nil {
87-
fmt.Fprintf(os.Stderr, "Warning: failed to analyze %s: %v\n", path, err)
92+
logger.Instance.Warn("[PHP] failed to analyze %s: %v", path, err)
8893
}
8994
return nil
9095
})
@@ -136,13 +141,13 @@ func (ca *CodeAnalyzer) parseAndCollect(filePath string, content []byte) error {
136141
if len(parserErrors) > 0 {
137142
// Only log first few errors to avoid spam
138143
maxErrors := 3
139-
fmt.Fprintf(os.Stderr, "PHP parser warnings in %s:\n", filePath)
144+
logger.Instance.Debug("[PHP] parser warnings in %s:", filePath)
140145
for i, e := range parserErrors {
141146
if i >= maxErrors {
142-
fmt.Fprintf(os.Stderr, " ... and %d more\n", len(parserErrors)-maxErrors)
147+
logger.Instance.Debug("[PHP] ... and %d more", len(parserErrors)-maxErrors)
143148
break
144149
}
145-
fmt.Fprintf(os.Stderr, " %s\n", e.String())
150+
logger.Instance.Debug("[PHP] %s", e.String())
146151
}
147152
}
148153

@@ -1198,15 +1203,13 @@ func (v *symbolCollector) walkExpr(expr ast.Vertex, calls *[]MethodCall) {
11981203

11991204
// IsLaravelProject detects if the analyzed code is from a Laravel project
12001205
func (ca *CodeAnalyzer) IsLaravelProject() bool {
1206+
// 1. Quick check: namespace/class-based detection from parsed packages
12011207
for _, pkg := range ca.packages {
1202-
// Check for Laravel-specific namespaces
12031208
if strings.HasPrefix(pkg.Namespace, "App\\Models") ||
12041209
strings.HasPrefix(pkg.Namespace, "App\\Http\\Controllers") ||
12051210
strings.HasPrefix(pkg.Namespace, "Illuminate\\") {
12061211
return true
12071212
}
1208-
1209-
// Check for Laravel base classes
12101213
for _, class := range pkg.Classes {
12111214
if class.Extends == "Model" ||
12121215
class.Extends == "Controller" ||
@@ -1216,6 +1219,72 @@ func (ca *CodeAnalyzer) IsLaravelProject() bool {
12161219
}
12171220
}
12181221
}
1222+
1223+
// 2. Filesystem walk-up: check for "artisan" file by walking parent dirs
1224+
for _, pkg := range ca.packages {
1225+
for _, class := range pkg.Classes {
1226+
if class.FilePath != "" {
1227+
if ca.isLaravelByFilesystem(class.FilePath) {
1228+
return true
1229+
}
1230+
}
1231+
}
1232+
for _, fn := range pkg.Functions {
1233+
if fn.FilePath != "" {
1234+
if ca.isLaravelByFilesystem(fn.FilePath) {
1235+
return true
1236+
}
1237+
}
1238+
}
1239+
}
1240+
return false
1241+
}
1242+
1243+
// maxLaravelWalkUpDepth limits how many parent directories the walk-up detection traverses.
1244+
const maxLaravelWalkUpDepth = 10
1245+
1246+
// isLaravelByFilesystem walks up from filePath checking for Laravel root indicators.
1247+
// Results are cached per starting directory to avoid repeated stat calls.
1248+
func (ca *CodeAnalyzer) isLaravelByFilesystem(filePath string) bool {
1249+
startDir := filepath.Dir(filePath)
1250+
// Check cache for the starting directory first
1251+
if result, ok := ca.laravelCache[startDir]; ok {
1252+
return result
1253+
}
1254+
1255+
dir := startDir
1256+
for depth := 0; depth < maxLaravelWalkUpDepth; depth++ {
1257+
// Check cache for this specific directory
1258+
if result, ok := ca.laravelCache[dir]; ok {
1259+
// Cache the result for the starting directory as well
1260+
ca.laravelCache[startDir] = result
1261+
return result
1262+
}
1263+
1264+
// Check for artisan (the strongest Laravel indicator)
1265+
if _, err := os.Stat(filepath.Join(dir, "artisan")); err == nil {
1266+
ca.laravelCache[dir] = true
1267+
ca.laravelCache[startDir] = true
1268+
return true
1269+
}
1270+
1271+
// Also check for composer.json with laravel/framework
1272+
composerPath := filepath.Join(dir, "composer.json")
1273+
if content, err := os.ReadFile(composerPath); err == nil {
1274+
if strings.Contains(string(content), "laravel/framework") {
1275+
ca.laravelCache[dir] = true
1276+
ca.laravelCache[startDir] = true
1277+
return true
1278+
}
1279+
}
1280+
1281+
parent := filepath.Dir(dir)
1282+
if parent == dir {
1283+
break // reached filesystem root
1284+
}
1285+
dir = parent
1286+
}
1287+
ca.laravelCache[startDir] = false
12191288
return false
12201289
}
12211290

pkg/parser/php/laravel/enricher.go

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package laravel
22

33
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/doITmagic/rag-code-mcp/internal/logger"
49
"github.com/doITmagic/rag-code-mcp/pkg/parser/php"
510
)
611

@@ -15,32 +20,109 @@ func init() {
1520
})
1621
}
1722

18-
// IsApplicable checks if the parsed paths correspond to a Laravel project
23+
// IsApplicable checks if the parsed paths correspond to a Laravel project.
24+
// ca.IsLaravelProject() already includes both namespace/class-based AND filesystem walk-up detection,
25+
// so we only need to call it once. If packages are empty (no classes/functions parsed),
26+
// fall back to direct path-based filesystem walk-up.
1927
func (e *Enricher) IsApplicable(ca *php.CodeAnalyzer, paths []string) bool {
20-
return ca.IsLaravelProject()
28+
// 1. namespace/class-based + cached filesystem walk from parsed packages
29+
byPackages := ca.IsLaravelProject()
30+
logger.Instance.Debug("[LARAVEL] IsApplicable: ca.IsLaravelProject()=%v for paths=%v", byPackages, paths)
31+
if byPackages {
32+
return true
33+
}
34+
35+
// 2. Fallback: direct path-based filesystem walk (useful when no packages were parsed)
36+
byFS := IsLaravelProjectByPaths(paths)
37+
logger.Instance.Debug("[LARAVEL] IsApplicable: IsLaravelProjectByPaths()=%v for paths=%v", byFS, paths)
38+
return byFS
39+
}
40+
41+
// IsLaravelProjectByPaths walks UP parent directories from the given paths
42+
// looking for Laravel root indicators (artisan file, composer.json with laravel/framework).
43+
func IsLaravelProjectByPaths(paths []string) bool {
44+
for _, p := range paths {
45+
dir := p
46+
info, err := os.Stat(p)
47+
if err != nil {
48+
logger.Instance.Debug("[LARAVEL] IsLaravelProjectByPaths: stat error for %s: %v", p, err)
49+
continue
50+
}
51+
if !info.IsDir() {
52+
dir = filepath.Dir(p)
53+
}
54+
55+
if isLaravelRoot(dir) {
56+
logger.Instance.Debug("[LARAVEL] IsLaravelProjectByPaths: FOUND Laravel root walking up from %s", dir)
57+
return true
58+
}
59+
logger.Instance.Debug("[LARAVEL] IsLaravelProjectByPaths: NO Laravel root found walking up from %s", dir)
60+
}
61+
return false
62+
}
63+
64+
// maxWalkUpDepth limits how many parent directories the walk-up detection traverses.
65+
// This prevents scanning unrelated parts of the host filesystem for deeply nested paths.
66+
const maxWalkUpDepth = 10
67+
68+
// isLaravelRoot walks UP from dir checking each parent for Laravel indicators.
69+
// Stops after maxWalkUpDepth levels to avoid traversing unrelated directories.
70+
func isLaravelRoot(dir string) bool {
71+
for depth := 0; depth < maxWalkUpDepth; depth++ {
72+
// Check for artisan (the strongest Laravel indicator)
73+
artisanPath := filepath.Join(dir, "artisan")
74+
if _, err := os.Stat(artisanPath); err == nil {
75+
logger.Instance.Debug("[LARAVEL] isLaravelRoot: FOUND artisan at %s", artisanPath)
76+
return true
77+
}
78+
79+
// Check for composer.json with laravel/framework
80+
composerPath := filepath.Join(dir, "composer.json")
81+
if content, err := os.ReadFile(composerPath); err == nil {
82+
if strings.Contains(string(content), "laravel/framework") {
83+
logger.Instance.Debug("[LARAVEL] isLaravelRoot: FOUND laravel/framework in %s", composerPath)
84+
return true
85+
}
86+
}
87+
88+
parent := filepath.Dir(dir)
89+
if parent == dir {
90+
break // reached filesystem root
91+
}
92+
dir = parent
93+
}
94+
return false
2195
}
2296

2397
// Enrich receives the base PHP chunks and analyzed packages and returns chunks merged with Laravel specifics
2498
func (e *Enricher) Enrich(ca *php.CodeAnalyzer, packages []*php.PackageInfo, paths []string, chunks []php.CodeChunk) []php.CodeChunk {
99+
logger.Instance.Debug("[LARAVEL] Enrich: %d packages, %d paths, %d existing chunks", len(packages), len(paths), len(chunks))
100+
25101
// Run Laravel-specific package analysis for Controllers and Eloquent Models
26102
for _, pkg := range packages {
27103
analyzer := NewAnalyzer(pkg)
28104
info := analyzer.Analyze()
105+
logger.Instance.Debug("[LARAVEL] Enrich pkg=%s: models=%d controllers=%d", pkg.Namespace, len(info.Models), len(info.Controllers))
29106

30107
// Enrich existing chunks with Laravel context (table, fillable, api routes)
31108
e.adapter.enrichChunks(chunks, info)
32109
}
33110

34-
// Analyze Routes (these are handled separately since they are mostly top level closures inside routes/)
111+
// Analyze Routes
35112
routeFiles := e.adapter.findRouteFiles(paths)
113+
logger.Instance.Debug("[LARAVEL] Enrich: found %d route files from paths=%v", len(routeFiles), paths)
36114
if len(routeFiles) > 0 {
37115
routeAnalyzer := NewRouteAnalyzer()
38116
routes, err := routeAnalyzer.Analyze(routeFiles)
39117
if err == nil {
40118
routeChunks := e.adapter.convertRoutesToChunks(routes)
119+
logger.Instance.Debug("[LARAVEL] Enrich: %d routes → %d chunks", len(routes), len(routeChunks))
41120
chunks = append(chunks, routeChunks...)
121+
} else {
122+
logger.Instance.Error("[LARAVEL] Enrich: route analysis error: %v", err)
42123
}
43124
}
44125

126+
logger.Instance.Debug("[LARAVEL] Enrich DONE: returning %d total chunks", len(chunks))
45127
return chunks
46128
}

0 commit comments

Comments
 (0)