Bläddra i källkod

feat: Add complete PHP language support with trait handling and property extraction

Addresses PHP traits extracted as classes, missing class properties, skipped constants, and invisible trait usage. Adds classifyClassNode to distinguish traits from classes, fixes property extraction for PHP's property_element AST structure (added 4,366 field nodes), and adds visitNode hook for class constants and trait use declarations (increased trait edges from 636 to 1,514). Also improves Liquid schema name handling and file path reference resolution. Verified against Laravel codebase.
Colby McHenry 2 månader sedan
förälder
incheckning
f402ab8363

+ 5 - 0
docs/SEARCH_QUALITY_LOOP.md

@@ -460,6 +460,10 @@ test().catch(console.error);
 | Svelte `$state`/`$derived` rune calls creating noise | Runes are compiler builtins, not real function calls | `src/extraction/svelte-extractor.ts` filters `SVELTE_RUNES` set from unresolved references |
 | Object literal getters/setters extracted as standalone functions | `method_definition` inside `object` literals treated same as class methods | `src/extraction/tree-sitter.ts: extractMethod` skips `method_definition` nodes whose parent is `object`/`object_expression` |
 | JavaScript `class extends` produces zero inheritance edges | JS tree-sitter uses `class_heritage → identifier` (bare), not `class_heritage → extends_clause → identifier` like TypeScript | `src/extraction/tree-sitter.ts: extractInheritance` — handle bare `identifier`/`type_identifier` children when parent is `class_heritage` |
+| PHP traits extracted as classes | `trait_declaration` in `classTypes` but `extractClass` hardcodes `class` kind | `src/extraction/languages/php.ts: classifyClassNode` returns `'trait'` for `trait_declaration`; `src/extraction/tree-sitter-types.ts` adds `'trait'` to return type |
+| PHP class properties missing (0 field nodes) | `extractField` looks for `variable_declarator` children; PHP uses `property_element > variable_name > name` | `src/extraction/tree-sitter.ts: extractField` — handle `property_element` children with `variable_name > name` path |
+| PHP class constants skipped inside classes | `variableTypes` check has `!isInsideClassLikeNode()` guard, so `const_declaration` inside classes falls through | `src/extraction/languages/php.ts: visitNode` hook catches `const_declaration`, extracts `const_element > name` as `constant` kind |
+| PHP `use TraitName` inside classes invisible | `use_declaration` nodes in class body not processed for edges | `src/extraction/languages/php.ts: visitNode` hook extracts trait names from `use_declaration` and creates `implements` unresolved references |
 
 ## After Fixing Issues
 
@@ -547,6 +551,7 @@ if (receiverType) {
 - [x] **Dart** — NOT needed for `getReceiverType`. Methods nested in class body. Added bare call extraction for selector-based method calls (e.g. `object.method()`). Verified against Flutter
 - [x] **Kotlin** — `getReceiverType` extracts receiver from extension functions `fun Type.method()`. Added `classifyClassNode` to distinguish interfaces/enums from classes (all use `class_declaration` AST node). Added `resolveBody` hook since Kotlin's tree-sitter grammar doesn't use field names. Added `navigation_expression` handling for method call extraction. Added `object_declaration` via `extraClassNodeTypes`. Added `delegation_specifier` handling in `extractInheritance` for Kotlin's `: Parent, Interface` syntax. Also fixed `extractInterface` to visit body children (interface methods were not being extracted). Added `visitNode` hook to handle `fun interface` (SAM) declarations — tree-sitter-kotlin doesn't support this Kotlin 1.4+ syntax, producing ERROR or function_declaration misparse; the hook detects both patterns and extracts the interface. Verified against Koin, LeakCanary
 - [x] **Svelte** — Custom `SvelteExtractor` delegates `<script>` blocks to TS/JS parser; creates `component` nodes for each `.svelte` file. Added template expression call extraction: scans `{expression}` blocks in markup for function calls (e.g. `class={cn(...)}`), creating call edges from component to callees — increased Svelte call edges from 29 to 387. Filtered Svelte 5 rune calls (`$state`, `$props`, `$derived`, `$effect`, `$bindable`). Also fixed: destructured `$props()` patterns (e.g. `let { x, y } = $props()`) no longer extracted as ugly multi-line variable names (skip `object_pattern`/`array_pattern` in `extractVariable`). Object literal getter/setter methods no longer extracted as standalone functions. Verified against shadcn-svelte
+- [x] **PHP** — NOT needed for `getReceiverType`. Methods nested in class body. Added `classifyClassNode` to distinguish traits from classes (`trait_declaration` → `trait` kind). Added `'trait'` to `classifyClassNode` return type in `tree-sitter-types.ts` and handling in visitor. Fixed PHP property extraction: `extractField` now handles `property_element > variable_name > name` AST structure (added 4,366 field nodes). Added `visitNode` hook for class constants (`const_declaration` inside classes was skipped by `variableTypes` guard) and trait `use` declarations (`use HasFactory, SoftDeletes;` creates `implements` edges — increased from 636 to 1,514). Verified against Laravel
 
 ### Needs Verification
 

+ 40 - 0
src/extraction/languages/php.ts

@@ -19,6 +19,9 @@ export const phpExtractor: LanguageExtractor = {
   bodyField: 'body',
   paramsField: 'parameters',
   returnField: 'return_type',
+  classifyClassNode: (node) => {
+    return node.type === 'trait_declaration' ? 'trait' : 'class';
+  },
   getVisibility: (node) => {
     for (let i = 0; i < node.childCount; i++) {
       const child = node.child(i);
@@ -38,6 +41,43 @@ export const phpExtractor: LanguageExtractor = {
     }
     return false;
   },
+  visitNode: (node, ctx) => {
+    // Handle class constants: const_declaration inside classes
+    // These are skipped by the main visitor because variableTypes check excludes class-like contexts
+    if (node.type === 'const_declaration') {
+      const constElements = node.namedChildren.filter((c: SyntaxNode) => c.type === 'const_element');
+      for (const elem of constElements) {
+        const nameNode = elem.namedChildren.find((c: SyntaxNode) => c.type === 'name');
+        if (!nameNode) continue;
+        const name = getNodeText(nameNode, ctx.source);
+        ctx.createNode('constant', name, elem, {});
+      }
+      return true; // handled
+    }
+
+    // Handle trait usage: use TraitName, OtherTrait; inside classes
+    // Creates unresolved references that will be resolved to 'implements' edges
+    if (node.type === 'use_declaration') {
+      const names = node.namedChildren.filter((c: SyntaxNode) => c.type === 'name' || c.type === 'qualified_name');
+      const parentId = ctx.nodeStack.length > 0 ? ctx.nodeStack[ctx.nodeStack.length - 1] : undefined;
+      if (parentId) {
+        for (const nameNode of names) {
+          const traitName = getNodeText(nameNode, ctx.source);
+          ctx.addUnresolvedReference({
+            fromNodeId: parentId,
+            referenceName: traitName,
+            referenceKind: 'implements',
+            filePath: ctx.filePath,
+            line: node.startPosition.row + 1,
+            column: node.startPosition.column,
+          });
+        }
+      }
+      return true; // handled
+    }
+
+    return false;
+  },
   extractImport: (node, source) => {
     const importText = source.substring(node.startIndex, node.endIndex).trim();
 

+ 4 - 1
src/extraction/liquid-extractor.ts

@@ -252,7 +252,10 @@ export class LiquidExtractor {
       try {
         const schemaJson = JSON.parse(schemaContent!);
         if (schemaJson.name) {
-          schemaName = schemaJson.name;
+          // Shopify schema names can be translation objects like {"en": "...", "fr": "..."}
+          schemaName = typeof schemaJson.name === 'string'
+            ? schemaJson.name
+            : schemaJson.name.en || Object.values(schemaJson.name)[0] as string || 'schema';
         }
       } catch {
         // Schema isn't valid JSON, use default name

+ 1 - 1
src/extraction/tree-sitter-types.ts

@@ -154,7 +154,7 @@ export interface LanguageExtractor {
    * Classify a class_declaration node when the grammar reuses one node type
    * for multiple concepts (e.g. Swift uses class_declaration for classes, structs, and enums).
    */
-  classifyClassNode?: (node: SyntaxNode) => 'class' | 'struct' | 'enum' | 'interface';
+  classifyClassNode?: (node: SyntaxNode) => 'class' | 'struct' | 'enum' | 'interface' | 'trait';
 
   /**
    * Resolve the body node for a function/method/class when it's not a child field.

+ 34 - 2
src/extraction/tree-sitter.ts

@@ -266,6 +266,8 @@ export class TreeSitterExtractor {
         this.extractEnum(node);
       } else if (classification === 'interface') {
         this.extractInterface(node);
+      } else if (classification === 'trait') {
+        this.extractClass(node, 'trait');
       } else {
         this.extractClass(node);
       }
@@ -542,7 +544,7 @@ export class TreeSitterExtractor {
   /**
    * Extract a class
    */
-  private extractClass(node: SyntaxNode): void {
+  private extractClass(node: SyntaxNode, kind: NodeKind = 'class'): void {
     if (!this.extractor) return;
 
     const name = extractName(node, this.source, this.extractor);
@@ -550,7 +552,7 @@ export class TreeSitterExtractor {
     const visibility = this.extractor.getVisibility?.(node);
     const isExported = this.extractor.isExported?.(node, this.source);
 
-    const classNode = this.createNode('class', name, node, {
+    const classNode = this.createNode(kind, name, node, {
       docstring,
       visibility,
       isExported,
@@ -864,6 +866,35 @@ export class TreeSitterExtractor {
       }
     }
 
+    // PHP property_declaration: property_element → variable_name → name
+    if (declarators.length === 0) {
+      const propElements = node.namedChildren.filter(c => c.type === 'property_element');
+      if (propElements.length > 0) {
+        // Get type annotation if present (e.g. "string", "int", "?Foo")
+        const typeNode = node.namedChildren.find(
+          c => c.type !== 'visibility_modifier' && c.type !== 'static_modifier'
+            && c.type !== 'readonly_modifier' && c.type !== 'property_element'
+            && c.type !== 'var_modifier'
+        );
+        const typeText = typeNode ? getNodeText(typeNode, this.source) : undefined;
+
+        for (const elem of propElements) {
+          const varName = elem.namedChildren.find(c => c.type === 'variable_name');
+          const nameNode = varName?.namedChildren.find(c => c.type === 'name');
+          if (!nameNode) continue;
+          const name = getNodeText(nameNode, this.source);
+          const signature = typeText ? `${typeText} $${name}` : `$${name}`;
+          this.createNode('field', name, elem, {
+            docstring,
+            signature,
+            visibility,
+            isStatic,
+          });
+        }
+        return;
+      }
+    }
+
     if (declarators.length > 0) {
       // Get field type from the type child
       // Java: type is a direct child of field_declaration
@@ -1458,6 +1489,7 @@ export class TreeSitterExtractor {
         if (classification === 'struct') this.extractStruct(node);
         else if (classification === 'enum') this.extractEnum(node);
         else if (classification === 'interface') this.extractInterface(node);
+        else if (classification === 'trait') this.extractClass(node, 'trait');
         else this.extractClass(node);
         return;
       }

+ 7 - 0
src/resolution/index.ts

@@ -369,6 +369,13 @@ export class ReferenceResolver {
       if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
     }
 
+    // For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename
+    const slashIdx = name.lastIndexOf('/');
+    if (slashIdx > 0) {
+      const fileName = name.substring(slashIdx + 1);
+      if (this.knownNames.has(fileName)) return true;
+    }
+
     return false;
   }
 

+ 59 - 0
src/resolution/name-matcher.ts

@@ -7,6 +7,61 @@
 import { Node } from '../types';
 import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
 
+/**
+ * Try to resolve a path-like reference (e.g., "snippets/drawer-menu.liquid")
+ * by matching the filename against file nodes.
+ */
+export function matchByFilePath(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  if (!ref.referenceName.includes('/')) return null;
+
+  // Extract the filename from the path
+  const fileName = ref.referenceName.split('/').pop();
+  if (!fileName) return null;
+
+  // Search for file nodes with this name
+  const candidates = context.getNodesByName(fileName);
+  const fileNodes = candidates.filter(n => n.kind === 'file');
+
+  if (fileNodes.length === 0) return null;
+
+  // Prefer exact path match on qualified_name
+  const exactMatch = fileNodes.find(n => n.qualifiedName === ref.referenceName || n.filePath === ref.referenceName);
+  if (exactMatch) {
+    return {
+      original: ref,
+      targetNodeId: exactMatch.id,
+      confidence: 0.95,
+      resolvedBy: 'file-path',
+    };
+  }
+
+  // Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches "src/snippets/foo.liquid")
+  const suffixMatch = fileNodes.find(n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName));
+  if (suffixMatch) {
+    return {
+      original: ref,
+      targetNodeId: suffixMatch.id,
+      confidence: 0.85,
+      resolvedBy: 'file-path',
+    };
+  }
+
+  // If only one file node with this name, use it with lower confidence
+  if (fileNodes.length === 1) {
+    return {
+      original: ref,
+      targetNodeId: fileNodes[0]!.id,
+      confidence: 0.7,
+      resolvedBy: 'file-path',
+    };
+  }
+
+  return null;
+}
+
 /**
  * Try to resolve a reference by exact name match
  */
@@ -360,6 +415,10 @@ export function matchReference(
   // Try strategies in order of confidence
   let result: ResolvedRef | null;
 
+  // 0. File path match (e.g., "snippets/drawer-menu.liquid" → file node)
+  result = matchByFilePath(ref, context);
+  if (result) return result;
+
   // 1. Qualified name match (highest confidence)
   result = matchByQualifiedName(ref, context);
   if (result) return result;

+ 1 - 1
src/resolution/types.ts

@@ -39,7 +39,7 @@ export interface ResolvedRef {
   /** Confidence score (0-1) */
   confidence: number;
   /** How it was resolved */
-  resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method';
+  resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path';
 }
 
 /**