Bladeren bron

feat(frameworks): add Drupal 8/9/10/11 support (#271)

Detects Drupal projects via composer.json drupal/* deps; extracts routes from *.routing.yml (route nodes + references edges to controllers/forms/entity handlers) and Drupal hook implementations from .module/.install/.theme/.inc. Adds yaml/twig as file-level languages and excludes core/contrib by default. Resolves #268.
Marcelo Vani 1 maand geleden
bovenliggende
commit
5b71a89574

+ 22 - 0
CHANGELOG.md

@@ -7,6 +7,28 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
 This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
 This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
 and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 
+## [Unreleased]
+
+### Added
+- **Framework support: Drupal 8/9/10/11** — CodeGraph now detects Drupal
+  projects (via a `drupal/*` dependency in `composer.json`) and adds three
+  levels of intelligence:
+  - **Route extraction**: `*.routing.yml` files emit a `route` node per route,
+    linked by a `references` edge to the `_controller`, `_form`, or
+    entity-handler class/method, so querying a controller method surfaces the
+    URL route that binds it.
+  - **Hook detection**: hook implementations in `.module`, `.install`, `.theme`,
+    and `.inc` files are detected via docblock (`Implements hook_X()`) with a
+    module-name-prefix fallback. Each emits a `references` edge to the canonical
+    `hook_X` name so `codegraph_callers("hook_form_alter")` returns every
+    implementation across modules.
+  - **Resolution**: `_controller`/`_form` FQCNs resolve to their PHP
+    class/method nodes.
+  New `yaml`/`twig` languages are tracked at the file level, the Drupal PHP
+  extensions (`.module`/`.install`/`.theme`/`.inc`) are indexed with the PHP
+  grammar, and `web/core`, `web/modules/contrib`, `web/themes/contrib` are
+  excluded by default. Resolves [#268](https://github.com/colbymchenry/codegraph/issues/268).
+
 ## [0.9.1] - 2026-05-21
 ## [0.9.1] - 2026-05-21
 
 
 ### Fixed
 ### Fixed

+ 2 - 1
README.md

@@ -124,7 +124,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
 | **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
 | **19+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi |
-| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 13 frameworks |
+| **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
 
 
 ---
 ---
@@ -141,6 +141,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
 | **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
 | **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
 | **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
 | **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
 | **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
 | **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
+| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` |
 | **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
 | **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
 | **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |
 | **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |
 | **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` |
 | **Gin / chi / gorilla / mux** | `r.GET(...)`, `router.HandleFunc(...)` |

+ 518 - 0
__tests__/drupal.test.ts

@@ -0,0 +1,518 @@
+/**
+ * Tests for Drupal framework resolver.
+ *
+ * Unit tests cover drupalResolver.detect(), extract() (routes + hooks), and resolve().
+ * Integration tests use a real CodeGraph instance with a temporary Drupal project layout.
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { CodeGraph } from '../src';
+import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
+import { drupalResolver } from '../src/resolution/frameworks/drupal';
+import type { ResolutionContext } from '../src/resolution/types';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeContext(
+  overrides: Partial<ResolutionContext> = {},
+): ResolutionContext {
+  return {
+    getNodesInFile: () => [],
+    getNodesByName: () => [],
+    getNodesByQualifiedName: () => [],
+    getNodesByKind: () => [],
+    fileExists: () => false,
+    readFile: () => null,
+    getProjectRoot: () => '/project',
+    getAllFiles: () => [],
+    getNodesByLowerName: () => [],
+    getImportMappings: () => [],
+    ...overrides,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// detect()
+// ---------------------------------------------------------------------------
+
+describe('drupalResolver.detect', () => {
+  it('returns true when composer.json has a drupal/ dependency', () => {
+    const ctx = makeContext({
+      readFile: (f) =>
+        f === 'composer.json'
+          ? JSON.stringify({
+              require: {
+                'drupal/core-recommended': '~10.5',
+                'drush/drush': '^13',
+              },
+            })
+          : null,
+    });
+    expect(drupalResolver.detect(ctx)).toBe(true);
+  });
+
+  it('returns true when drupal/ dependency is in require-dev', () => {
+    const ctx = makeContext({
+      readFile: (f) =>
+        f === 'composer.json'
+          ? JSON.stringify({ 'require-dev': { 'drupal/core': '^10' } })
+          : null,
+    });
+    expect(drupalResolver.detect(ctx)).toBe(true);
+  });
+
+  it('returns false when composer.json has no drupal/ dependencies', () => {
+    const ctx = makeContext({
+      readFile: (f) =>
+        f === 'composer.json'
+          ? JSON.stringify({
+              require: { 'laravel/framework': '^10', php: '>=8.1' },
+            })
+          : null,
+    });
+    expect(drupalResolver.detect(ctx)).toBe(false);
+  });
+
+  it('returns false when composer.json is absent', () => {
+    const ctx = makeContext({ readFile: () => null });
+    expect(drupalResolver.detect(ctx)).toBe(false);
+  });
+
+  it('returns false when composer.json is malformed JSON', () => {
+    const ctx = makeContext({ readFile: () => '{ bad json' });
+    expect(drupalResolver.detect(ctx)).toBe(false);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// extract() — routing.yml
+// ---------------------------------------------------------------------------
+
+describe('drupalResolver.extract — routing.yml', () => {
+  const routing = `
+mymodule.example:
+  path: '/mymodule/example'
+  defaults:
+    _controller: '\\Drupal\\mymodule\\Controller\\MyController::build'
+    _title: 'Example page'
+  requirements:
+    _permission: 'access content'
+`;
+
+  it('emits a route node for each YAML route', () => {
+    const { nodes } = drupalResolver.extract!(
+      'mymodule/mymodule.routing.yml',
+      routing,
+    );
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0]!.kind).toBe('route');
+    expect(nodes[0]!.name).toBe('/mymodule/example');
+  });
+
+  it('sets qualifiedName to filePath::routeName', () => {
+    const { nodes } = drupalResolver.extract!(
+      'mymodule/mymodule.routing.yml',
+      routing,
+    );
+    expect(nodes[0]!.qualifiedName).toBe(
+      'mymodule/mymodule.routing.yml::mymodule.example',
+    );
+  });
+
+  it('emits a references edge to the controller FQCN', () => {
+    const { references } = drupalResolver.extract!(
+      'mymodule/mymodule.routing.yml',
+      routing,
+    );
+    expect(references).toHaveLength(1);
+    expect(references[0]!.referenceName).toBe(
+      '\\Drupal\\mymodule\\Controller\\MyController::build',
+    );
+    expect(references[0]!.referenceKind).toBe('references');
+  });
+
+  it('emits a references edge to a _form handler', () => {
+    const src = `
+mymodule.settings_form:
+  path: '/admin/config/mymodule'
+  defaults:
+    _form: '\\Drupal\\mymodule\\Form\\SettingsForm'
+    _title: 'MyModule settings'
+  requirements:
+    _permission: 'administer site configuration'
+`;
+    const { nodes, references } = drupalResolver.extract!(
+      'mymodule/mymodule.routing.yml',
+      src,
+    );
+    expect(nodes).toHaveLength(1);
+    expect(references[0]!.referenceName).toBe(
+      '\\Drupal\\mymodule\\Form\\SettingsForm',
+    );
+  });
+
+  it('handles multiple routes in one file', () => {
+    const src = `
+mod.page_one:
+  path: '/page-one'
+  defaults:
+    _controller: '\\Drupal\\mod\\Controller\\PageController::one'
+  requirements:
+    _permission: 'access content'
+
+mod.page_two:
+  path: '/page-two'
+  defaults:
+    _controller: '\\Drupal\\mod\\Controller\\PageController::two'
+  requirements:
+    _permission: 'access content'
+`;
+    const { nodes, references } = drupalResolver.extract!(
+      'mod/mod.routing.yml',
+      src,
+    );
+    expect(nodes).toHaveLength(2);
+    expect(nodes.map((n) => n.name)).toContain('/page-one');
+    expect(nodes.map((n) => n.name)).toContain('/page-two');
+    expect(references).toHaveLength(2);
+  });
+
+  it('skips commented-out lines', () => {
+    const src = `
+mod.page:
+  path: '/page'
+  defaults:
+    #_controller: '\\Drupal\\mod\\Controller\\Old::build'
+    _controller: '\\Drupal\\mod\\Controller\\New::build'
+  requirements:
+    _permission: 'access content'
+`;
+    const { references } = drupalResolver.extract!('mod/mod.routing.yml', src);
+    expect(references).toHaveLength(1);
+    expect(references[0]!.referenceName).toContain('New');
+  });
+
+  it('includes HTTP methods in the route node name when present', () => {
+    const src = `
+mod.api:
+  path: '/api/resource'
+  defaults:
+    _controller: '\\Drupal\\mod\\Controller\\ApiController::get'
+  methods: [GET, POST]
+  requirements:
+    _permission: 'access content'
+`;
+    const { nodes } = drupalResolver.extract!('mod/mod.routing.yml', src);
+    expect(nodes[0]!.name).toContain('GET');
+    expect(nodes[0]!.name).toContain('POST');
+  });
+
+  it('returns empty result for non-routing-yml files', () => {
+    const { nodes, references } = drupalResolver.extract!(
+      'mymodule.module',
+      '<?php\n',
+    );
+    // Module files go through hook detection, not route extraction
+    expect(nodes).toHaveLength(0);
+  });
+
+  it('returns empty result for files with no valid routes', () => {
+    const { nodes, references } = drupalResolver.extract!(
+      'some.routing.yml',
+      '# empty\n',
+    );
+    expect(nodes).toHaveLength(0);
+    expect(references).toHaveLength(0);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// extract() — hook detection in .module files
+// ---------------------------------------------------------------------------
+
+describe('drupalResolver.extract — hook detection', () => {
+  it('detects hook implementation via docblock (Strategy A)', () => {
+    const src = `<?php
+
+/**
+ * Implements hook_form_alter().
+ */
+function mymodule_form_alter(&$form, $form_state, $form_id) {
+  // ...
+}
+`;
+    const { references } = drupalResolver.extract!(
+      'web/modules/custom/mymodule/mymodule.module',
+      src,
+    );
+    const hookRef = references.find(
+      (r) => r.referenceName === 'hook_form_alter',
+    );
+    expect(hookRef).toBeDefined();
+    expect(hookRef!.referenceKind).toBe('references');
+  });
+
+  it('detects hook implementation via name pattern (Strategy B)', () => {
+    const src = `<?php
+
+function mymodule_views_data() {
+  return [];
+}
+`;
+    const { references } = drupalResolver.extract!(
+      'web/modules/custom/mymodule/mymodule.module',
+      src,
+    );
+    const hookRef = references.find(
+      (r) => r.referenceName === 'hook_views_data',
+    );
+    expect(hookRef).toBeDefined();
+  });
+
+  it('does not emit a hook ref for non-hook helper functions', () => {
+    // 'other_module_helper' doesn't start with 'mymodule_', so no hook ref
+    const src = `<?php
+function other_module_helper() {}
+`;
+    const { references } = drupalResolver.extract!(
+      'web/modules/custom/mymodule/mymodule.module',
+      src,
+    );
+    expect(references).toHaveLength(0);
+  });
+
+  it('detects hooks in .install files', () => {
+    const src = `<?php
+/**
+ * Implements hook_schema().
+ */
+function mymodule_schema() {
+  return [];
+}
+`;
+    const { references } = drupalResolver.extract!(
+      'web/modules/custom/mymodule/mymodule.install',
+      src,
+    );
+    const hookRef = references.find((r) => r.referenceName === 'hook_schema');
+    expect(hookRef).toBeDefined();
+  });
+
+  it('detects hooks in .theme files', () => {
+    const src = `<?php
+/**
+ * Implements hook_preprocess_node().
+ */
+function mytheme_preprocess_node(&$variables) {}
+`;
+    const { references } = drupalResolver.extract!(
+      'web/themes/custom/mytheme/mytheme.theme',
+      src,
+    );
+    const hookRef = references.find(
+      (r) => r.referenceName === 'hook_preprocess_node',
+    );
+    expect(hookRef).toBeDefined();
+  });
+
+  it('does not duplicate refs when both docblock and name pattern match', () => {
+    // Strategy A matches first and adds to docblockMatched set;
+    // Strategy B skips already-matched functions.
+    const src = `<?php
+/**
+ * Implements hook_form_alter().
+ */
+function mymodule_form_alter(&$form, $form_state, $form_id) {}
+`;
+    const { references } = drupalResolver.extract!(
+      'web/modules/custom/mymodule/mymodule.module',
+      src,
+    );
+    const hookRefs = references.filter(
+      (r) => r.referenceName === 'hook_form_alter',
+    );
+    expect(hookRefs).toHaveLength(1);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// resolve()
+// ---------------------------------------------------------------------------
+
+describe('drupalResolver.resolve', () => {
+  it('resolves a _controller FQCN with ::method to the method node', () => {
+    const methodNode = {
+      id: 'method:abc123',
+      kind: 'method' as const,
+      name: 'build',
+      qualifiedName: 'MyController::build',
+      filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
+      language: 'php' as const,
+      startLine: 10,
+      endLine: 20,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const classNode = {
+      id: 'class:def456',
+      kind: 'class' as const,
+      name: 'MyController',
+      qualifiedName: 'MyController',
+      filePath: 'web/modules/custom/mymodule/src/Controller/MyController.php',
+      language: 'php' as const,
+      startLine: 5,
+      endLine: 30,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const ctx = makeContext({
+      getNodesByName: (name) => (name === 'MyController' ? [classNode] : []),
+      getNodesInFile: () => [classNode, methodNode],
+    });
+    const ref = {
+      fromNodeId: 'route:x',
+      referenceName: '\\Drupal\\mymodule\\Controller\\MyController::build',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 0,
+      filePath: 'mymodule.routing.yml',
+      language: 'yaml' as const,
+    };
+    const resolved = drupalResolver.resolve(ref, ctx);
+    expect(resolved).not.toBeNull();
+    expect(resolved!.targetNodeId).toBe('method:abc123');
+    expect(resolved!.confidence).toBeGreaterThanOrEqual(0.85);
+  });
+
+  it('resolves a _form FQCN (no ::method) to the class node', () => {
+    const classNode = {
+      id: 'class:form123',
+      kind: 'class' as const,
+      name: 'SettingsForm',
+      qualifiedName: 'SettingsForm',
+      filePath: 'web/modules/custom/mymodule/src/Form/SettingsForm.php',
+      language: 'php' as const,
+      startLine: 1,
+      endLine: 50,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: 0,
+    };
+    const ctx = makeContext({
+      getNodesByName: (name) => (name === 'SettingsForm' ? [classNode] : []),
+    });
+    const ref = {
+      fromNodeId: 'route:x',
+      referenceName: '\\Drupal\\mymodule\\Form\\SettingsForm',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 0,
+      filePath: 'mymodule.routing.yml',
+      language: 'yaml' as const,
+    };
+    const resolved = drupalResolver.resolve(ref, ctx);
+    expect(resolved).not.toBeNull();
+    expect(resolved!.targetNodeId).toBe('class:form123');
+  });
+
+  it('returns null when the target class cannot be found', () => {
+    const ctx = makeContext({ getNodesByName: () => [] });
+    const ref = {
+      fromNodeId: 'route:x',
+      referenceName: '\\Drupal\\mymodule\\Controller\\Missing::method',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 0,
+      filePath: 'mymodule.routing.yml',
+      language: 'yaml' as const,
+    };
+    expect(drupalResolver.resolve(ref, ctx)).toBeNull();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// End-to-end integration test
+// ---------------------------------------------------------------------------
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+});
+
+describe('Drupal end-to-end — route node linked to controller method', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  it('creates a route→controller edge from routing.yml to PHP class', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-drupal-'));
+
+    // Minimal composer.json to trigger Drupal detection
+    fs.writeFileSync(
+      path.join(tmpDir, 'composer.json'),
+      JSON.stringify({ require: { 'drupal/core-recommended': '~10.5' } }),
+    );
+
+    // Module directory structure
+    const modDir = path.join(tmpDir, 'web', 'modules', 'custom', 'my_module');
+    fs.mkdirSync(path.join(modDir, 'src', 'Controller'), { recursive: true });
+
+    // routing.yml
+    fs.writeFileSync(
+      path.join(modDir, 'my_module.routing.yml'),
+      [
+        'my_module.hello:',
+        "  path: '/hello'",
+        '  defaults:',
+        "    _controller: '\\Drupal\\my_module\\Controller\\HelloController::build'",
+        "    _title: 'Hello'",
+        '  requirements:',
+        "    _permission: 'access content'",
+      ].join('\n') + '\n',
+    );
+
+    // PHP controller
+    fs.writeFileSync(
+      path.join(modDir, 'src', 'Controller', 'HelloController.php'),
+      [
+        '<?php',
+        'namespace Drupal\\my_module\\Controller;',
+        'use Drupal\\Core\\Controller\\ControllerBase;',
+        'class HelloController extends ControllerBase {',
+        '  public function build() {',
+        "    return ['#markup' => 'Hello'];",
+        '  }',
+        '}',
+      ].join('\n') + '\n',
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    // Route node must exist
+    const routes = cg.getNodesByKind('route');
+    expect(routes.length).toBeGreaterThan(0);
+    const route = routes.find((n) => n.name.includes('/hello'));
+    expect(route).toBeDefined();
+
+    // Controller method must be indexed
+    const methods = cg.getNodesByKind('method');
+    const buildMethod = methods.find((n) => n.name === 'build');
+    expect(buildMethod).toBeDefined();
+
+    // Edge: route → build method (or class fallback)
+    const edges = cg.getOutgoingEdges(route!.id);
+    expect(edges.length).toBeGreaterThan(0);
+
+    cg.close();
+  });
+});

+ 16 - 1
src/extraction/grammars.ts

@@ -10,7 +10,7 @@ import * as path from 'path';
 import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
 import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
 import { Language } from '../types';
 import { Language } from '../types';
 
 
-export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'unknown'>;
+export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'unknown'>;
 
 
 /**
 /**
  * WASM filename map — maps each language to its .wasm grammar file
  * WASM filename map — maps each language to its .wasm grammar file
@@ -63,6 +63,16 @@ export const EXTENSION_MAP: Record<string, Language> = {
   '.hxx': 'cpp',
   '.hxx': 'cpp',
   '.cs': 'csharp',
   '.cs': 'csharp',
   '.php': 'php',
   '.php': 'php',
+  // Drupal-specific PHP file extensions
+  '.module': 'php',
+  '.install': 'php',
+  '.theme': 'php',
+  '.inc': 'php',
+  // YAML (used for Drupal routing files; no symbol extraction, file-level tracking only)
+  '.yml': 'yaml',
+  '.yaml': 'yaml',
+  // Twig templates (file-level tracking only, no symbol extraction)
+  '.twig': 'twig',
   '.rb': 'ruby',
   '.rb': 'ruby',
   '.rake': 'ruby',
   '.rake': 'ruby',
   '.swift': 'swift',
   '.swift': 'swift',
@@ -215,6 +225,8 @@ export function isLanguageSupported(language: Language): boolean {
   if (language === 'svelte') return true; // custom extractor (script block delegation)
   if (language === 'svelte') return true; // custom extractor (script block delegation)
   if (language === 'vue') return true; // custom extractor (script block delegation)
   if (language === 'vue') return true; // custom extractor (script block delegation)
   if (language === 'liquid') return true; // custom regex extractor
   if (language === 'liquid') return true; // custom regex extractor
+  if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
+  if (language === 'twig') return true; // file-level tracking only
   if (language === 'unknown') return false;
   if (language === 'unknown') return false;
   return language in WASM_GRAMMAR_FILES;
   return language in WASM_GRAMMAR_FILES;
 }
 }
@@ -224,6 +236,7 @@ export function isLanguageSupported(language: Language): boolean {
  */
  */
 export function isGrammarLoaded(language: Language): boolean {
 export function isGrammarLoaded(language: Language): boolean {
   if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
   if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
+  if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
   return languageCache.has(language);
   return languageCache.has(language);
 }
 }
 
 
@@ -301,6 +314,8 @@ export function getLanguageDisplayName(language: Language): string {
     scala: 'Scala',
     scala: 'Scala',
     lua: 'Lua',
     lua: 'Lua',
     luau: 'Luau',
     luau: 'Luau',
+    yaml: 'YAML',
+    twig: 'Twig',
     unknown: 'Unknown',
     unknown: 'Unknown',
   };
   };
   return names[language] || language;
   return names[language] || language;

+ 5 - 0
src/extraction/tree-sitter.ts

@@ -2535,6 +2535,11 @@ export function extractFromSource(
     // Use custom extractor for Liquid
     // Use custom extractor for Liquid
     const extractor = new LiquidExtractor(filePath, source);
     const extractor = new LiquidExtractor(filePath, source);
     result = extractor.extract();
     result = extractor.extract();
+  } else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig') {
+    // No symbol extraction — file is tracked at the file-record level only.
+    // Framework extractors (e.g. Drupal routing resolver) run below and may
+    // add route nodes / references for yaml files such as *.routing.yml.
+    result = { nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: 0 };
   } else if (
   } else if (
     detectedLanguage === 'pascal' &&
     detectedLanguage === 'pascal' &&
     (fileExtension === '.dfm' || fileExtension === '.fmx')
     (fileExtension === '.dfm' || fileExtension === '.fmx')

+ 373 - 0
src/resolution/frameworks/drupal.ts

@@ -0,0 +1,373 @@
+/**
+ * Drupal Framework Resolver
+ *
+ * Supports Drupal 8/9/10/11 (Composer-based projects). Drupal 7 is not supported.
+ *
+ * ## What this resolver does
+ *
+ * 1. **Detection** — reads composer.json and checks for any `drupal/*` dependency in
+ *    `require` or `require-dev`.
+ *
+ * 2. **Route extraction** — parses `*.routing.yml` files and emits `route` nodes for each
+ *    Drupal route, with `references` edges to the `_controller`, `_form`, or entity handler
+ *    class/method.
+ *
+ * 3. **Hook detection** — scans `.module`, `.install`, `.theme`, and `.inc` files for Drupal
+ *    hook implementations. Two strategies are used:
+ *      a. Docblock: `@Implements hook_X()` → precise, no false positives.
+ *      b. Name pattern: function `{moduleName}_{hookSuffix}()` → catches hooks without
+ *         docblocks but may produce false positives on helper functions.
+ *    Detected hooks emit an `UnresolvedRef` from the implementing function node to the
+ *    canonical `hook_X` name, linking implementations to the hook when `codegraph_callers`
+ *    is invoked.
+ *
+ * ## Design decisions (review in future iterations)
+ *
+ * - Hook graph resolution (v1): hook references are stored as UnresolvedRef pointing to the
+ *   canonical `hook_X` name. If Drupal core is indexed, these will resolve to core hook
+ *   definitions. Without core, they remain unresolved but are still searchable via
+ *   `codegraph_search("form_alter")`. Full hook-node creation (virtual nodes for every hook)
+ *   is deferred to a future iteration.
+ *
+ * - Services / plugins (out of scope for v1): `*.services.yml` service definitions and plugin
+ *   annotations (`@Block`, `@FormElement`, etc.) are not extracted. Add a TODO below when
+ *   ready to implement.
+ *
+ * - Twig templates (out of scope for v1): `.twig` files are tracked as file nodes but no
+ *   symbol extraction is performed (no tree-sitter Twig grammar). Implement when a Twig
+ *   grammar WASM is available.
+ *
+ * ## TODOs for future iterations
+ *
+ * - TODO: Extract service definitions from `*.services.yml` files (class → service-id edges).
+ * - TODO: Extract plugin annotations (`@Block`, `@FormElement`, `@Field`, etc.) from PHP
+ *   docblocks and emit plugin nodes with references to the annotated class.
+ * - TODO: Add Twig symbol extraction when a tree-sitter Twig grammar becomes available.
+ * - TODO: Improve hook resolution: create virtual `hook_*` nodes so `codegraph_callers`
+ *   returns all implementations even when Drupal core is not indexed.
+ */
+
+import { generateNodeId } from '../../extraction/tree-sitter-helpers';
+import { Node } from '../../types';
+import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse the last PHP namespace segment from a FQCN like `\Drupal\mymodule\Controller\Foo`.
+ * Returns `null` for strings that don't look like a FQCN.
+ */
+function lastSegment(fqcn: string): string | null {
+  const clean = fqcn.replace(/^\\+/, '').trim();
+  if (!clean.includes('\\')) return null;
+  const parts = clean.split('\\');
+  return parts[parts.length - 1] ?? null;
+}
+
+/**
+ * Derive the Drupal module name from a file path.
+ * e.g. `web/modules/custom/my_module/my_module.module` → `my_module`
+ */
+function moduleNameFromPath(filePath: string): string | null {
+  const match = filePath.match(/\/([^/]+)\.[^./]+$/);
+  return match ? match[1]! : null;
+}
+
+// ---------------------------------------------------------------------------
+// Route extraction helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract route nodes and handler references from a Drupal `*.routing.yml` file.
+ *
+ * Drupal routing YAML format:
+ *
+ *   route.name:
+ *     path: '/some/path'
+ *     defaults:
+ *       _controller: '\Drupal\module\Controller\MyController::method'
+ *       _form: '\Drupal\module\Form\MyForm'
+ *       _title: 'Page title'
+ *     requirements:
+ *       _permission: 'access content'
+ *     methods: [GET, POST]   # optional
+ */
+function extractDrupalRoutes(
+  filePath: string,
+  content: string
+): { nodes: Node[]; references: UnresolvedRef[] } {
+  const nodes: Node[] = [];
+  const references: UnresolvedRef[] = [];
+  const now = Date.now();
+
+  const lines = content.split('\n');
+
+  type PendingRoute = { name: string; lineNum: number };
+  let pending: PendingRoute | null = null;
+  let currentPath: string | null = null;
+  let handlerRefs: string[] = [];
+  let methods: string[] = [];
+
+  const flushRoute = () => {
+    if (!pending || !currentPath) return;
+
+    const methodTag = methods.length > 0 ? ` [${methods.join(',')}]` : '';
+    const routeNode: Node = {
+      id: `route:${filePath}:${pending.lineNum}:${currentPath}`,
+      kind: 'route',
+      name: `${currentPath}${methodTag}`,
+      qualifiedName: `${filePath}::${pending.name}`,
+      filePath,
+      startLine: pending.lineNum,
+      endLine: pending.lineNum,
+      startColumn: 0,
+      endColumn: 0,
+      language: 'yaml',
+      updatedAt: now,
+    };
+    nodes.push(routeNode);
+
+    for (const handler of handlerRefs) {
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: handler,
+        referenceKind: 'references',
+        line: pending.lineNum,
+        column: 0,
+        filePath,
+        language: 'yaml',
+      });
+    }
+  };
+
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]!;
+    const trimmed = line.trim();
+
+    if (!trimmed || trimmed.startsWith('#')) continue;
+
+    // Top-level route name: no leading whitespace, ends with a colon (no value after)
+    if (/^\S.*:\s*$/.test(line) && !/^\s/.test(line)) {
+      flushRoute();
+      pending = { name: trimmed.slice(0, -1).trim(), lineNum: i + 1 };
+      currentPath = null;
+      handlerRefs = [];
+      methods = [];
+      continue;
+    }
+
+    // path: '/some/path'
+    const pathMatch = trimmed.match(/^path:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
+    if (pathMatch) {
+      currentPath = pathMatch[1]!.trim();
+      continue;
+    }
+
+    // _controller: '\Drupal\...\Class::method'
+    const controllerMatch = trimmed.match(/^_controller:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
+    if (controllerMatch) {
+      handlerRefs.push(controllerMatch[1]!.trim());
+      continue;
+    }
+
+    // _form: '\Drupal\...\Form\MyForm'
+    const formMatch = trimmed.match(/^_form:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
+    if (formMatch) {
+      handlerRefs.push(formMatch[1]!.trim());
+      continue;
+    }
+
+    // _entity_form / _entity_list / _entity_view: entity.type
+    const entityMatch = trimmed.match(/^_(entity_form|entity_list|entity_view):\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/);
+    if (entityMatch) {
+      handlerRefs.push(entityMatch[2]!.trim());
+      continue;
+    }
+
+    // methods: [GET, POST]  or  methods: [GET]
+    const methodsMatch = trimmed.match(/^methods:\s*\[([^\]]+)\]/);
+    if (methodsMatch) {
+      methods = methodsMatch[1]!.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean);
+      continue;
+    }
+  }
+
+  flushRoute();
+  return { nodes, references };
+}
+
+// ---------------------------------------------------------------------------
+// Hook detection helpers
+// ---------------------------------------------------------------------------
+
+const HOOK_FILE_EXTENSIONS = ['.module', '.install', '.theme', '.inc'];
+
+function isDrupalHookFile(filePath: string): boolean {
+  return HOOK_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
+}
+
+/**
+ * Extract hook implementation references from a Drupal PHP file.
+ *
+ * Strategy A (primary): look for docblocks containing `Implements hook_X().`
+ * followed immediately by the function definition. This is the Drupal coding
+ * standard and is precise.
+ *
+ * Strategy B (fallback): for functions whose name starts with `{moduleName}_`,
+ * treat the suffix as the hook name. Catches hooks without docblocks but may
+ * produce false positives on non-hook helper functions.
+ *
+ * Each detected hook emits an UnresolvedRef from the implementing function node
+ * (identified by computing the same ID tree-sitter would generate) to the
+ * canonical hook name, e.g. `hook_form_alter`.
+ */
+function extractDrupalHooks(
+  filePath: string,
+  content: string
+): { nodes: Node[]; references: UnresolvedRef[] } {
+  const references: UnresolvedRef[] = [];
+
+  // Build a map of function name → 1-indexed line number for all top-level functions.
+  // This mirrors tree-sitter's line numbering so we can reconstruct node IDs.
+  const funcLineMap = new Map<string, number>();
+  const funcDef = /^function\s+(\w+)\s*\(/gm;
+  let fm: RegExpExecArray | null;
+  while ((fm = funcDef.exec(content)) !== null) {
+    const name = fm[1]!;
+    if (!funcLineMap.has(name)) {
+      // line = number of newlines before match start + 1
+      funcLineMap.set(name, content.slice(0, fm.index).split('\n').length);
+    }
+  }
+
+  const emitHookRef = (hookName: string, funcName: string) => {
+    const lineNum = funcLineMap.get(funcName);
+    if (lineNum === undefined) return;
+    const nodeId = generateNodeId(filePath, 'function', funcName, lineNum);
+    references.push({
+      fromNodeId: nodeId,
+      referenceName: hookName,
+      referenceKind: 'references',
+      line: lineNum,
+      column: 0,
+      filePath,
+      language: 'php',
+    });
+  };
+
+  // Strategy A: docblock `Implements hook_X().` followed by function definition.
+  // The docblock and function may be separated by blank lines.
+  const docblockPattern =
+    /\/\*\*[\s\S]*?(?:@|\*\s+)Implements\s+(hook_\w+)\s*\(\)[\s\S]*?\*\/\s*\n(?:\s*\n)*function\s+(\w+)\s*\(/g;
+  const docblockMatched = new Set<string>();
+  let match: RegExpExecArray | null;
+  while ((match = docblockPattern.exec(content)) !== null) {
+    const [, hookName, funcName] = match;
+    emitHookRef(hookName!, funcName!);
+    docblockMatched.add(funcName!);
+  }
+
+  // Strategy B: fallback name-pattern matching for functions without docblocks.
+  // Only applies to functions whose name starts with {moduleName}_ and that were
+  // not already matched by Strategy A.
+  const moduleName = moduleNameFromPath(filePath);
+  if (moduleName) {
+    const prefix = moduleName + '_';
+    for (const [funcName] of funcLineMap) {
+      if (docblockMatched.has(funcName)) continue;
+      if (!funcName.startsWith(prefix)) continue;
+      const hookSuffix = funcName.slice(prefix.length);
+      if (!hookSuffix) continue;
+      // Emit a reference to hook_{suffix} — the resolver will link it if the
+      // hook is defined somewhere in the indexed graph (e.g. Drupal core).
+      emitHookRef(`hook_${hookSuffix}`, funcName);
+    }
+  }
+
+  return { nodes: [], references };
+}
+
+// ---------------------------------------------------------------------------
+// Resolver
+// ---------------------------------------------------------------------------
+
+export const drupalResolver: FrameworkResolver = {
+  name: 'drupal',
+  languages: ['php', 'yaml'],
+
+  detect(context: ResolutionContext): boolean {
+    const composer = context.readFile('composer.json');
+    if (!composer) return false;
+    try {
+      const json = JSON.parse(composer) as { require?: Record<string, string>; 'require-dev'?: Record<string, string> };
+      const deps = { ...json.require, ...(json['require-dev'] ?? {}) };
+      return Object.keys(deps).some((k) => k.startsWith('drupal/'));
+    } catch {
+      return false;
+    }
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    const name = ref.referenceName;
+
+    // _controller: '\Drupal\module\...\ClassName::methodName'
+    const controllerMatch = name.match(/^\\?(?:Drupal\\[^:]+\\)?([^\\:]+)::(\w+)$/);
+    if (controllerMatch) {
+      const [, className, methodName] = controllerMatch;
+      const classNodes = context.getNodesByName(className!);
+      for (const cls of classNodes) {
+        if (cls.kind !== 'class') continue;
+        const fileNodes = context.getNodesInFile(cls.filePath);
+        const method = fileNodes.find((n) => n.kind === 'method' && n.name === methodName);
+        if (method) {
+          return { original: ref, targetNodeId: method.id, confidence: 0.9, resolvedBy: 'framework' };
+        }
+        return { original: ref, targetNodeId: cls.id, confidence: 0.7, resolvedBy: 'framework' };
+      }
+    }
+
+    // _form / _entity_form: '\Drupal\module\...\ClassName'  (no ::method)
+    if (name.includes('\\') && !name.includes('::')) {
+      const className = lastSegment(name);
+      if (className) {
+        const classNodes = context.getNodesByName(className);
+        const cls = classNodes.find((n) => n.kind === 'class');
+        if (cls) {
+          return { original: ref, targetNodeId: cls.id, confidence: 0.85, resolvedBy: 'framework' };
+        }
+      }
+    }
+
+    // hook_X — find any function whose name ends in _{hookSuffix} in a hook file
+    if (name.startsWith('hook_')) {
+      const hookSuffix = name.slice(5); // strip 'hook_'
+      const candidates = context.getNodesByKind('function').filter(
+        (n) => n.name.endsWith(`_${hookSuffix}`) && isDrupalHookFile(n.filePath)
+      );
+      if (candidates.length > 0) {
+        return {
+          original: ref,
+          targetNodeId: candidates[0]!.id,
+          confidence: 0.75,
+          resolvedBy: 'framework',
+        };
+      }
+    }
+
+    return null;
+  },
+
+  extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } {
+    if (filePath.endsWith('.routing.yml')) {
+      return extractDrupalRoutes(filePath, content);
+    }
+
+    if (isDrupalHookFile(filePath) || filePath.endsWith('.php')) {
+      return extractDrupalHooks(filePath, content);
+    }
+
+    return { nodes: [], references: [] };
+  },
+};

+ 3 - 0
src/resolution/frameworks/index.ts

@@ -6,6 +6,7 @@
 
 
 import { FrameworkResolver, ResolutionContext } from '../types';
 import { FrameworkResolver, ResolutionContext } from '../types';
 import type { Language } from '../../types';
 import type { Language } from '../../types';
+import { drupalResolver } from './drupal';
 import { laravelResolver } from './laravel';
 import { laravelResolver } from './laravel';
 import { expressResolver } from './express';
 import { expressResolver } from './express';
 import { nestjsResolver } from './nestjs';
 import { nestjsResolver } from './nestjs';
@@ -26,6 +27,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
 const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   // PHP
   // PHP
   laravelResolver,
   laravelResolver,
+  drupalResolver,
   // JavaScript/TypeScript
   // JavaScript/TypeScript
   expressResolver,
   expressResolver,
   nestjsResolver,
   nestjsResolver,
@@ -105,6 +107,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
 }
 }
 
 
 // Re-export framework resolvers
 // Re-export framework resolvers
+export { drupalResolver } from './drupal';
 export { laravelResolver, FACADE_MAPPINGS } from './laravel';
 export { laravelResolver, FACADE_MAPPINGS } from './laravel';
 export { expressResolver } from './express';
 export { expressResolver } from './express';
 export { nestjsResolver } from './nestjs';
 export { nestjsResolver } from './nestjs';

+ 16 - 0
src/types.ts

@@ -87,6 +87,8 @@ export const LANGUAGES = [
   'scala',
   'scala',
   'lua',
   'lua',
   'luau',
   'luau',
+  'yaml',
+  'twig',
   'unknown',
   'unknown',
 ] as const;
 ] as const;
 
 
@@ -522,6 +524,15 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
     '**/*.cs',
     '**/*.cs',
     // PHP
     // PHP
     '**/*.php',
     '**/*.php',
+    // Drupal-specific PHP extensions
+    '**/*.module',
+    '**/*.install',
+    '**/*.theme',
+    '**/*.inc',
+    // Drupal routing YAML
+    '**/*.routing.yml',
+    // Twig templates
+    '**/*.twig',
     // Ruby
     // Ruby
     '**/*.rb',
     '**/*.rb',
     // Swift
     // Swift
@@ -667,6 +678,11 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
     '**/storage/framework/**',
     '**/storage/framework/**',
     '**/bootstrap/cache/**',
     '**/bootstrap/cache/**',
 
 
+    // Drupal - core and contrib are rarely customised; index only custom code
+    '**/web/core/**',
+    '**/web/modules/contrib/**',
+    '**/web/themes/contrib/**',
+
     // Ruby
     // Ruby
     '**/.bundle/**',
     '**/.bundle/**',
     '**/tmp/cache/**',
     '**/tmp/cache/**',