diff --git a/CHANGELOG.md b/CHANGELOG.md index 773a2459f..52550dbb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,13 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - CodeGraph's refusal to use a sensitive folder such as `~/.ssh`, `~/.config`, `/etc` or `C:\Windows` as a project can no longer be sidestepped: a link or junction that leads to one, a differently capitalized name on Windows and macOS, a `\\?\` spelling on Windows, and a path to a missing folder inside one are now refused like the folder itself. Before, any of these let an AI agent's query read the files inside. Thanks @mixxer for finding it. - The MCP server no longer uses a sensitive folder such as `~/.ssh`, `~/.config` or `/etc` as a session's default project, whether the folder came from your AI agent's workspace, the folder the server was started in, or `--path`. Before, only a folder an agent asked for by path was refused, so a session started in one that held an index answered from the files inside and kept it synced; now that session has no default project, and the agent can still query any other project by path. +### New Features + +- Symfony support: routes from `#[Route]` attributes, `config/routes.yaml` and the compiled router cache now appear as entry points linked to the controller action that serves them, including Shopware 6 shops and plugins. Thanks @jorgerobles. (#611) + ### Fixes +- In PHP, an attribute such as `#[AsCommand]`, `#[ORM\Entity]` or `#[Route]` on a class, method or function now links to the attribute's class, so attribute classes show who uses them. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) diff --git a/__tests__/kernel-php-parity.test.ts b/__tests__/kernel-php-parity.test.ts index d95deb1c5..4d5f1bce8 100644 --- a/__tests__/kernel-php-parity.test.ts +++ b/__tests__/kernel-php-parity.test.ts @@ -17,7 +17,7 @@ * anonymous-class garbage ref + file-level-function methods), static value * reads, php type refs, HOF string/array callables, value-ref targets * (namespaced top-level consts DROPPED), heredoc/nowdoc/interpolation, - * attributes shifting node lines without emitting. + * attributes as `decorates` refs. * - TortureModule.module — drupal extension routing + un-namespaced * top-level const value-ref target + hook-docblocked function. * - TortureHtml.php — leading/interleaved HTML (absolute row positions), @@ -136,6 +136,27 @@ describe.skipIf(!kernelBuilt)('kernel PHP extraction parity', () => { expect(impl?.filePath).toBe('src/W.php'); }); + it('attributes on classes, methods and functions are decorates refs on both paths', () => { + const src = [ + ' r.referenceKind === 'decorates') + .map((r) => `${r.line}:${r.referenceName}`) + .sort(); + expect(decorates).toEqual(['2:Entity', '2:Package', '4:Route', '4:Route', '7:AsCommand']); + }); + it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => { const broken = ' { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('Symfony end-to-end — PHP 8 #[Route] attribute extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('extracts route nodes and reference edges from #[Route] attributes', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-attr-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\n r.name === 'GET /blog/'); + expect(indexRoute).toBeDefined(); + + const showRoute = routes.find(r => r.name === 'GET /blog/{slug}'); + expect(showRoute).toBeDefined(); + + // Check reference edges from route → handler method + const indexEdges = cg.getOutgoingEdges(indexRoute!.id); + const indexRef = indexEdges.find(e => e.kind === 'references'); + expect(indexRef).toBeDefined(); + + const methods = cg.getNodesByKind('method'); + const indexMethod = methods.find(m => m.name === 'index'); + expect(indexMethod).toBeDefined(); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — YAML route extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('extracts route nodes from YAML config files', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-yaml-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^6.4' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\n r.name === 'ANY /'); + expect(homepage).toBeDefined(); + + const blog = routes.find(r => r.name === 'GET /blog'); + expect(blog).toBeDefined(); + + // Reference edges require a matching controller PHP file to resolve; + // YAML-only tests verify route nodes are created correctly. + cg.close(); + }); +}); + +describe('Symfony end-to-end — compiled DI container', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('extracts service nodes from a compiled container file', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-di-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\nprivates['logger'] ?? \\$this->load('getLoggerService'); + } + + protected function getBlogRepositoryService(): \\App\\Repository\\BlogRepository + { + return new \\App\\Repository\\BlogRepository(\\$this->getLoggerService()); + } +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const variables = cg.getNodesByKind('variable'); + const loggerSvc = variables.find(v => v.name === 'Logger'); + expect(loggerSvc).toBeDefined(); + expect(loggerSvc!.qualifiedName).toContain('Monolog'); + + const blogRepo = variables.find(v => v.name === 'BlogRepository'); + expect(blogRepo).toBeDefined(); + expect(blogRepo!.qualifiedName).toContain('App\\Repository\\BlogRepository'); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — Doctrine entity detection', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('links an #[Entity] class to its attribute class without a duplicate class node', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-doctrine-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\n c.name === 'BlogPost'); + expect(blogPosts).toHaveLength(1); + + const entityAttr = classes.find(c => c.name === 'Entity'); + expect(entityAttr).toBeDefined(); + const decorates = cg.getOutgoingEdges(blogPosts[0]!.id).filter(e => e.kind === 'decorates'); + expect(decorates.map(e => e.target)).toContain(entityAttr!.id); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — console command detection', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('indexes an #[AsCommand] class once', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-cmd-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\n c.name === 'GenerateReportCommand'); + expect(commands).toHaveLength(1); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — Twig template reference extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('extracts Twig template references from $this->render() calls', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-twig-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\nrender('blog/index.html.twig'); + } + + public function show(): array + { + return \\$this->render('blog/show.html.twig'); + } +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const refs = cg.searchNodes(''); + + // The edge target names for Twig templates are stored as reference names + // Twig references are stored on route nodes emitted by the Twig + // extractor. Verify the route node exists. + const routes = cg.getNodesByKind('route'); + expect(routes.length).toBeGreaterThanOrEqual(0); + // The controller is also indexed as a class + const controller = cg.getNodesByKind('class').find(c => c.name === 'BlogController'); + expect(controller).toBeDefined(); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — full-stack symfony project', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('indexes a complete Symfony project with controllers, YAML routes, and DI container', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-full-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\nprivates['logger'] ?? \\$this->load('getLoggerService'); + } +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Route extraction + const routes = cg.getNodesByKind('route'); + const attrRoute = routes.find(r => r.name === 'ANY /'); + expect(attrRoute).toBeDefined(); + + // Framework detection adds no second node for a class the PHP extractor already indexed + const classKeys = cg.getNodesByKind('class').map(c => `${c.filePath}::${c.name}`); + expect(new Set(classKeys).size).toBe(classKeys.length); + + // DI container service extraction + const services = cg.getNodesByKind('variable'); + const loggerSvc = services.find(s => s.name === 'Logger'); + expect(loggerSvc).toBeDefined(); + expect(loggerSvc!.qualifiedName).toContain('Monolog'); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — compiled container route extraction', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('extracts routes from $routes->add() in compiled container', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-cont-routes-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\nadd('blog_index', new Route('/', ['_controller' => 'App\\\\Controller\\\\BlogController::index'], [], [], '', [], ['GET'])); + \\$routes->add('blog_show', new Route('/blog/{slug}', ['_controller' => 'App\\\\Controller\\\\BlogController::show'], ['slug' => '[a-z]+'], [], '', [], ['GET'])); + return new Router(\\$routes); + } + + protected function getLoggerService(): \\Monolog\\Logger + { + return \\$this->privates['logger'] ?? \\$this->load('getLoggerService'); + } +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const routes = cg.getNodesByKind('route'); + const blogIndex = routes.find(r => r.name === 'GET /'); + expect(blogIndex).toBeDefined(); + expect(blogIndex!.qualifiedName).toContain('blog_index'); + + const blogShow = routes.find(r => r.name === 'GET /blog/{slug}'); + expect(blogShow).toBeDefined(); + expect(blogShow!.qualifiedName).toContain('blog_show'); + + // Controller references are stored as unresolved refs in extraction; + // they only become edges when the target node exists in the graph + // (no controller file in this test, so no edge — that's correct) + + // Service extraction should still work + const services = cg.getNodesByKind('variable'); + const loggerSvc = services.find(s => s.name === 'Logger'); + expect(loggerSvc).toBeDefined(); + + cg.close(); + }); + + it('extracts routes with PHP short array syntax from compiled container', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-cont-arr-')); + fs.writeFileSync( + path.join(tmpDir, 'composer.json'), + JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + ); + fs.mkdirSync(path.join(tmpDir, 'bin')); + fs.writeFileSync(path.join(tmpDir, 'bin/console'), '#!/usr/bin/env php\nadd('api_entries', new Route('/api/entries', ['_controller' => 'App\\\\Controller\\\\ApiController::list'], [], [], '', [], ['GET', 'POST'])); + return new Router(\\$routes); + } +} +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const routes = cg.getNodesByKind('route'); + expect(routes).toHaveLength(2); + expect(routes.map(r => r.name)).toContain('GET /api/entries'); + expect(routes.map(r => r.name)).toContain('POST /api/entries'); + + cg.close(); + }); +}); + +describe('Symfony end-to-end — controller resolution and attribute edges', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('links a YAML route to the action of the class its FQCN names, and attributes to their class', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-fqcn-')); + fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } })); + fs.mkdirSync(path.join(tmpDir, 'config')); + fs.writeFileSync( + path.join(tmpDir, 'config/routes.yaml'), + 'blog:\n path: /blog\n controller: App\\Controller\\BlogController::index\n' + ); + fs.mkdirSync(path.join(tmpDir, 'src/Controller/Admin'), { recursive: true }); + for (const [file, ns] of [['src/Controller/BlogController.php', 'App\\Controller'], ['src/Controller/Admin/BlogController.php', 'App\\Controller\\Admin']]) { + fs.writeFileSync( + path.join(tmpDir, file), + ` r.name === 'ANY /blog'); + expect(route).toBeDefined(); + const target = cg.getOutgoingEdges(route!.id).find((e) => e.kind === 'references')?.target; + const action = cg.getNodesByKind('method').find((m) => m.id === target); + expect(action?.filePath).toBe('src/Controller/BlogController.php'); + + const attrClass = cg.getNodesByKind('class').find((c) => c.name === 'AsController'); + const decorated = cg.getIncomingEdges(attrClass!.id).filter((e) => e.kind === 'decorates'); + expect(decorated).toHaveLength(2); + + cg.close(); + }); +}); diff --git a/__tests__/symfony-resolver.test.ts b/__tests__/symfony-resolver.test.ts new file mode 100644 index 000000000..d2cb3ff04 --- /dev/null +++ b/__tests__/symfony-resolver.test.ts @@ -0,0 +1,805 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import type { Node } from '../src/types'; +import { symfonyResolver } from '../src/resolution/frameworks/symfony'; + +// The Symfony #[Route] extractor reads the PHP AST +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['php']); +}); + +describe('symfonyResolver.extract — PHP 8 attributes', () => { + it('extracts route from #[Route] attribute with path and method', () => { + const src = `render('blog/index.html.twig'); + } +} +`; + const { nodes, references } = symfonyResolver.extract!('src/Controller/BlogController.php', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe('route'); + expect(nodes[0].name).toBe('GET /blog/'); + expect(references[0].referenceName).toBe('index'); + expect(references[0].referenceKind).toBe('references'); + expect(references[0].fromNodeId).toBe(nodes[0].id); + }); + + it('joins class-level #[Route] prefix with method path', () => { + const src = ` { + const src = ` n.name)).toEqual(['GET /blog/{slug}', 'GET /blog/search']); + expect(references.map(r => r.referenceName)).toEqual(['postShow', 'search']); + }); + + it('defaults method to ANY when no methods attribute specified', () => { + const src = ` { + const src = ` n.name)).toEqual(['GET /admin/post/new', 'POST /admin/post/new']); + }); + + it('extracts with no class-level prefix (bare #[Route] on methods)', () => { + const src = ` { + const src = ` { + const src = ` n.name)).toEqual(['GET /api/items/{id}', 'HEAD /api/items/{id}']); + }); +}); + +describe('symfonyResolver.detect', () => { + const baseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + it('detects symfony/framework-bundle in composer.json require', () => { + const context = { + ...baseContext, + readFile: (p: string) => + p === 'composer.json' + ? JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }) + : null, + }; + expect(symfonyResolver.detect(context as any)).toBe(true); + }); + + it('detects a Shopware plugin, which requires only shopware/core and has no bin/console', () => { + const context = { + ...baseContext, + readFile: (p: string) => + p === 'composer.json' + ? JSON.stringify({ type: 'shopware-platform-plugin', require: { 'shopware/core': '~6.6.0' } }) + : null, + }; + expect(symfonyResolver.detect(context as any)).toBe(true); + }); + + it('detects symfony/symfony in composer.json require-dev', () => { + const context = { + ...baseContext, + readFile: (p: string) => + p === 'composer.json' + ? JSON.stringify({ 'require-dev': { 'symfony/symfony': '^5.4' } }) + : null, + }; + expect(symfonyResolver.detect(context as any)).toBe(true); + }); + + it('detects by bin/console + config/ fallback', () => { + const context = { + ...baseContext, + fileExists: (p: string) => p === 'bin/console' || p === 'config/', + }; + expect(symfonyResolver.detect(context as any)).toBe(true); + }); + + it('returns false for non-Symfony project', () => { + const context = { + ...baseContext, + readFile: (p: string) => + p === 'composer.json' + ? JSON.stringify({ require: { 'laravel/framework': '^10.0' } }) + : null, + }; + expect(symfonyResolver.detect(context as any)).toBe(false); + }); +}); + +describe('symfonyResolver.resolve', () => { + const baseContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: () => false, + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => [], + getNodesByLowerName: () => [], + getImportMappings: () => [], + }; + + it('resolves Controller::method reference', () => { + const classNode: Node = { + id: 'class:src/Controller/BlogController.php:10:BlogController:50', + kind: 'class', + name: 'BlogController', + qualifiedName: 'App\\Controller::BlogController', + filePath: 'src/Controller/BlogController.php', + language: 'php', + startLine: 10, + endLine: 60, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const methodNode: Node = { + id: 'method:src/Controller/BlogController.php:50:index:50', + kind: 'method', + name: 'index', + qualifiedName: 'src/Controller/BlogController.php::index', + filePath: 'src/Controller/BlogController.php', + language: 'php', + startLine: 50, + endLine: 50, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { + ...baseContext, + fileExists: (p: string) => p === 'src/Controller/BlogController.php', + getNodesInFile: (p: string) => p === 'src/Controller/BlogController.php' ? [methodNode] : [], + getNodesByName: (name: string) => name === 'BlogController' ? [classNode] : [], + // The class comes from the name index; scanning every class is not needed. + getNodesByKind: () => { throw new Error('getNodesByKind must not be called'); }, + }; + const ref = { + fromNodeId: 'route:config/routes.yaml:15:GET:/blog', + referenceName: 'App\\Controller\\BlogController::index', + referenceKind: 'references' as const, + line: 15, + column: 0, + filePath: 'config/routes.yaml', + language: 'yaml' as const, + }; + const result = symfonyResolver.resolve(ref, context as any); + expect(result?.targetNodeId).toBe(methodNode.id); + expect(result?.confidence).toBeGreaterThanOrEqual(0.8); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('leaves a FQCN unresolved when only another namespace has a class of that name', () => { + const other: Node = { + id: 'class:src/Other/Logger.php:3:Logger:0', + kind: 'class', + name: 'Logger', + qualifiedName: 'Other::Logger', + filePath: 'src/Other/Logger.php', + language: 'php', + startLine: 3, + endLine: 9, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + }; + const context = { ...baseContext, getNodesByName: (name: string) => (name === 'Logger' ? [other] : []) }; + const ref = { + fromNodeId: 'file:config/services.yaml', + referenceName: 'External\\Logger', + referenceKind: 'references' as const, + line: 3, + column: 0, + filePath: 'config/services.yaml', + language: 'yaml' as const, + }; + expect(symfonyResolver.resolve(ref, context as any)).toBeNull(); + expect(symfonyResolver.resolve({ ...ref, referenceName: 'Other\\Logger' }, context as any)?.targetNodeId).toBe(other.id); + }); + + it('returns null for unresolvable reference', () => { + const ref = { + fromNodeId: 'x', + referenceName: 'SomeNonExistentClass::method', + referenceKind: 'references' as const, + line: 1, + column: 1, + filePath: 'a.yaml', + language: 'yaml' as const, + }; + expect(symfonyResolver.resolve(ref, baseContext as any)).toBeNull(); + }); +}); + +describe('symfonyResolver.extract — YAML routes', () => { + it('extracts route from YAML config with controller reference', () => { + const src = `homepage: + path: / + controller: Symfony\\Bundle\\FrameworkBundle\\Controller\\TemplateController::templateAction + defaults: + template: default/homepage.html.twig +`; + const { nodes, references } = symfonyResolver.extract!('config/routes.yaml', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('ANY /'); + expect(references[0].referenceName).toBe('Symfony\\Bundle\\FrameworkBundle\\Controller\\TemplateController::templateAction'); + }); + + it('extracts YAML route with methods specified', () => { + const src = `api_index: + path: /api + controller: App\\Controller\\ApiController::index + methods: [GET, POST] +`; + const { nodes, references } = symfonyResolver.extract!('config/routes/api.yaml', src); + expect(nodes).toHaveLength(2); + expect(nodes.map(n => n.name)).toEqual(['GET /api', 'POST /api']); + expect(references[0].referenceName).toBe('App\\Controller\\ApiController::index'); + }); + + it('returns empty for non-route YAML files', () => { + const src = `framework: + secret: '%env(APP_SECRET)%' + test: true +`; + const { nodes, references } = symfonyResolver.extract!('config/packages/framework.yaml', src); + expect(nodes).toEqual([]); + expect(references).toEqual([]); + }); +}); + +describe('symfonyResolver — comment stripping regression', () => { + it('skips commented-out #[Route] attributes', () => { + const src = ` n.name)).toEqual(['GET /real']); + }); + + it('skips commented-out YAML routes', () => { + const src = `# fake_route: + # path: /fake + # controller: App\\Controller\\FakeController::index + +real_route: + path: /real + controller: App\\Controller\\RealController::index +`; + const { nodes } = symfonyResolver.extract!('config/routes.yaml', src); + expect(nodes.map(n => n.name)).toEqual(['ANY /real']); + }); +}); + +describe('symfonyResolver — DI container', () => { + it('extracts services from compiled container PHP', () => { + const src = `privates['doctrine'] ?? \$this->load('getDoctrineService'); + } + + protected function getBlogRepositoryService(): \\App\\Repository\\BlogRepository + { + return new \\App\\Repository\\BlogRepository(\$this->getDoctrineService()); + } +} +`; + const { nodes, references } = symfonyResolver.extract!('var/cache/dev/Container_XYZ.php', src); + const svcNames = nodes.filter(n => n.kind === 'variable').map(n => n.name); + expect(svcNames).toContain('Doctrine'); + expect(svcNames).toContain('BlogRepository'); + const fqcns = references.map(r => r.referenceName); + expect(fqcns).toContain('\\Doctrine\\ORM\\EntityManager'); + expect(fqcns).toContain('\\App\\Repository\\BlogRepository'); + }); + + it('extracts service references from config/services.yaml', () => { + const src = `services: + App\\Repository\\PostRepository: + arguments: + - '@doctrine' + + App\\Service\\MailerService: + autowire: true +`; + const { references } = symfonyResolver.extract!('config/services.yaml', src); + const fqcns = references.map(r => r.referenceName); + expect(fqcns).toContain('App\\Repository\\PostRepository'); + expect(fqcns).toContain('App\\Service\\MailerService'); + }); +}); + +describe('symfonyResolver.extract — Phase 2.6 attribute params', () => { + function parseMeta(nodes: any[]): Record | null { + const sig = nodes[0]?.signature; + return sig ? JSON.parse(sig) : null; + } + + it('extracts defaults from #[Route] and stores as signature', () => { + const src = ` 1])] + public function page(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/BlogController.php', src); + const meta = parseMeta(nodes); + expect(meta).not.toBeNull(); + expect(meta!.defaults).toEqual({ page: '1' }); + }); + + it('extracts requirements from #[Route] and stores as signature', () => { + const src = ` '[a-z]+'])] + public function show(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/BlogController.php', src); + const meta = parseMeta(nodes); + expect(meta).not.toBeNull(); + expect(meta!.requirements).toEqual({ slug: '[a-z]+' }); + }); + + it('extracts host from #[Route] and stores as signature', () => { + const src = ` { + const src = ` { + const src = ` 'en'], requirements: ['locale' => 'en|fr|de'], host: 'example.com', schemes: ['HTTPS'])] + public function app(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/LocaleController.php', src); + const meta = parseMeta(nodes); + expect(meta).not.toBeNull(); + expect(meta!.defaults).toEqual({ locale: 'en' }); + expect(meta!.requirements).toEqual({ locale: 'en|fr|de' }); + expect(meta!.host).toBe('example.com'); + expect(meta!.schemes).toEqual(['HTTPS']); + }); + + it('leaves signature empty when no extra params are present', () => { + const src = ` val]', () => { + const src = ` 'en'])] + public function app(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/LocaleController.php', src); + const sig = nodes[0]?.signature; + expect(sig).toBeDefined(); + const meta = JSON.parse(sig); + expect(meta.defaults).toEqual({ locale: 'en' }); + }); + + it('extracts requirements from PHP short array syntax [key => val]', () => { + const src = ` '[a-z]+'])] + public function show(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/SlugController.php', src); + const sig = nodes[0]?.signature; + expect(sig).toBeDefined(); + const meta = JSON.parse(sig); + expect(meta.requirements).toEqual({ slug: '[a-z]+' }); + }); + + it('keeps defaults and requirements apart when both are arrays', () => { + const src = ` 'en'], requirements: ['slug' => '[a-z]+'])] + public function app(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/MixedController.php', src); + const sig = nodes[0]?.signature; + expect(sig).toBeDefined(); + const meta = JSON.parse(sig); + expect(meta.defaults).toEqual({ locale: 'en' }); + expect(meta.requirements).toEqual({ slug: '[a-z]+' }); + }); +}); + +describe('symfonyResolver.extract — broader container path', () => { + it('detects compiled container in var/cache path', () => { + const src = `privates['logger'] ?? \$this->load('getLoggerService'); + } +} +`; + const { nodes } = symfonyResolver.extract!('var/cache/dev/Container_XYZ.php', src); + expect(nodes.filter(n => n.kind === 'variable').map(n => n.name)).toContain('Logger'); + }); + + it('detects compiled container in .symfony cache path', () => { + const src = `privates['cache'] ?? \$this->load('getCacheService'); + } +} +`; + const { nodes } = symfonyResolver.extract!('.symfony/cache/App_KernelContainer.php', src); + expect(nodes.filter(n => n.kind === 'variable').map(n => n.name)).toContain('Cache'); + }); + + it('does not read a Cache source folder as the compiled container', () => { + const src = ` n.kind === 'variable')).toEqual([]); + }); +}); + +describe('symfonyResolver.extract — YAML prefix detection', () => { + it('detects prefix from resource import (prefix not applied to sibling routes)', () => { + const src = `app_controllers: + resource: ../src/Controller/ + type: attribute + prefix: /app + +app_index: + path: / + controller: App\\Controller\\DefaultController::index +`; + const { nodes } = symfonyResolver.extract!('config/routes.yaml', src); + // prefix on a resource import does NOT apply to sibling routes + // prefix would be applied via postExtract to routes from the imported resource + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('ANY /'); + }); + + it('prefixed route with resource is not emitted (no path)', () => { + const src = `admin_pages: + resource: ../src/Admin/ + prefix: /admin + +admin_dashboard: + path: /dashboard + controller: App\\Controller\\Admin\\DashboardController::index +`; + const { nodes } = symfonyResolver.extract!('config/routes/admin.yaml', src); + expect(nodes).toHaveLength(1); + expect(nodes[0].name).toBe('ANY /dashboard'); + }); +}); + +describe('symfonyResolver.extract — Shopware-style controllers', () => { + it('keeps the first method route after a path-less class-level #[Route]', () => { + const src = ` ['storefront']])] +#[Package('storefront')] +class AuthController extends StorefrontController +{ + #[Route(path: '/account/login', name: 'frontend.account.login.page', methods: ['GET'])] + public function loginPage(): Response {} + + #[Route(path: '/account/register', name: 'frontend.account.register.page', methods: ['GET'])] + public function accountRegisterPage(): Response {} +} +`; + const { nodes, references } = symfonyResolver.extract!('src/Controller/AuthController.php', src); + expect(nodes.map(n => n.name)).toEqual(['GET /account/login', 'GET /account/register']); + expect(references.map(r => r.referenceName)).toEqual(['loginPage', 'accountRegisterPage']); + }); + + it('emits every stacked #[Route] of one action', () => { + const src = ` n.name)).toEqual(['POST /account/address/create', 'POST /account/address/{addressId}']); + expect(references.every(r => r.referenceName === 'saveAddress')).toBe(true); + }); + + it('reads a multi-line #[Route] with nested defaults', () => { + const src = ` true, + '_loginRequired' => true, + '_template' => 'checkout/phone.html.twig', + ], + methods: ['POST'] + )] + public function updatePhone(): Response {} +} +`; + const { nodes } = symfonyResolver.extract!('src/Controller/CheckoutController.php', src); + expect(nodes.map(n => n.name)).toEqual(['POST /checkout/update-phone']); + expect(JSON.parse(nodes[0]!.signature!).defaults).toEqual({ + XmlHttpRequest: 'true', + _loginRequired: 'true', + _template: 'checkout/phone.html.twig', + }); + }); + + it('does not read the directory of a nested resource import as a route', () => { + const src = `controllers: + resource: + path: ../src/Controller/ + namespace: App\\Controller + type: attribute + +app_index: + path: / + controller: App\\Controller\\DefaultController::index +`; + const { nodes } = symfonyResolver.extract!('config/routes.yaml', src); + expect(nodes.map(n => n.name)).toEqual(['ANY /']); + }); +}); + +describe('symfonyResolver.extract — bundle service YAML', () => { + it('extracts service references from vendor bundle services.yaml', () => { + const src = `services: + Acme\\BlogBundle\\Controller\\PostController: + tags: ['controller.service_arguments'] + + Acme\\BlogBundle\\Repository\\PostRepository: + arguments: + - '@doctrine' +`; + const { references } = symfonyResolver.extract!('vendor/acme/blog-bundle/Resources/config/services.yaml', src); + const fqcns = references.map(r => r.referenceName); + expect(fqcns).toContain('Acme\\BlogBundle\\Controller\\PostController'); + expect(fqcns).toContain('Acme\\BlogBundle\\Repository\\PostRepository'); + }); +}); + +describe('symfonyResolver.extract — no duplicate class nodes', () => { + it('leaves entity, repository, subscriber and command classes to the PHP extractor', () => { + const src = ` n.kind === 'class')).toEqual([]); + }); +}); + +describe('symfonyResolver.extract — Twig template references', () => { + it('extracts Twig template reference from $this->render()', () => { + const src = `render('blog/index.html.twig'); + } +} +`; + const { references } = symfonyResolver.extract!('src/Controller/BlogController.php', src); + const twigRef = references.find(r => r.referenceName === 'blog/index.html.twig'); + expect(twigRef).toBeDefined(); + expect(twigRef!.referenceKind).toBe('references'); + }); + + it('extracts multiple Twig template references', () => { + const src = `render('blog/index.html.twig'); + } + + public function show(): Response + { + return \$this->render('blog/show.html.twig'); + } +} +`; + const { references } = symfonyResolver.extract!('src/Controller/BlogController.php', src); + const templates = references.filter(r => r.referenceName.endsWith('.twig')).map(r => r.referenceName); + expect(templates).toContain('blog/index.html.twig'); + expect(templates).toContain('blog/show.html.twig'); + }); +}); + +describe('symfonyResolver.extract — pathological input', () => { + // `#[Route(…)]` followed by whitespace-only lines and no `class` took the + // former `(?:\s*\n\s*)*` class-route regex seconds at 16 lines, doubling per line. + it('reads a Route attribute followed by thousands of blank lines in linear time', () => { + const src = ` [".repeat(2000)}`; + const started = performance.now(); + const { nodes } = symfonyResolver.extract!('src/Controller/Slow.php', src); + expect(nodes).toEqual([]); + expect(performance.now() - started).toBeLessThan(1000); + }); +}); + +describe('symfonyResolver.extract — empty paths and compiled route arguments', () => { + it('keeps a method route with an empty path under the class prefix', () => { + const src = ` n.name)).toEqual(['ANY /blog']); + }); + + it('reads HTTP methods from the seventh Route argument, not the schemes', () => { + const src = `add('a', new Route('/a', ['_controller' => 'App\\\\C::a'], [], [], '', [], ['POST'])); + \$routes->add('b', new Route('/b', ['_controller' => 'App\\\\C::b'], [], [], '', ['https'], [])); + } +} +`; + const { nodes } = symfonyResolver.extract!('var/cache/prod/Container_XYZ.php', src); + expect(nodes.filter((n) => n.kind === 'route').map((n) => n.name).sort()).toEqual(['ANY /b', 'POST /a']); + }); +}); diff --git a/codegraph-kernel/src/php.rs b/codegraph-kernel/src/php.rs index fef28c9c5..3feb3a106 100644 --- a/codegraph-kernel/src/php.rs +++ b/codegraph-kernel/src/php.rs @@ -360,7 +360,7 @@ impl<'t> Walker<'t> { id: id_ref, docstring: doc_ref, signature: sig_ref, - decorators: NONE_STR, // php attributes never emit decorates refs + decorators: NONE_STR, // php attributes go out as decorates refs instead type_parameters: NONE_STR, return_type: ret_ref, extra_json: NONE_STR, @@ -694,7 +694,7 @@ impl<'t> Walker<'t> { }; let Some(row) = self.create_node("function", &name, node, extra) else { return }; self.extract_php_type_refs(node, row); - // decorators: none. + self.extract_attributes(node, row); self.stack.push(Scope { row, kind: "function", name }); if let Some(body) = node.child_by_field_name("body") { self.visit_function_body(body); @@ -714,6 +714,7 @@ impl<'t> Walker<'t> { }; let Some(row) = self.create_node("method", &name, node, extra) else { return }; self.extract_php_type_refs(node, row); + self.extract_attributes(node, row); self.stack.push(Scope { row, kind: "method", name }); // Bodiless (interface/abstract) methods still mint nodes, no walk. if let Some(body) = node.child_by_field_name("body") { @@ -732,8 +733,8 @@ impl<'t> Walker<'t> { }; let Some(row) = self.create_node(kind, &name, node, extra) else { return }; self.extract_inheritance(node, row); - // primary-ctor refs: csharp-only (needs a parameter_list child type); - // decorators: none. + // primary-ctor refs: csharp-only (needs a parameter_list child type). + self.extract_attributes(node, row); self.stack.push(Scope { row, kind, name }); let body = node.child_by_field_name("body").unwrap_or(node); for i in 0..body.named_child_count() { @@ -856,6 +857,32 @@ impl<'t> Walker<'t> { } } + /// extractDecoratorsFor's php branch: `#[A, B]` is attribute_list → + /// attribute_group → attribute, each a `decorates` ref named by the last + /// segment of its `name` / `qualified_name` (`ORM\Entity` → `Entity`). + fn extract_attributes(&mut self, decl: Node<'t>, decorated_row: u32) { + for i in 0..decl.named_child_count() { + let Some(list) = decl.named_child(i) else { continue }; + if list.kind() != "attribute_list" { + continue; + } + for j in 0..list.named_child_count() { + let Some(group) = list.named_child(j) else { continue }; + for k in 0..group.named_child_count() { + let Some(attr) = group.named_child(k) else { continue }; + if attr.kind() != "attribute" { + continue; + } + let Some(written) = attr.named_child(0) else { continue }; + let name = self.text(written).rsplit('\\').next().unwrap_or("").trim().to_string(); + if !name.is_empty() { + self.push_ref_at(decorated_row, &name, edge_kind_index("decorates").unwrap(), attr); + } + } + } + } + } + // --- imports ------------------------------------------------------------------- /// pushPhpUseRef (3563): `Foo\Bar\Baz` → an `imports` ref named diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 8421fb46b..8b33972e9 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -110,8 +110,8 @@ table; the resolver half is one `Astro.redirect` reader. ### 2. Server-rendered frameworks — a redirect is a transition, not just a response -**Fourteen frameworks** have route nodes and no navigation: Django, Flask, -FastAPI, Express, NestJS, Laravel, Drupal, Rails, Spring, Play, Gin/chi/gorilla, +**Fifteen frameworks** have route nodes and no navigation: Django, Flask, +FastAPI, Express, NestJS, Laravel, Drupal, Symfony, Rails, Spring, Play, Gin/chi/gorilla, Axum/actix/Rocket, ASP.NET, Vapor. Be precise about what is missing. `redirect_to`, `HttpResponseRedirect`, diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index e41b5f614..00e67c089 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 28; +export const EXTRACTION_VERSION = 29; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index a8a738951..54b07066e 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -7252,6 +7252,16 @@ export class TreeSitterExtractor { consider(child.namedChild(j)); } } + // PHP: attribute_list → attribute_group → attribute (`ORM\Entity` → `Entity`). + if (child?.type === 'attribute_list' && this.language === 'php') { + for (const attr of child.namedChildren.flatMap((g) => g?.namedChildren ?? [])) { + const written = attr?.type === 'attribute' ? attr.namedChild(0) : null; + const name = written && getNodeText(written, this.source).split('\\').pop()?.trim(); + if (!attr || !name) continue; + const { row, column } = attr.startPosition; + this.unresolvedReferences.push({ fromNodeId: decoratedId, referenceName: name, referenceKind: 'decorates', line: row + 1, column }); + } + } } // 2. Decorators that are PRECEDING siblings of the declaration diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index da8f7b0ec..c18d137b5 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -7,6 +7,7 @@ import { FrameworkResolver, ResolutionContext } from '../types'; import type { Language } from '../../types'; import { drupalResolver } from './drupal'; +import { symfonyResolver } from './symfony'; import { laravelResolver } from './laravel'; import { expressResolver } from './express'; import { nestjsResolver } from './nestjs'; @@ -44,6 +45,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ // PHP laravelResolver, drupalResolver, + symfonyResolver, // JavaScript/TypeScript expressResolver, nestjsResolver, @@ -177,6 +179,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void { // Re-export framework resolvers export { drupalResolver } from './drupal'; +export { symfonyResolver } from './symfony'; export { laravelResolver, FACADE_MAPPINGS } from './laravel'; export { expressResolver } from './express'; export { nestjsResolver } from './nestjs'; diff --git a/src/resolution/frameworks/symfony/attribute-routes.ts b/src/resolution/frameworks/symfony/attribute-routes.ts new file mode 100644 index 000000000..dacf8cf6f --- /dev/null +++ b/src/resolution/frameworks/symfony/attribute-routes.ts @@ -0,0 +1,145 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { Node } from '../../../types'; +import { UnresolvedRef } from '../../types'; +import { getParser } from '../../../extraction/grammars'; +import { extractPath, extractMethods, extractName, extractDefaults, extractRequirements, extractHost, extractSchemes } from './attributes'; + +/** + * `#[Route]` attributes read from the PHP AST: every attribute on a method is + * its own route (Symfony allows several per action), and a class-level + * `#[Route]` contributes a prefix only when it carries a path — Shopware's + * `#[Route(defaults: ['_routeScope' => …])]` does not. + */ +export function extractAttributeRoutes( + filePath: string, + content: string, + now: number +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + + // A second parse per PHP file is only worth it when a Route attribute can be there. + if (!content.includes('#[') || !content.includes('Route')) return { nodes, references }; + + const parser = getParser('php'); + if (!parser) return { nodes, references }; + const tree = parser.parse(content); + if (!tree) return { nodes, references }; + + try { + walk(tree.rootNode, (cls) => { + if (cls.type !== 'class_declaration') return; + + let classPrefix = ''; + for (const attr of routeAttributes(cls)) { + const path = extractPath(argsText(attr)); + if (path) { + classPrefix = path; + break; + } + } + + const body = cls.childForFieldName('body'); + for (let i = 0; body && i < body.namedChildCount; i++) { + const method = body.namedChild(i); + if (!method || method.type !== 'method_declaration') continue; + const methodName = method.childForFieldName('name')?.text; + if (!methodName) continue; + + for (const attr of routeAttributes(method)) { + const args = argsText(attr); + const path = extractPath(args); + if (path === null) continue; + + const fullPath = classPrefix + path || '/'; + const routeMethods = extractMethods(args); + const httpMethods = routeMethods.length > 0 ? routeMethods : ['ANY']; + const routeName = extractName(args); + const line = attr.startPosition.row + 1; + + const defaults = extractDefaults(args); + const requirements = extractRequirements(args); + const host = extractHost(args); + const schemes = extractSchemes(args); + let signature: string | undefined; + if (defaults || requirements || host || schemes) { + const meta: Record = {}; + if (defaults) meta.defaults = defaults; + if (requirements) meta.requirements = requirements; + if (host) meta.host = host; + if (schemes) meta.schemes = schemes; + signature = JSON.stringify(meta); + } + + for (const httpMethod of httpMethods) { + const routeNode: Node = { + id: `route:${filePath}:${line}:${httpMethod}:${fullPath}`, + kind: 'route', + name: `${httpMethod} ${fullPath}`, + qualifiedName: routeName ? `${filePath}::${routeName}` : `${filePath}::route:${fullPath}`, + filePath, + startLine: line, + endLine: attr.endPosition.row + 1, + startColumn: attr.startPosition.column, + endColumn: attr.endPosition.column, + language: 'php', + updatedAt: now, + }; + if (signature) routeNode.signature = signature; + nodes.push(routeNode); + + references.push({ + fromNodeId: routeNode.id, + referenceName: methodName, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'php', + }); + } + } + } + }); + } finally { + tree.delete(); + } + + return { nodes, references }; +} + +/** `Route`, `Annotation\Route` and `\Symfony\…\Route` attributes of a declaration. */ +function routeAttributes(decl: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < decl.namedChildCount; i++) { + const list = decl.namedChild(i); + if (!list || list.type !== 'attribute_list') continue; + for (let j = 0; j < list.namedChildCount; j++) { + const group = list.namedChild(j); + for (let k = 0; group && k < group.namedChildCount; k++) { + const attr = group.namedChild(k); + if (!attr || attr.type !== 'attribute') continue; + const name = attr.namedChild(0)?.text ?? ''; + if (name.split('\\').pop() === 'Route') out.push(attr); + } + } + } + return out; +} + +/** + * The attribute's argument list without its parentheses, on one line: the arg + * helpers' regexes do not cross newlines, and a multi-line `defaults: [` is common. + */ +function argsText(attr: SyntaxNode): string { + const args = attr.childForFieldName('parameters') ?? attr.namedChildren.find((c) => c?.type === 'arguments'); + return args ? args.text.replace(/^\(|\)$/g, '').replace(/\s+/g, ' ').trim() : ''; +} + +function walk(node: SyntaxNode, fn: (node: SyntaxNode) => void): void { + fn(node); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) walk(child, fn); + } +} diff --git a/src/resolution/frameworks/symfony/attributes.ts b/src/resolution/frameworks/symfony/attributes.ts new file mode 100644 index 000000000..a8870aba0 --- /dev/null +++ b/src/resolution/frameworks/symfony/attributes.ts @@ -0,0 +1,77 @@ +function normalizeMethod(method: string): string { + const trimmed = method.trim(); + if (!trimmed) return ''; + const dequoted = trimmed.replace(/^['"]|['"]$/g, ''); + if (dequoted.includes('::')) { + const constName = dequoted.split('::').pop()!; + return constName.replace(/^METHOD_/, ''); + } + return dequoted.toUpperCase(); +} + +export function extractMethods(args: string): string[] { + const methodsMatch = args.match(/methods:\s*\[([^\]]*)\]/); + if (methodsMatch) { + return methodsMatch[1]!.split(',').map(normalizeMethod).filter(Boolean); + } + return []; +} + +export function extractName(args: string): string | null { + const nameMatch = args.match(/\bname:\s*['"]([^'"]+)['"]/); + return nameMatch ? nameMatch[1]! : null; +} + +export function extractPath(args: string): string | null { + const named = args.match(/\bpath:\s*['"]([^'"]*)['"]/); + if (named) return named[1]!; + const pos = args.match(/^['"]([^'"]*)['"]/); + return pos ? pos[1]! : null; +} + +/** The `[...]` array a named argument holds, nested brackets balanced. */ +function extractDictPhpStyle(pattern: string, args: string): Record | undefined { + const head = new RegExp(`\\b${pattern}:\\s*\\[`).exec(args); + if (!head) return undefined; + const start = head.index + head[0].length; + let depth = 1; + let end = start; + for (; end < args.length && depth > 0; end++) { + if (args[end] === '[') depth++; + else if (args[end] === ']') depth--; + } + return parsePhpArrayPairs(args.slice(start, end - 1)); +} + +/** `'key' => 'value'` and `'key' => 1` pairs of a PHP array literal. */ +function parsePhpArrayPairs(inner: string): Record | undefined { + const pairs: Record = {}; + const pairRe = /['"](\w[\w-]*)['"]\s*=>\s*(?:['"]([^'"]*)['"]|([\w.-]+))/g; + let m; + while ((m = pairRe.exec(inner)) !== null) { + pairs[m[1]!] = (m[2] ?? m[3]!).trim(); + } + return Object.keys(pairs).length > 0 ? pairs : undefined; +} + +export function extractDefaults(args: string): Record | undefined { + return extractDictPhpStyle('defaults', args); +} + +export function extractRequirements(args: string): Record | undefined { + return extractDictPhpStyle('requirements', args); +} + +export function extractHost(args: string): string | undefined { + const match = args.match(/\bhost:\s*['"]([^'"]+)['"]/); + return match ? match[1]! : undefined; +} + +export function extractSchemes(args: string): string[] | undefined { + const match = args.match(/\bschemes:\s*\[([^\]]*)\]/); + if (match) { + const schemes = match[1]!.split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); + return schemes.length > 0 ? schemes : undefined; + } + return undefined; +} diff --git a/src/resolution/frameworks/symfony/container-routes.ts b/src/resolution/frameworks/symfony/container-routes.ts new file mode 100644 index 000000000..99f14c5cd --- /dev/null +++ b/src/resolution/frameworks/symfony/container-routes.ts @@ -0,0 +1,146 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { Node } from '../../../types'; +import { UnresolvedRef } from '../../types'; +import { getParser } from '../../../extraction/grammars'; + +export function extractContainerRoutes( + filePath: string, + content: string, + now: number +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + + const parser = getParser('php'); + if (!parser) return { nodes, references }; + + const tree = parser.parse(content); + if (!tree) return { nodes, references }; + try { + walkTree(tree.rootNode, (node: SyntaxNode) => { + if (node.type !== 'member_call_expression') return; + + const obj = node.childForFieldName('object'); + if (!obj || obj.type !== 'variable_name' || obj.text !== '$routes') return; + + const method = node.childForFieldName('name'); + if (!method || method.text !== 'add') return; + + const args = node.childForFieldName('arguments'); + if (!args || args.namedChildCount < 2) return; + + const nameArg = args.namedChild(0); + const routeName = getStringValue(nameArg); + if (!routeName) return; + + const routeCreation = args.namedChild(1); + const objCreation = routeCreation?.type === 'argument' ? routeCreation.namedChild(0) : null; + if (!objCreation || objCreation.type !== 'object_creation_expression') return; + + const className = objCreation.namedChild(0); + if (!className || className.text !== 'Route') return; + + const routeArgs = objCreation.namedChild(1); + if (!routeArgs || routeArgs.type !== 'arguments') return; + + const pathArg = routeArgs.namedChild(0); + const path = getStringValue(pathArg); + if (!path) return; + + const defaultsArg = routeArgs.namedChild(1); + const controller = defaultsArg ? getControllerFromArray(defaultsArg) : null; + + // Route($path, $defaults, $requirements, $options, $host, $schemes, $methods, …) + const methodsArg = routeArgs.namedChild(6); + const listed = methodsArg ? getMethodsFromArray(methodsArg) : []; + const methods = listed.length > 0 ? listed : ['ANY']; + + const line = node.startPosition.row + 1; + for (const httpMethod of methods) { + const routeNodeId = `route:${filePath}:${line}:${httpMethod}:${path}`; + const routeNode: Node = { + id: routeNodeId, + kind: 'route', + name: `${httpMethod} ${path}`, + qualifiedName: routeName ? `${filePath}::${routeName}` : `${filePath}::route:${path}`, + filePath, + startLine: line, + endLine: line, + startColumn: 0, + endColumn: node.endIndex - node.startIndex, + language: 'php', + updatedAt: now, + }; + nodes.push(routeNode); + + if (controller) { + references.push({ + fromNodeId: routeNodeId, + referenceName: controller, + referenceKind: 'references', + line, + column: 0, + filePath, + language: 'php', + }); + } + } + }); + } finally { + tree.delete(); + } + + return { nodes, references }; +} + +function walkTree(node: SyntaxNode, fn: (node: SyntaxNode) => void): void { + fn(node); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) walkTree(child, fn); + } +} + +function getStringValue(node: SyntaxNode | null): string | null { + if (!node) return null; + if (node.type === 'argument') return getStringValue(node.namedChild(0)); + if (node.type === 'encapsed_string' || node.type === 'string') { + const text = node.text; + if (text.length >= 2) return text.slice(1, -1); + return null; + } + return null; +} + +function getControllerFromArray(node: SyntaxNode | null): string | null { + if (!node) return null; + const arr = node.type === 'argument' ? node.namedChild(0) : node; + if (!arr || arr.type !== 'array_creation_expression') return null; + for (let i = 0; i < arr.namedChildCount; i++) { + const elem = arr.namedChild(i); + if (!elem || elem.type !== 'array_element_initializer') continue; + if (elem.namedChildCount < 2) continue; + const key = elem.namedChild(0); + const val = elem.namedChild(1); + if (getStringValue(key) === '_controller') return getStringValue(val); + } + return null; +} + +function getMethodsFromArray(node: SyntaxNode | null): string[] { + const methods: string[] = []; + if (!node) return methods; + const arr = node.type === 'argument' ? node.namedChild(0) : node; + if (!arr || arr.type !== 'array_creation_expression') return methods; + for (let i = 0; i < arr.namedChildCount; i++) { + const elem = arr.namedChild(i); + if (!elem || elem.type !== 'array_element_initializer') continue; + const val = elem.namedChildCount > 1 ? elem.namedChild(1) : elem.namedChild(0); + const v = getStringValue(val); + if (v) { + const normalized = v.includes('::') ? v.split('::').pop()!.replace(/^METHOD_/, '') : v.toUpperCase(); + methods.push(normalized); + } + } + return methods; +} diff --git a/src/resolution/frameworks/symfony/controller.ts b/src/resolution/frameworks/symfony/controller.ts new file mode 100644 index 000000000..ff93ce39a --- /dev/null +++ b/src/resolution/frameworks/symfony/controller.ts @@ -0,0 +1,50 @@ +import { Node } from '../../../types'; +import { ResolutionContext } from '../../types'; + +export function lastFqcnSegment(fqcn: string): string { + const clean = fqcn.replace(/^\\+/, '').trim(); + const parts = clean.split('\\'); + return parts[parts.length - 1] ?? clean; +} + +export function parseControllerServiceRef(expr: string): { class: string; method: string } | null { + const clean = expr.trim().replace(/^['"]|['"]$/g, ''); + const match = clean.match(/^([A-Za-z_\\][\w\\]*):{1,2}(\w+)$/); + if (match) { + return { class: match[1]!, method: match[2]! }; + } + // Bare FQCN — single-action controller (__invoke) + if (clean.includes('\\') && /[A-Z]/.test(clean)) { + return { class: clean, method: '__invoke' }; + } + return null; +} + +/** + * The classes a controller reference names, looked up by short name through + * the name index — one query per reference, not a scan of every class. A + * written FQCN names only the class in that namespace: another namespace's + * class of the same short name is not it, so none is better than a wrong one. + */ +export function findControllerClasses(controller: string, context: ResolutionContext): Node[] { + const fqcn = controller.replace(/^\\+/, ''); + const classes = context.getNodesByName(lastFqcnSegment(fqcn)).filter((n) => n.kind === 'class'); + if (!fqcn.includes('\\')) return classes; + return classes.filter((n) => `\\${n.qualifiedName.replace(/::/g, '\\')}`.endsWith(`\\${fqcn}`)); +} + +export function resolveControllerMethod( + controller: string, + method: string, + context: ResolutionContext +): string | null { + for (const cls of findControllerClasses(controller, context)) { + const named = context.getNodesInFileNamed?.(cls.filePath, method) + ?? context.getNodesInFile(cls.filePath).filter((n) => n.name === method); + const found = named.find( + (n) => n.kind === 'method' && n.startLine >= cls.startLine && n.startLine <= cls.endLine + ); + if (found) return found.id; + } + return null; +} diff --git a/src/resolution/frameworks/symfony/di.ts b/src/resolution/frameworks/symfony/di.ts new file mode 100644 index 000000000..cc280fcc9 --- /dev/null +++ b/src/resolution/frameworks/symfony/di.ts @@ -0,0 +1,7 @@ +export function isContainerFilePath(filePath: string, content: string): boolean { + // The kernel's build dir (`var/cache//Container…/`), not any `Cache/` source folder. + const containerPathPattern = /(?:^|[\\/])(?:var[\\/]cache|\.symfony)[\\/].*(?:Container|App_Kernel).*\.php$/; + if (!containerPathPattern.test(filePath)) return false; + if (content.includes('Container') || content.includes('App_Kernel')) return true; + return /class\s+\w+Container\s+extends\s/.test(content.slice(0, 500)); +} diff --git a/src/resolution/frameworks/symfony/index.ts b/src/resolution/frameworks/symfony/index.ts new file mode 100644 index 000000000..69e17f592 --- /dev/null +++ b/src/resolution/frameworks/symfony/index.ts @@ -0,0 +1,122 @@ +import { Node } from '../../../types'; +import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../../types'; +import { findControllerClasses, parseControllerServiceRef, resolveControllerMethod } from './controller'; +import { isContainerFilePath } from './di'; +import { extractYamlRoutes } from './yaml'; +import { extractTwigReferences } from './twig'; +import { extractContainerRoutes } from './container-routes'; +import { extractAttributeRoutes } from './attribute-routes'; + +export const symfonyResolver: FrameworkResolver = { + name: 'symfony', + languages: ['php', 'yaml'], + + detect(context: ResolutionContext): boolean { + const composer = context.readFile('composer.json'); + if (composer) { + try { + const json = JSON.parse(composer) as { + require?: Record; + 'require-dev'?: Record; + }; + const deps = { ...json.require, ...(json['require-dev'] ?? {}) }; + // Shopware 6 is a Symfony application; its plugins require only shopware/core + if (Object.keys(deps).some(k => k === 'symfony/framework-bundle' || k === 'symfony/symfony' || k === 'shopware/core')) { + return true; + } + if (Object.keys(deps).some(k => k.startsWith('symfony/') && !k.startsWith('symfony/polyfill-'))) { + const hasConsole = context.fileExists('bin/console'); + const hasConfig = context.fileExists('config/'); + if (hasConsole && hasConfig) return true; + } + } catch { + } + } + return context.fileExists('bin/console') && context.fileExists('config/'); + }, + + // A route's `App\Controller\X::method` and a service's FQCN name no node + // themselves; everything else resolves by the normal strategies. + claimsReference(name: string): boolean { + return name.includes('\\'); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const parsed = parseControllerServiceRef(ref.referenceName); + if (!parsed) return null; + // A route names its action; a service definition names its class. + const targetNodeId = ref.fromNodeId.startsWith('route:') + ? resolveControllerMethod(parsed.class, parsed.method, context) + : parsed.method === '__invoke' + ? (findControllerClasses(parsed.class, context)[0]?.id ?? null) + : null; + return targetNodeId ? { original: ref, targetNodeId, confidence: 0.9, resolvedBy: 'framework' } : null; + }, + + extract(filePath: string, content: string): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const now = Date.now(); + + // ── PHP ────────────────────────────────────────────────────────────────── + if (filePath.endsWith('.php')) { + const attrRoutes = extractAttributeRoutes(filePath, content, now); + nodes.push(...attrRoutes.nodes); + references.push(...attrRoutes.references); + + // ── Compiled DI container ───────────────────────────────────────── + if (isContainerFilePath(filePath, content)) { + const svcRegex = /protected\s+function\s+get(\w+)Service\s*\(\s*\)\s*:\s*(\\?[\w\\]+)/g; + let svcMatch: RegExpExecArray | null; + while ((svcMatch = svcRegex.exec(content)) !== null) { + const svcName = svcMatch[1]!; + const fqcn = svcMatch[2]!; + const svcLine = content.slice(0, svcMatch.index).split('\n').length; + + nodes.push({ + id: `service:${filePath}:${svcLine}:${svcName}`, + kind: 'variable', + name: svcName, + qualifiedName: fqcn, + filePath, + startLine: svcLine, + endLine: svcLine, + startColumn: 0, + endColumn: svcMatch[0].length, + language: 'php', + updatedAt: now, + }); + + references.push({ + fromNodeId: `service:${filePath}:${svcLine}:${svcName}`, + referenceName: fqcn, + referenceKind: 'references', + line: svcLine, + column: 0, + filePath, + language: 'php', + }); + } + + // Compiled route definitions from container (tree-sitter AST) + const containerRoutes = extractContainerRoutes(filePath, content, now); + nodes.push(...containerRoutes.nodes); + references.push(...containerRoutes.references); + } + + // ── Twig template references ────────────────────────────────────── + const twigResult = extractTwigReferences(content, filePath, now); + nodes.push(...twigResult.nodes); + references.push(...twigResult.references); + } + + // ── YAML ────────────────────────────────────────────────────────────────── + if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { + const yamlResult = extractYamlRoutes(filePath, content, now); + nodes.push(...yamlResult.nodes); + references.push(...yamlResult.references); + } + + return { nodes, references }; + }, +}; diff --git a/src/resolution/frameworks/symfony/twig.ts b/src/resolution/frameworks/symfony/twig.ts new file mode 100644 index 000000000..6ca0bc2ff --- /dev/null +++ b/src/resolution/frameworks/symfony/twig.ts @@ -0,0 +1,23 @@ +import { Node } from '../../../types'; +import { UnresolvedRef } from '../../types'; + +export function extractTwigReferences(content: string, filePath: string, _now: number): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + const twigRegex = /\$this->render(?:View)?\s*\(\s*['"]([^'"]+\.twig)['"]/g; + let match: RegExpExecArray | null; + while ((match = twigRegex.exec(content)) !== null) { + const template = match[1]!; + const line = content.slice(0, match.index).split('\n').length; + references.push({ + fromNodeId: `file:${filePath}`, + referenceName: template, + referenceKind: 'references', + line, + column: match[0].indexOf(template), + filePath, + language: 'php', + }); + } + return { nodes, references }; +} diff --git a/src/resolution/frameworks/symfony/yaml.ts b/src/resolution/frameworks/symfony/yaml.ts new file mode 100644 index 000000000..958c666ea --- /dev/null +++ b/src/resolution/frameworks/symfony/yaml.ts @@ -0,0 +1,133 @@ +import { Node } from '../../../types'; +import { UnresolvedRef } from '../../types'; + +export function extractYamlRoutes( + filePath: string, + content: string, + now: number +): { nodes: Node[]; references: UnresolvedRef[] } { + const nodes: Node[] = []; + const references: UnresolvedRef[] = []; + + // Skip YAML files without route- or service-specific keywords + if (!/^[\t ]*(?:controller|resource|services):/m.test(content)) return { nodes, references }; + + const lines = content.split('\n'); + let currentRoute: { name: string; lineNum: number } | null = null; + let currentPathVal: string | null = null; + let currentController: string | null = null; + let currentMethods: string[] = []; + let currentPrefix: string | null = null; + let routeChildIndent: number | null = null; + let isImport = false; + + const flushRoute = () => { + if (!currentRoute || !currentPathVal || isImport) return; + const effectivePath = currentPrefix ? currentPrefix + currentPathVal : currentPathVal; + const httpMethods = currentMethods.length > 0 ? currentMethods : ['ANY']; + for (const httpMethod of httpMethods) { + nodes.push({ + id: `route:${filePath}:${currentRoute.lineNum}:${httpMethod}:${effectivePath}`, + kind: 'route', + name: `${httpMethod} ${effectivePath}`, + qualifiedName: `${filePath}::${currentRoute.name}`, + filePath, + startLine: currentRoute.lineNum, + endLine: currentRoute.lineNum, + startColumn: 0, + endColumn: 0, + language: 'yaml', + updatedAt: now, + }); + if (currentController) { + references.push({ + fromNodeId: `route:${filePath}:${currentRoute.lineNum}:${httpMethod}:${effectivePath}`, + referenceName: currentController, + referenceKind: 'references', + line: currentRoute.lineNum, + column: 0, + filePath, + language: 'yaml', + }); + } + } + }; + + let inServicesBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const indent = line.length - line.trimStart().length; + const keyValMatch = trimmed.match(/^([\w\-_.\\]+):\s*(.*)/); + if (!keyValMatch) continue; + + const key = keyValMatch[1]!; + const val = keyValMatch[2]!.trim(); + + // Top-level key (column 0) + if (indent === 0) { + flushRoute(); + currentRoute = null; + currentPathVal = null; + currentController = null; + currentMethods = []; + currentPrefix = null; + routeChildIndent = null; + isImport = false; + inServicesBlock = key === 'services'; + + if (!val || val.startsWith('#')) { + currentRoute = { name: key, lineNum: i + 1 }; + } else if (!inServicesBlock && key !== 'services') { + currentRoute = { name: key, lineNum: i + 1 }; + currentPathVal = val.replace(/^['"]|['"]$/g, ''); + } + continue; + } + + // Sub-key of current route — only its direct children; `resource: { path: … }` + // nests a directory path that is not the route's own + if (currentRoute && !inServicesBlock && (routeChildIndent ?? indent) === indent) { + routeChildIndent = indent; + if (key === 'resource') { + isImport = true; + } else if (key === 'path') { + const subVal = val.replace(/^['"]|['"]$/g, '').replace(/\s+#.*$/, ''); + if (subVal) currentPathVal = subVal; + } else if (key === 'controller') { + const subVal = val.replace(/^['"]|['"]$/g, '').replace(/\s+#.*$/, ''); + if (subVal) currentController = subVal; + } else if (key === 'methods') { + const m = val.match(/\[([^\]]*)\]/); + if (m) { + currentMethods = m[1]!.split(',').map(s => s.trim().replace(/['"]/g, '').toUpperCase()).filter(Boolean); + } + } else if (key === 'prefix') { + const subVal = val.replace(/^['"]|['"]$/g, '').replace(/\s+#.*$/, ''); + if (subVal) currentPrefix = subVal; + } + } + + // Service definitions under `services:` + if (inServicesBlock && indent > 0) { + const svcMatch = trimmed.match(/^([\w\\_.]+):\s*/); + if (svcMatch && svcMatch[1]!.includes('\\') && indent >= 2) { + references.push({ + fromNodeId: `file:${filePath}`, + referenceName: svcMatch[1]!, + referenceKind: 'references', + line: i + 1, + column: 0, + filePath, + language: 'yaml', + }); + } + } + } + flushRoute(); + + return { nodes, references }; +}