diff --git a/gitnexus-web/public/wasm/php/tree-sitter-php.wasm b/gitnexus-web/public/wasm/php/tree-sitter-php.wasm new file mode 100644 index 000000000..12f3c445c Binary files /dev/null and b/gitnexus-web/public/wasm/php/tree-sitter-php.wasm differ diff --git a/gitnexus-web/src/config/supported-languages.ts b/gitnexus-web/src/config/supported-languages.ts index 15df37c54..a9bcd8248 100644 --- a/gitnexus-web/src/config/supported-languages.ts +++ b/gitnexus-web/src/config/supported-languages.ts @@ -8,7 +8,7 @@ export enum SupportedLanguages { CSharp = 'csharp', Go = 'go', Rust = 'rust', - // PHP = 'php', + PHP = 'php', // Ruby = 'ruby', // Swift = 'swift', } \ No newline at end of file diff --git a/gitnexus-web/src/core/ingestion/entry-point-scoring.ts b/gitnexus-web/src/core/ingestion/entry-point-scoring.ts index 1ef3d3ddc..9285e407d 100644 --- a/gitnexus-web/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus-web/src/core/ingestion/entry-point-scoring.ts @@ -102,6 +102,27 @@ const ENTRY_POINT_PATTERNS: Record = { /^Run$/, // Run methods /^Start$/, // Start methods ], + + // PHP / Laravel + 'php': [ + /Controller$/, // UserController (class name convention) + /^handle$/, // Job::handle(), Listener::handle() + /^execute$/, // Command::execute() + /^boot$/, // ServiceProvider::boot() + /^register$/, // ServiceProvider::register() + /^__invoke$/, // Invokable controllers/actions + /^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods + /^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions + /^run$/, // Command/Job run() + /^fire$/, // Event fire() + /^dispatch$/, // Dispatchable jobs + /Service$/, // UserService (Service layer) + /Repository$/, // UserRepository (Repository pattern) + /^find$/, // Repository::find() + /^findAll$/, // Repository::findAll() + /^save$/, // Repository::save() + /^delete$/, // Repository::delete() + ], }; // ============================================================================ @@ -252,7 +273,12 @@ export function isTestFile(filePath: string): boolean { p.includes('/tests/') || // C# test patterns p.includes('.tests/') || - p.includes('tests.cs') + p.includes('tests.cs') || + // PHP/Laravel test patterns + p.endsWith('test.php') || + p.endsWith('spec.php') || + p.includes('/tests/feature/') || + p.includes('/tests/unit/') ); } diff --git a/gitnexus-web/src/core/ingestion/framework-detection.ts b/gitnexus-web/src/core/ingestion/framework-detection.ts index d3c75ab87..4aec27f7c 100644 --- a/gitnexus-web/src/core/ingestion/framework-detection.ts +++ b/gitnexus-web/src/core/ingestion/framework-detection.ts @@ -195,8 +195,70 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'c-cpp', entryPointMultiplier: 2.5, reason: 'c-app' }; } + // ========== PHP / LARAVEL FRAMEWORKS ========== + + // Laravel routes (highest - these ARE the entry point definitions) + if (p.includes('/routes/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-routes' }; + } + + // Laravel controllers (very high - receive HTTP requests) + if ((p.includes('/http/controllers/') || p.includes('/controllers/')) && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-controller' }; + } + + // Laravel controller by file name convention + if (p.endsWith('controller.php')) { + return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-controller-file' }; + } + + // Laravel console commands + if ((p.includes('/console/commands/') || p.includes('/commands/')) && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-command' }; + } + + // Laravel jobs (queue entry points) + if (p.includes('/jobs/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-job' }; + } + + // Laravel listeners (event-driven entry points) + if (p.includes('/listeners/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-listener' }; + } + + // Laravel middleware + if (p.includes('/http/middleware/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-middleware' }; + } + + // Laravel service providers + if (p.includes('/providers/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.8, reason: 'laravel-provider' }; + } + + // Laravel policies + if (p.includes('/policies/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.0, reason: 'laravel-policy' }; + } + + // Laravel models (important but not entry points per se) + if (p.includes('/models/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-model' }; + } + + // Laravel services (Service Repository pattern) + if (p.includes('/services/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.8, reason: 'laravel-service' }; + } + + // Laravel repositories (Service Repository pattern) + if (p.includes('/repositories/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' }; + } + // ========== GENERIC PATTERNS ========== - + // Any language: index files in API folders if (p.includes('/api/') && ( p.endsWith('/index.ts') || p.endsWith('/index.js') || @@ -235,7 +297,11 @@ export const FRAMEWORK_AST_PATTERNS = { // Go patterns (function signatures) 'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'], - + + // PHP/Laravel + 'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete', + 'Route::resource', 'Route::apiResource', '#[Route('], + // Rust macros 'actix': ['#[get', '#[post', '#[put', '#[delete'], 'axum': ['Router::new'], diff --git a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts index a931b4a40..5cb2d46ff 100644 --- a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts @@ -317,6 +317,85 @@ export const RUST_QUERIES = ` (impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage `; +// PHP queries - works with tree-sitter-php (php_only grammar) +export const PHP_QUERIES = ` +; ── Namespace ──────────────────────────────────────────────────────────────── +(namespace_definition + name: (namespace_name) @name) @definition.namespace + +; ── Classes ────────────────────────────────────────────────────────────────── +(class_declaration + name: (name) @name) @definition.class + +; ── Interfaces ─────────────────────────────────────────────────────────────── +(interface_declaration + name: (name) @name) @definition.interface + +; ── Traits ─────────────────────────────────────────────────────────────────── +(trait_declaration + name: (name) @name) @definition.trait + +; ── Enums (PHP 8.1) ────────────────────────────────────────────────────────── +(enum_declaration + name: (name) @name) @definition.enum + +; ── Top-level functions ─────────────────────────────────────────────────────── +(function_definition + name: (name) @name) @definition.function + +; ── Methods (including constructors) ───────────────────────────────────────── +(method_declaration + name: (name) @name) @definition.method + +; ── Class properties (including Eloquent $fillable, $casts, etc.) ──────────── +(property_declaration + (property_element + (variable_name + (name) @name))) @definition.property + +; ── Imports: use statements ────────────────────────────────────────────────── +; Simple: use App\\Models\\User; +(namespace_use_declaration + (namespace_use_clause + (qualified_name) @import.source)) @import + +; ── Function/method calls ──────────────────────────────────────────────────── +; Regular function call: foo() +(function_call_expression + function: (name) @call.name) @call + +; Method call: $obj->method() +(member_call_expression + name: (name) @call.name) @call + +; Nullsafe method call: $obj?->method() +(nullsafe_member_call_expression + name: (name) @call.name) @call + +; Static call: Foo::bar() (php_only uses scoped_call_expression) +(scoped_call_expression + name: (name) @call.name) @call + +; ── Heritage: extends ──────────────────────────────────────────────────────── +(class_declaration + name: (name) @heritage.class + (base_clause + [(name) (qualified_name)] @heritage.extends)) @heritage + +; ── Heritage: implements ───────────────────────────────────────────────────── +(class_declaration + name: (name) @heritage.class + (class_interface_clause + [(name) (qualified_name)] @heritage.implements)) @heritage.impl + +; ── Heritage: use trait (must capture enclosing class name) ────────────────── +(class_declaration + name: (name) @heritage.class + body: (declaration_list + (use_declaration + [(name) (qualified_name)] @heritage.trait))) @heritage +`; + export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES, [SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES, @@ -327,5 +406,6 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.CPlusPlus]: CPP_QUERIES, [SupportedLanguages.CSharp]: CSHARP_QUERIES, [SupportedLanguages.Rust]: RUST_QUERIES, + [SupportedLanguages.PHP]: PHP_QUERIES, }; \ No newline at end of file diff --git a/gitnexus-web/src/core/ingestion/utils.ts b/gitnexus-web/src/core/ingestion/utils.ts index 959eb55dc..c53fa4248 100644 --- a/gitnexus-web/src/core/ingestion/utils.ts +++ b/gitnexus-web/src/core/ingestion/utils.ts @@ -25,6 +25,12 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | if (filename.endsWith('.go')) return SupportedLanguages.Go; // Rust if (filename.endsWith('.rs')) return SupportedLanguages.Rust; + // PHP (all common extensions) + if (filename.endsWith('.php') || filename.endsWith('.phtml') || + filename.endsWith('.php3') || filename.endsWith('.php4') || + filename.endsWith('.php5') || filename.endsWith('.php8')) { + return SupportedLanguages.PHP; + } return null; }; diff --git a/gitnexus-web/src/core/tree-sitter/parser-loader.ts b/gitnexus-web/src/core/tree-sitter/parser-loader.ts index d5224ca4e..e38e8d5d2 100644 --- a/gitnexus-web/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus-web/src/core/tree-sitter/parser-loader.ts @@ -39,6 +39,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string => [SupportedLanguages.CSharp]: '/wasm/csharp/tree-sitter-csharp.wasm', [SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm', [SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm', + [SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm', }; return languageFileMap[language]; diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 4d555d47b..b2f14dea1 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -30,6 +30,7 @@ "tree-sitter-go": "^0.21.0", "tree-sitter-java": "^0.21.0", "tree-sitter-javascript": "^0.21.0", + "tree-sitter-php": "^0.23.12", "tree-sitter-python": "^0.21.0", "tree-sitter-rust": "^0.21.0", "tree-sitter-typescript": "^0.21.0", @@ -4378,6 +4379,34 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/tree-sitter-php": { + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz", + "integrity": "sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-php/node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/tree-sitter-python": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 94f56946a..04b20e88a 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -62,6 +62,7 @@ "tree-sitter-go": "^0.21.0", "tree-sitter-java": "^0.21.0", "tree-sitter-javascript": "^0.21.0", + "tree-sitter-php": "^0.23.12", "tree-sitter-python": "^0.21.0", "tree-sitter-rust": "^0.21.0", "tree-sitter-typescript": "^0.21.0", diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index 15df37c54..a9bcd8248 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -8,7 +8,7 @@ export enum SupportedLanguages { CSharp = 'csharp', Go = 'go', Rust = 'rust', - // PHP = 'php', + PHP = 'php', // Ruby = 'ruby', // Swift = 'swift', } \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index 55d0b1035..ed328cc13 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -102,6 +102,27 @@ const ENTRY_POINT_PATTERNS: Record = { /^Run$/, // Run methods /^Start$/, // Start methods ], + + // PHP / Laravel + 'php': [ + /Controller$/, // UserController (class name convention) + /^handle$/, // Job::handle(), Listener::handle() + /^execute$/, // Command::execute() + /^boot$/, // ServiceProvider::boot() + /^register$/, // ServiceProvider::register() + /^__invoke$/, // Invokable controllers/actions + /^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods + /^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions + /^run$/, // Command/Job run() + /^fire$/, // Event fire() + /^dispatch$/, // Dispatchable jobs + /Service$/, // UserService (Service layer) + /Repository$/, // UserRepository (Repository pattern) + /^find$/, // Repository::find() + /^findAll$/, // Repository::findAll() + /^save$/, // Repository::save() + /^delete$/, // Repository::delete() + ], }; // ============================================================================ @@ -252,7 +273,12 @@ export function isTestFile(filePath: string): boolean { p.includes('/tests/') || // C# test patterns p.includes('.tests/') || - p.includes('tests.cs') + p.includes('tests.cs') || + // PHP/Laravel test patterns + p.endsWith('test.php') || + p.endsWith('spec.php') || + p.includes('/tests/feature/') || + p.includes('/tests/unit/') ); } diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index d3c75ab87..4aec27f7c 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -195,8 +195,70 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'c-cpp', entryPointMultiplier: 2.5, reason: 'c-app' }; } + // ========== PHP / LARAVEL FRAMEWORKS ========== + + // Laravel routes (highest - these ARE the entry point definitions) + if (p.includes('/routes/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-routes' }; + } + + // Laravel controllers (very high - receive HTTP requests) + if ((p.includes('/http/controllers/') || p.includes('/controllers/')) && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-controller' }; + } + + // Laravel controller by file name convention + if (p.endsWith('controller.php')) { + return { framework: 'laravel', entryPointMultiplier: 3.0, reason: 'laravel-controller-file' }; + } + + // Laravel console commands + if ((p.includes('/console/commands/') || p.includes('/commands/')) && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-command' }; + } + + // Laravel jobs (queue entry points) + if (p.includes('/jobs/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-job' }; + } + + // Laravel listeners (event-driven entry points) + if (p.includes('/listeners/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-listener' }; + } + + // Laravel middleware + if (p.includes('/http/middleware/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.5, reason: 'laravel-middleware' }; + } + + // Laravel service providers + if (p.includes('/providers/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.8, reason: 'laravel-provider' }; + } + + // Laravel policies + if (p.includes('/policies/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 2.0, reason: 'laravel-policy' }; + } + + // Laravel models (important but not entry points per se) + if (p.includes('/models/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-model' }; + } + + // Laravel services (Service Repository pattern) + if (p.includes('/services/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.8, reason: 'laravel-service' }; + } + + // Laravel repositories (Service Repository pattern) + if (p.includes('/repositories/') && p.endsWith('.php')) { + return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' }; + } + // ========== GENERIC PATTERNS ========== - + // Any language: index files in API folders if (p.includes('/api/') && ( p.endsWith('/index.ts') || p.endsWith('/index.js') || @@ -235,7 +297,11 @@ export const FRAMEWORK_AST_PATTERNS = { // Go patterns (function signatures) 'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'], - + + // PHP/Laravel + 'laravel': ['Route::get', 'Route::post', 'Route::put', 'Route::delete', + 'Route::resource', 'Route::apiResource', '#[Route('], + // Rust macros 'actix': ['#[get', '#[post', '#[put', '#[delete'], 'axum': ['Router::new'], diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 1040201a0..6ab4213d9 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -122,6 +122,37 @@ async function loadGoModulePath(repoRoot: string): Promise directory (e.g., "App\\" -> "app/") */ + psr4: Map; +} + +async function loadComposerConfig(repoRoot: string): Promise { + try { + const composerPath = path.join(repoRoot, 'composer.json'); + const raw = await fs.readFile(composerPath, 'utf-8'); + const composer = JSON.parse(raw); + const psr4Raw = composer.autoload?.['psr-4'] ?? {}; + const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {}; + const merged = { ...psr4Raw, ...psr4Dev }; + + const psr4 = new Map(); + for (const [ns, dir] of Object.entries(merged)) { + const nsNorm = (ns as string).replace(/\\+$/, ''); + const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, ''); + psr4.set(nsNorm, dirNorm); + } + + if (isDev) { + console.log(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`); + } + return { psr4 }; + } catch { + return null; + } +} + // ============================================================================ // IMPORT PATH RESOLUTION // ============================================================================ @@ -143,6 +174,8 @@ const EXTENSIONS = [ '.go', // Rust '.rs', '/mod.rs', + // PHP + '.php', '.phtml', ]; /** @@ -581,6 +614,48 @@ function resolveGoPackage( return matches; } +// ============================================================================ +// PHP PSR-4 IMPORT RESOLUTION +// ============================================================================ + +/** + * Resolve a PHP use-statement import path using PSR-4 mappings. + * e.g. "App\Http\Controllers\UserController" -> "app/Http/Controllers/UserController.php" + */ +function resolvePhpImport( + importPath: string, + composerConfig: ComposerConfig | null, + allFiles: Set, + normalizedFileList: string[], + allFileList: string[], + index?: SuffixIndex, +): string | null { + // Normalize: replace backslashes with forward slashes + const normalized = importPath.replace(/\\/g, '/'); + + // Try PSR-4 resolution if composer.json was found + if (composerConfig) { + // Sort namespaces by length descending (longest match wins) + const sorted = [...composerConfig.psr4.entries()].sort((a, b) => b[0].length - a[0].length); + for (const [nsPrefix, dirPrefix] of sorted) { + const nsPrefixSlash = nsPrefix.replace(/\\/g, '/'); + if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) { + const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, ''); + const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php'; + if (allFiles.has(filePath)) return filePath; + if (index) { + const result = index.getInsensitive(filePath); + if (result) return result; + } + } + } + } + + // Fallback: suffix matching (works without composer.json) + const pathParts = normalized.split('/').filter(Boolean); + return suffixResolve(pathParts, normalizedFileList, allFileList, index); +} + // ============================================================================ // MAIN IMPORT PROCESSOR // ============================================================================ @@ -612,6 +687,7 @@ export const processImports = async ( const effectiveRoot = repoRoot || ''; const tsconfigPaths = await loadTsconfigPaths(effectiveRoot); const goModule = await loadGoModulePath(effectiveRoot); + const composerConfig = await loadComposerConfig(effectiveRoot); // Helper: add an IMPORTS edge + update import map const addImportEdge = (filePath: string, resolvedPath: string) => { @@ -736,6 +812,15 @@ export const processImports = async ( // Fall through if no files found (package might be external) } + // ---- PHP: handle namespace-based imports (use statements) ---- + if (language === SupportedLanguages.PHP) { + const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index); + if (resolved) { + addImportEdge(file.path, resolved); + } + return; + } + // ---- Standard single-file resolution ---- const resolvedPath = resolveImportPath( file.path, @@ -785,6 +870,7 @@ export const processImportsFromExtracted = async ( const effectiveRoot = repoRoot || ''; const tsconfigPaths = await loadTsconfigPaths(effectiveRoot); const goModule = await loadGoModulePath(effectiveRoot); + const composerConfig = await loadComposerConfig(effectiveRoot); const addImportEdge = (filePath: string, resolvedPath: string) => { const sourceId = generateId('File', filePath); @@ -884,6 +970,16 @@ export const processImportsFromExtracted = async ( } } + // PHP: handle namespace-based imports (use statements) + if (language === SupportedLanguages.PHP) { + const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index); + if (resolved) { + resolveCache.set(cacheKey, resolved); + addImportEdge(filePath, resolved); + } + continue; + } + // Standard resolution (has its own internal cache) const resolvedPath = resolveImportPath( filePath, diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f8bcd7add..ff4f8f28f 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -317,6 +317,85 @@ export const RUST_QUERIES = ` (impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage `; +// PHP queries - works with tree-sitter-php (php_only grammar) +export const PHP_QUERIES = ` +; ── Namespace ──────────────────────────────────────────────────────────────── +(namespace_definition + name: (namespace_name) @name) @definition.namespace + +; ── Classes ────────────────────────────────────────────────────────────────── +(class_declaration + name: (name) @name) @definition.class + +; ── Interfaces ─────────────────────────────────────────────────────────────── +(interface_declaration + name: (name) @name) @definition.interface + +; ── Traits ─────────────────────────────────────────────────────────────────── +(trait_declaration + name: (name) @name) @definition.trait + +; ── Enums (PHP 8.1) ────────────────────────────────────────────────────────── +(enum_declaration + name: (name) @name) @definition.enum + +; ── Top-level functions ─────────────────────────────────────────────────────── +(function_definition + name: (name) @name) @definition.function + +; ── Methods (including constructors) ───────────────────────────────────────── +(method_declaration + name: (name) @name) @definition.method + +; ── Class properties (including Eloquent $fillable, $casts, etc.) ──────────── +(property_declaration + (property_element + (variable_name + (name) @name))) @definition.property + +; ── Imports: use statements ────────────────────────────────────────────────── +; Simple: use App\\Models\\User; +(namespace_use_declaration + (namespace_use_clause + (qualified_name) @import.source)) @import + +; ── Function/method calls ──────────────────────────────────────────────────── +; Regular function call: foo() +(function_call_expression + function: (name) @call.name) @call + +; Method call: $obj->method() +(member_call_expression + name: (name) @call.name) @call + +; Nullsafe method call: $obj?->method() +(nullsafe_member_call_expression + name: (name) @call.name) @call + +; Static call: Foo::bar() (php_only uses scoped_call_expression) +(scoped_call_expression + name: (name) @call.name) @call + +; ── Heritage: extends ──────────────────────────────────────────────────────── +(class_declaration + name: (name) @heritage.class + (base_clause + [(name) (qualified_name)] @heritage.extends)) @heritage + +; ── Heritage: implements ───────────────────────────────────────────────────── +(class_declaration + name: (name) @heritage.class + (class_interface_clause + [(name) (qualified_name)] @heritage.implements)) @heritage.impl + +; ── Heritage: use trait (must capture enclosing class name) ────────────────── +(class_declaration + name: (name) @heritage.class + body: (declaration_list + (use_declaration + [(name) (qualified_name)] @heritage.trait))) @heritage +`; + export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES, [SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES, @@ -327,5 +406,6 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.CPlusPlus]: CPP_QUERIES, [SupportedLanguages.CSharp]: CSHARP_QUERIES, [SupportedLanguages.Rust]: RUST_QUERIES, + [SupportedLanguages.PHP]: PHP_QUERIES, }; \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 620fac7ac..12b4c6b3e 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -31,6 +31,12 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | if (filename.endsWith('.go')) return SupportedLanguages.Go; // Rust if (filename.endsWith('.rs')) return SupportedLanguages.Rust; + // PHP (all common extensions) + if (filename.endsWith('.php') || filename.endsWith('.phtml') || + filename.endsWith('.php3') || filename.endsWith('.php4') || + filename.endsWith('.php5') || filename.endsWith('.php8')) { + return SupportedLanguages.PHP; + } return null; }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 2eee2c5bc..ff985ad4c 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -9,6 +9,7 @@ import CPP from 'tree-sitter-cpp'; import CSharp from 'tree-sitter-c-sharp'; import Go from 'tree-sitter-go'; import Rust from 'tree-sitter-rust'; +import PHP from 'tree-sitter-php'; import { SupportedLanguages } from '../../../config/supported-languages.js'; import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js'; import { getLanguageFromFilename } from '../utils.js'; @@ -28,6 +29,7 @@ interface ParsedNode { endLine: number; language: string; isExported: boolean; + description?: string; }; } @@ -100,6 +102,7 @@ const languageMap: Record = { [SupportedLanguages.CSharp]: CSharp, [SupportedLanguages.Go]: Go, [SupportedLanguages.Rust]: Rust, + [SupportedLanguages.PHP]: PHP.php_only, }; const setLanguage = (language: SupportedLanguages, filePath: string): void => { @@ -185,6 +188,24 @@ const isNodeExported = (node: any, name: string, language: string): boolean => { case 'cpp': return false; + case 'php': + // Top-level classes/interfaces/traits are always accessible + // Methods/properties are exported only if they have 'public' modifier + while (current) { + if (current.type === 'class_declaration' || + current.type === 'interface_declaration' || + current.type === 'trait_declaration' || + current.type === 'enum_declaration') { + return true; + } + if (current.type === 'visibility_modifier') { + return current.text === 'public'; + } + current = current.parent; + } + // Top-level functions (no parent class) are globally accessible + return true; + default: return false; } @@ -200,6 +221,7 @@ const FUNCTION_NODE_TYPES = new Set([ 'function_definition', 'async_function_declaration', 'async_arrow_function', 'method_declaration', 'constructor_declaration', 'local_function_statement', 'function_item', 'impl_item', + 'anonymous_function_creation_expression', // PHP anonymous functions ]); /** Walk up AST to find enclosing function, return its generateId or null for top-level */ @@ -297,6 +319,23 @@ const BUILT_INS = new Set([ 'mutex_lock', 'mutex_unlock', 'mutex_init', 'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree', 'get', 'put', + // PHP built-ins + 'echo', 'isset', 'empty', 'unset', 'list', 'array', 'compact', 'extract', + 'count', 'strlen', 'strpos', 'strrpos', 'substr', 'strtolower', 'strtoupper', 'trim', + 'ltrim', 'rtrim', 'str_replace', 'str_contains', 'str_starts_with', 'str_ends_with', + 'sprintf', 'vsprintf', 'printf', 'number_format', + 'array_map', 'array_filter', 'array_reduce', 'array_push', 'array_pop', 'array_shift', + 'array_unshift', 'array_slice', 'array_splice', 'array_merge', 'array_keys', 'array_values', + 'array_key_exists', 'in_array', 'array_search', 'array_unique', 'usort', 'rsort', + 'json_encode', 'json_decode', 'serialize', 'unserialize', + 'intval', 'floatval', 'strval', 'boolval', 'is_null', 'is_string', 'is_int', 'is_array', + 'is_object', 'is_numeric', 'is_bool', 'is_float', + 'var_dump', 'print_r', 'var_export', + 'date', 'time', 'strtotime', 'mktime', 'microtime', + 'file_exists', 'file_get_contents', 'file_put_contents', 'is_file', 'is_dir', + 'preg_match', 'preg_match_all', 'preg_replace', 'preg_split', + 'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean', + 'dd', 'dump', ]); // ============================================================================ @@ -409,6 +448,109 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n return result; }; +// ============================================================================ +// PHP Eloquent metadata extraction +// ============================================================================ + +/** Eloquent model properties whose array values are worth indexing */ +const ELOQUENT_ARRAY_PROPS = new Set(['fillable', 'casts', 'hidden', 'guarded', 'with', 'appends']); + +/** Eloquent relationship method names */ +const ELOQUENT_RELATIONS = new Set([ + 'hasMany', 'hasOne', 'belongsTo', 'belongsToMany', + 'morphTo', 'morphMany', 'morphOne', 'morphToMany', 'morphedByMany', + 'hasManyThrough', 'hasOneThrough', +]); + +function findDescendant(node: any, type: string): any { + if (node.type === type) return node; + for (const child of (node.children ?? [])) { + const found = findDescendant(child, type); + if (found) return found; + } + return null; +} + +function extractStringContent(node: any): string | null { + if (!node) return null; + const content = node.children?.find((c: any) => c.type === 'string_content'); + if (content) return content.text; + if (node.type === 'string_content') return node.text; + return null; +} + +/** + * For a PHP property_declaration node, extract array values as a description string. + * Returns null if not an Eloquent model property or no array values found. + */ +function extractPhpPropertyDescription(propName: string, propDeclNode: any): string | null { + if (!ELOQUENT_ARRAY_PROPS.has(propName)) return null; + + const arrayNode = findDescendant(propDeclNode, 'array_creation_expression'); + if (!arrayNode) return null; + + const items: string[] = []; + for (const child of (arrayNode.children ?? [])) { + if (child.type !== 'array_element_initializer') continue; + const children = child.children ?? []; + const arrowIdx = children.findIndex((c: any) => c.type === '=>'); + if (arrowIdx !== -1) { + // key => value pair (used in $casts) + const key = extractStringContent(children[arrowIdx - 1]); + const val = extractStringContent(children[arrowIdx + 1]); + if (key && val) items.push(`${key}:${val}`); + } else { + // Simple value (used in $fillable, $hidden, etc.) + const val = extractStringContent(children[0]); + if (val) items.push(val); + } + } + + return items.length > 0 ? items.join(', ') : null; +} + +/** + * For a PHP method_declaration node, detect if it defines an Eloquent relationship. + * Returns description like "hasMany(Post)" or null. + */ +function extractEloquentRelationDescription(methodNode: any): string | null { + function findRelationCall(node: any): any { + if (node.type === 'member_call_expression') { + const children = node.children ?? []; + const objectNode = children.find((c: any) => c.type === 'variable_name' && c.text === '$this'); + const nameNode = children.find((c: any) => c.type === 'name'); + if (objectNode && nameNode && ELOQUENT_RELATIONS.has(nameNode.text)) return node; + } + for (const child of (node.children ?? [])) { + const found = findRelationCall(child); + if (found) return found; + } + return null; + } + + const callNode = findRelationCall(methodNode); + if (!callNode) return null; + + const relType = callNode.children?.find((c: any) => c.type === 'name')?.text; + const argsNode = callNode.children?.find((c: any) => c.type === 'arguments'); + let targetModel: string | null = null; + if (argsNode) { + const firstArg = argsNode.children?.find((c: any) => c.type === 'argument'); + if (firstArg) { + const classConstant = firstArg.children?.find((c: any) => + c.type === 'class_constant_access_expression' + ); + if (classConstant) { + targetModel = classConstant.children?.find((c: any) => c.type === 'name')?.text ?? null; + } + } + } + + if (relType && targetModel) return `${relType}(${targetModel})`; + if (relType) return relType; + return null; +} + const processFileGroup = ( files: ParseWorkerInput[], language: SupportedLanguages, @@ -515,6 +657,15 @@ const processFileGroup = ( const nodeName = nameNode.text; const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`); + let description: string | undefined; + if (language === SupportedLanguages.PHP) { + if (nodeLabel === 'Property' && captureMap['definition.property']) { + description = extractPhpPropertyDescription(nodeName, captureMap['definition.property']) ?? undefined; + } else if (nodeLabel === 'Method' && captureMap['definition.method']) { + description = extractEloquentRelationDescription(captureMap['definition.method']) ?? undefined; + } + } + result.nodes.push({ id: nodeId, label: nodeLabel, @@ -525,6 +676,7 @@ const processFileGroup = ( endLine: nameNode.endPosition.row, language: language, isExported: isNodeExported(nameNode, nodeName, language), + ...(description !== undefined ? { description } : {}), }, }); diff --git a/gitnexus/src/core/kuzu/csv-generator.ts b/gitnexus/src/core/kuzu/csv-generator.ts index 03175f59e..fc8090584 100644 --- a/gitnexus/src/core/kuzu/csv-generator.ts +++ b/gitnexus/src/core/kuzu/csv-generator.ts @@ -212,7 +212,7 @@ export const streamAllCSVsToDisk = async ( // Create writers for every node type up-front const fileWriter = new BufferedCSVWriter(path.join(csvDir, 'file.csv'), 'id,name,filePath,content'); const folderWriter = new BufferedCSVWriter(path.join(csvDir, 'folder.csv'), 'id,name,filePath'); - const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content'; + const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description'; const functionWriter = new BufferedCSVWriter(path.join(csvDir, 'function.csv'), codeElementHeader); const classWriter = new BufferedCSVWriter(path.join(csvDir, 'class.csv'), codeElementHeader); const interfaceWriter = new BufferedCSVWriter(path.join(csvDir, 'interface.csv'), codeElementHeader); @@ -222,7 +222,7 @@ export const streamAllCSVsToDisk = async ( const processWriter = new BufferedCSVWriter(path.join(csvDir, 'process.csv'), 'id,label,heuristicLabel,processType,stepCount,communities,entryPointId,terminalId'); // Multi-language node types share the same CSV shape (no isExported column) - const multiLangHeader = 'id,name,filePath,startLine,endLine,content'; + const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description'; const MULTI_LANG_TYPES = ['Struct', 'Enum', 'Macro', 'Typedef', 'Union', 'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property', 'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module'] as const; const multiLangWriters = new Map(); @@ -305,6 +305,7 @@ export const streamAllCSVsToDisk = async ( escapeCSVNumber(node.properties.endLine, -1), node.properties.isExported ? 'true' : 'false', escapeCSVField(content), + escapeCSVField((node.properties as any).description || ''), ].join(',')); } else { // Multi-language node types (Struct, Impl, Trait, Macro, etc.) @@ -318,6 +319,7 @@ export const streamAllCSVsToDisk = async ( escapeCSVNumber(node.properties.startLine, -1), escapeCSVNumber(node.properties.endLine, -1), escapeCSVField(content), + escapeCSVField((node.properties as any).description || ''), ].join(',')); } } diff --git a/gitnexus/src/core/kuzu/kuzu-adapter.ts b/gitnexus/src/core/kuzu/kuzu-adapter.ts index 797144793..23e8473bc 100644 --- a/gitnexus/src/core/kuzu/kuzu-adapter.ts +++ b/gitnexus/src/core/kuzu/kuzu-adapter.ts @@ -277,10 +277,10 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => { } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { - return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content) FROM "${filePath}" ${COPY_CSV_OPTS}`; + return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; } // Multi-language tables (Struct, Impl, Trait, Macro, etc.) - return `COPY ${t}(id, name, filePath, startLine, endLine, content) FROM "${filePath}" ${COPY_CSV_OPTS}`; + return `COPY ${t}(id, name, filePath, startLine, endLine, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; }; /** @@ -317,10 +317,12 @@ export const insertNodeToKuzu = async ( } else if (label === 'Folder') { query = `CREATE (n:Folder {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}})`; } else if (TABLES_WITH_EXPORTED.has(label)) { - query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}})`; + const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : ''; + query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`; } else { // Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported - query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}})`; + const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` : ''; + query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descPart}})`; } // Use per-query connection if dbPath provided (avoids lock conflicts) @@ -386,9 +388,11 @@ export const batchInsertNodesToKuzu = async ( } else if (label === 'Folder') { query = `MERGE (n:Folder {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}`; } else if (TABLES_WITH_EXPORTED.has(label)) { - query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}`; + const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : ''; + query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`; } else { - query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}`; + const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` : ''; + query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}`; } await tempConn.query(query); diff --git a/gitnexus/src/core/kuzu/schema.ts b/gitnexus/src/core/kuzu/schema.ts index 7fcdac826..5d634cba8 100644 --- a/gitnexus/src/core/kuzu/schema.ts +++ b/gitnexus/src/core/kuzu/schema.ts @@ -64,6 +64,7 @@ CREATE NODE TABLE Function ( endLine INT64, isExported BOOLEAN, content STRING, + description STRING, PRIMARY KEY (id) )`; @@ -76,6 +77,7 @@ CREATE NODE TABLE Class ( endLine INT64, isExported BOOLEAN, content STRING, + description STRING, PRIMARY KEY (id) )`; @@ -88,6 +90,7 @@ CREATE NODE TABLE Interface ( endLine INT64, isExported BOOLEAN, content STRING, + description STRING, PRIMARY KEY (id) )`; @@ -100,6 +103,7 @@ CREATE NODE TABLE Method ( endLine INT64, isExported BOOLEAN, content STRING, + description STRING, PRIMARY KEY (id) )`; @@ -112,6 +116,7 @@ CREATE NODE TABLE CodeElement ( endLine INT64, isExported BOOLEAN, content STRING, + description STRING, PRIMARY KEY (id) )`; @@ -154,6 +159,7 @@ CREATE NODE TABLE Process ( // ============================================================================ // Generic code element with startLine/endLine for C, C++, Rust, Go, Java, C# +// description: optional metadata (e.g. Eloquent $fillable fields, relationship targets) const CODE_ELEMENT_BASE = (name: string) => ` CREATE NODE TABLE \`${name}\` ( id STRING, @@ -162,6 +168,7 @@ CREATE NODE TABLE \`${name}\` ( startLine INT64, endLine INT64, content STRING, + description STRING, PRIMARY KEY (id) )`; @@ -266,6 +273,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Method TO \`Impl\`, FROM Method TO Interface, FROM Method TO \`Constructor\`, + FROM Method TO \`Property\`, FROM \`Template\` TO \`Template\`, FROM \`Template\` TO Function, FROM \`Template\` TO Method, diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index cdca3003d..e92898424 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -8,6 +8,7 @@ import CPP from 'tree-sitter-cpp'; import CSharp from 'tree-sitter-c-sharp'; import Go from 'tree-sitter-go'; import Rust from 'tree-sitter-rust'; +import PHP from 'tree-sitter-php'; import { SupportedLanguages } from '../../config/supported-languages.js'; let parser: Parser | null = null; @@ -23,6 +24,7 @@ const languageMap: Record = { [SupportedLanguages.CSharp]: CSharp, [SupportedLanguages.Go]: Go, [SupportedLanguages.Rust]: Rust, + [SupportedLanguages.PHP]: PHP.php_only, }; export const loadParser = async (): Promise => {