feat(php): track Eloquent model properties and relationships with structured metadata

- 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 <noreply@anthropic.com>
This commit is contained in:
Güneş Bizim 2026-02-25 10:31:35 +03:00
parent 556ba91290
commit bb8aa0c5c5
3 changed files with 125 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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 } : {}),
},
});