From bb8aa0c5c5ccbf8396f3815533fd7c81a6550176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCne=C5=9F=20Bizim?= Date: Wed, 25 Feb 2026 10:31:35 +0300 Subject: [PATCH] feat(php): track Eloquent model properties and relationships with structured metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PHP property_declaration query to capture class properties ($fillable, $casts, etc.) - Extract array values from Eloquent model properties as description field: - $fillable → comma-separated field names (e.g. "name, email, password") - $casts → key:type pairs (e.g. "email_verified_at:datetime, active:boolean") - $hidden, $guarded, $with, $appends similarly - Detect Eloquent relationship methods (hasMany, hasOne, belongsTo, etc.) and annotate them with relation type and target model (e.g. "hasMany(Post)") Co-Authored-By: Claude Sonnet 4.6 --- .../src/core/ingestion/tree-sitter-queries.ts | 6 + .../src/core/ingestion/tree-sitter-queries.ts | 6 + .../core/ingestion/workers/parse-worker.ts | 113 ++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts index a7abb0a71..5cb2d46ff 100644 --- a/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus-web/src/core/ingestion/tree-sitter-queries.ts @@ -347,6 +347,12 @@ export const PHP_QUERIES = ` (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 diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index dc2756453..ff4f8f28f 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -347,6 +347,12 @@ export const PHP_QUERIES = ` (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 diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 8c683c3b6..f4541d0d2 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -424,6 +424,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, @@ -530,6 +633,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, @@ -540,6 +652,7 @@ const processFileGroup = ( endLine: nameNode.endPosition.row, language: language, isExported: isNodeExported(nameNode, nodeName, language), + ...(description !== undefined ? { description } : {}), }, });