From e78dfb12b2684e7fb198c947f606b450e4f89f53 Mon Sep 17 00:00:00 2001 From: Denis Kern Date: Thu, 8 Oct 2026 10:55:27 +0700 Subject: [PATCH 1/4] fix(php): an attribute on a class or method links the attribute class The PHP grammar nests `#[A, B]` as attribute_list > attribute_group > attribute; the decorator walk looked for attribute_declaration, which the grammar never produces, so no PHP attribute usage became a decorates edge. Namespaced names (`ORM\Entity`) now resolve by their last segment. --- src/extraction/tree-sitter.ts | 27 +++++----- src/resolution/frameworks/symfony/commands.ts | 27 ---------- src/resolution/frameworks/symfony/doctrine.ts | 52 ------------------- src/resolution/frameworks/symfony/events.ts | 27 ---------- 4 files changed, 12 insertions(+), 121 deletions(-) delete mode 100644 src/resolution/frameworks/symfony/commands.ts delete mode 100644 src/resolution/frameworks/symfony/doctrine.ts delete mode 100644 src/resolution/frameworks/symfony/events.ts diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index fc4160dcee..aecc262387 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2939,7 +2939,8 @@ export class TreeSitterExtractor { child.type === 'navigation_expression' || child.type === 'user_type' || // swift attribute → user_type (`@Argument`) child.type === 'type_identifier' || - child.type === 'name' // php attribute → name + child.type === 'name' || // php attribute → name + child.type === 'qualified_name' // php attribute → `ORM\Entity` ) { target = child; break; @@ -2949,8 +2950,8 @@ export class TreeSitterExtractor { let name = getNodeText(target, this.source); const lt = name.indexOf('<'); // strip generic args: `@Argument` → `Argument` if (lt > 0) name = name.slice(0, lt); - const lastDot = Math.max(name.lastIndexOf('.'), name.lastIndexOf('::')); - if (lastDot >= 0) name = name.slice(lastDot + 1).replace(/^[:.]/, ''); + const lastDot = Math.max(name.lastIndexOf('.'), name.lastIndexOf('::'), name.lastIndexOf('\\')); + if (lastDot >= 0) name = name.slice(lastDot + 1).replace(/^[:.\\]/, ''); name = name.trim(); if (!name) return; this.unresolvedReferences.push({ @@ -2975,10 +2976,13 @@ export class TreeSitterExtractor { for (let j = 0; j < child.namedChildCount; j++) { consider(child.namedChild(j)); } - } else if (child.type === 'attribute_declaration') { - // PHP wraps `attribute` nodes inside `attribute_declaration` containers + } else if (child.type === 'attribute_list') { + // PHP nests `#[A, B]` as attribute_list → attribute_group → attribute for (let j = 0; j < child.namedChildCount; j++) { - consider(child.namedChild(j)); + const group = child.namedChild(j); + for (let k = 0; group && k < group.namedChildCount; k++) { + consider(group.namedChild(k)); + } } } else { consider(child); @@ -3012,17 +3016,10 @@ export class TreeSitterExtractor { for (let j = declIdx - 1; j >= 0; j--) { const sibling = parent.namedChild(j); if (!sibling) continue; - if (sibling.type !== 'decorator' && sibling.type !== 'annotation' && sibling.type !== 'marker_annotation' && sibling.type !== 'attribute' && sibling.type !== 'attribute_declaration') { + if (sibling.type !== 'decorator' && sibling.type !== 'annotation' && sibling.type !== 'marker_annotation' && sibling.type !== 'attribute') { break; // non-decorator separator → stop consuming } - // PHP wraps `attribute` nodes inside `attribute_declaration` - if (sibling.type === 'attribute_declaration') { - for (let k = 0; k < sibling.namedChildCount; k++) { - consider(sibling.namedChild(k)); - } - } else { - consider(sibling); - } + consider(sibling); } } } diff --git a/src/resolution/frameworks/symfony/commands.ts b/src/resolution/frameworks/symfony/commands.ts deleted file mode 100644 index 7dca680414..0000000000 --- a/src/resolution/frameworks/symfony/commands.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Node } from '../../../types'; -import { UnresolvedRef } from '../../types'; - -export function extractConsoleCommands(content: string, filePath: string, now: number): { nodes: Node[]; references: UnresolvedRef[] } { - const nodes: Node[] = []; - const references: UnresolvedRef[] = []; - const cmdPattern = /#\[\s*AsCommand\s*\([^)]*\)\s*\](?:\s*\n\s*)*\b(?:final\s+)?(?:class\s+(\w+)\s+extends\s+(?:Command|AbstractCommand|ContainerAwareCommand))/g; - let match: RegExpExecArray | null; - while ((match = cmdPattern.exec(content)) !== null) { - const className = match[1]!; - const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `console_command:${filePath}:${line}:${className}`, - kind: 'class', - name: className, - qualifiedName: `${filePath}::${className}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: 0, - language: 'php', - updatedAt: now, - }); - } - return { nodes, references }; -} diff --git a/src/resolution/frameworks/symfony/doctrine.ts b/src/resolution/frameworks/symfony/doctrine.ts deleted file mode 100644 index 5771974add..0000000000 --- a/src/resolution/frameworks/symfony/doctrine.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Node } from '../../../types'; -import { UnresolvedRef } from '../../types'; - -export function extractDoctrineEntities(content: string, filePath: string, now: number): { nodes: Node[]; references: UnresolvedRef[] } { - const nodes: Node[] = []; - const references: UnresolvedRef[] = []; - const entityAttr = /#\[\s*(?:ORM\\)?Entity\s*(?:\([^)]*\))?\s*\](?:\s*\n\s*)*\b(?:final\s+)?(?:readonly\s+)?(?:class\s+(\w+))/g; - let match: RegExpExecArray | null; - while ((match = entityAttr.exec(content)) !== null) { - const className = match[1]!; - const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `entity:${filePath}:${line}:${className}`, - kind: 'class', - name: className, - qualifiedName: `${filePath}::${className}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: 0, - language: 'php', - updatedAt: now, - }); - } - return { nodes, references }; -} - -export function extractDoctrineRepositories(content: string, filePath: string, now: number): { nodes: Node[]; references: UnresolvedRef[] } { - const nodes: Node[] = []; - const references: UnresolvedRef[] = []; - const repoPattern = /(?:class\s+(\w+)\s+extends\s+(?:ServiceEntityRepository|EntityRepository))/g; - let match: RegExpExecArray | null; - while ((match = repoPattern.exec(content)) !== null) { - const className = match[1]!; - const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `repository:${filePath}:${line}:${className}`, - kind: 'class', - name: className, - qualifiedName: `${filePath}::${className}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: 0, - language: 'php', - updatedAt: now, - }); - } - return { nodes, references }; -} diff --git a/src/resolution/frameworks/symfony/events.ts b/src/resolution/frameworks/symfony/events.ts deleted file mode 100644 index e79f95b426..0000000000 --- a/src/resolution/frameworks/symfony/events.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Node } from '../../../types'; -import { UnresolvedRef } from '../../types'; - -export function extractEventSubscribers(content: string, filePath: string, now: number): { nodes: Node[]; references: UnresolvedRef[] } { - const nodes: Node[] = []; - const references: UnresolvedRef[] = []; - const subPattern = /(?:class\s+(\w+)\s+implements\s+(?:.*,\s*)?EventSubscriberInterface)/g; - let match: RegExpExecArray | null; - while ((match = subPattern.exec(content)) !== null) { - const className = match[1]!; - const line = content.slice(0, match.index).split('\n').length; - nodes.push({ - id: `event_subscriber:${filePath}:${line}:${className}`, - kind: 'class', - name: className, - qualifiedName: `${filePath}::${className}`, - filePath, - startLine: line, - endLine: line, - startColumn: 0, - endColumn: 0, - language: 'php', - updatedAt: now, - }); - } - return { nodes, references }; -} From 8a5ccc40fd7ca4df4eaaf88227e3e30bb02b1597 Mon Sep 17 00:00:00 2001 From: Denis Kern Date: Thu, 8 Oct 2026 10:55:27 +0700 Subject: [PATCH 2/4] fix(symfony): read #[Route] from the AST and drop duplicate class nodes - Routes come from the attribute nodes of each class and method, so a path-less class-level #[Route] (Shopware's _routeScope) no longer swallows the first method route, stacked routes on one action are all emitted, and commented-out attributes stay out without regex comment stripping. - Entity, repository, subscriber and command extractors emitted a second class node for classes the PHP extractor already indexes, with no edges; removed until they carry edges. - YAML: only direct children of a route key count, and a resource import is not a route (`resource: { path: ../src/Controller/ }` became a bogus route). - detect(): Shopware plugins (shopware/core, no bin/console) are Symfony apps; symfony/polyfill-* is now excluded by prefix, not a literal '*'. --- __tests__/frameworks.test.ts | 171 ++++++++++-------- __tests__/symfony-integration.test.ts | 52 +++--- .../frameworks/symfony/attribute-routes.ts | 135 ++++++++++++++ src/resolution/frameworks/symfony/index.ts | 103 +---------- src/resolution/frameworks/symfony/yaml.ts | 16 +- 5 files changed, 274 insertions(+), 203 deletions(-) create mode 100644 src/resolution/frameworks/symfony/attribute-routes.ts diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index d45bc09488..538fb0a7b4 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types'; import type { Node } from '../src/types'; +// The Symfony #[Route] extractor reads the PHP AST +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['php']); +}); + describe('FrameworkResolver.extract interface', () => { it('extract() returns { nodes, references }', () => { const resolver: FrameworkResolver = { @@ -1737,6 +1744,17 @@ describe('symfonyResolver.detect', () => { 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, @@ -2166,110 +2184,107 @@ admin_dashboard: }); }); -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'] +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 {} - Acme\\BlogBundle\\Repository\\PostRepository: - arguments: - - '@doctrine' + #[Route(path: '/account/register', name: 'frontend.account.register.page', methods: ['GET'])] + public function accountRegisterPage(): Response {} +} `; - 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'); + 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']); }); -}); -describe('symfonyResolver.extract — Doctrine entities', () => { - it('extracts entity from #[Entity] attribute', () => { + it('emits every stacked #[Route] of one action', () => { const src = ` n.id?.startsWith('entity:')); - expect(entity).toBeDefined(); - expect(entity!.kind).toBe('class'); - expect(entity!.name).toBe('BlogPost'); + const { nodes, references } = symfonyResolver.extract!('src/Controller/AddressController.php', src); + expect(nodes.map(n => n.name)).toEqual(['POST /account/address/create', 'POST /account/address/{addressId}']); + expect(references.every(r => r.referenceName === 'saveAddress')).toBe(true); }); - it('extracts entity from #[ORM\\\\Entity] attribute', () => { + it('reads a multi-line #[Route] with nested defaults', () => { const src = ` true, + '_loginRequired' => true, + ], + methods: ['POST'] + )] + public function updatePhone(): Response {} } `; - const { nodes } = symfonyResolver.extract!('src/Entity/Category.php', src); - const entity = nodes.find(n => n.id?.startsWith('entity:')); - expect(entity).toBeDefined(); - expect(entity!.name).toBe('Category'); + const { nodes } = symfonyResolver.extract!('src/Controller/CheckoutController.php', src); + expect(nodes.map(n => n.name)).toEqual(['POST /checkout/update-phone']); }); -}); -describe('symfonyResolver.extract — Doctrine repositories', () => { - it('extracts repository extending ServiceEntityRepository', () => { - const src = ` { + const src = `controllers: + resource: + path: ../src/Controller/ + namespace: App\\Controller + type: attribute + +app_index: + path: / + controller: App\\Controller\\DefaultController::index `; - const { nodes } = symfonyResolver.extract!('src/Repository/BlogPostRepository.php', src); - const repo = nodes.find(n => n.id?.startsWith('repository:')); - expect(repo).toBeDefined(); - expect(repo!.name).toBe('BlogPostRepository'); + const { nodes } = symfonyResolver.extract!('config/routes.yaml', src); + expect(nodes.map(n => n.name)).toEqual(['ANY /']); }); }); -describe('symfonyResolver.extract — event subscribers', () => { - it('extracts class implementing EventSubscriberInterface', () => { - const src = ` 'onKernelException']; - } +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'] - public function onKernelException(ExceptionEvent \$event): void {} -} + Acme\\BlogBundle\\Repository\\PostRepository: + arguments: + - '@doctrine' `; - const { nodes } = symfonyResolver.extract!('src/EventSubscriber/ExceptionSubscriber.php', src); - const sub = nodes.find(n => n.id?.startsWith('event_subscriber:')); - expect(sub).toBeDefined(); - expect(sub!.name).toBe('ExceptionSubscriber'); + 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 — console commands', () => { - it('extracts command with #[AsCommand] attribute', () => { +describe('symfonyResolver.extract — no duplicate class nodes', () => { + it('leaves entity, repository, subscriber and command classes to the PHP extractor', () => { const src = ` n.id?.startsWith('console_command:')); - expect(cmd).toBeDefined(); - expect(cmd!.name).toBe('GenerateReportCommand'); + const { nodes } = symfonyResolver.extract!('src/Post.php', src); + expect(nodes.filter(n => n.kind === 'class')).toEqual([]); }); }); diff --git a/__tests__/symfony-integration.test.ts b/__tests__/symfony-integration.test.ts index dbedd58a1b..cd65c09063 100644 --- a/__tests__/symfony-integration.test.ts +++ b/__tests__/symfony-integration.test.ts @@ -177,7 +177,7 @@ describe('Symfony end-to-end — Doctrine entity detection', () => { tmpDir = undefined; }); - it('detects Doctrine entities via #[Entity] attribute', async () => { + 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'), @@ -188,9 +188,23 @@ describe('Symfony end-to-end — Doctrine entity detection', () => { fs.mkdirSync(path.join(tmpDir, 'config')); fs.writeFileSync(path.join(tmpDir, 'config/packages'), ''); fs.mkdirSync(path.join(tmpDir, 'src/Entity'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'src/Mapping'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'src/Mapping/Entity.php'), + ` c.name === 'BlogPost'); - expect(blogPost).toBeDefined(); + const blogPosts = classes.filter(c => c.name === 'BlogPost'); + expect(blogPosts).toHaveLength(1); - // The entity should also be findable by entity: prefix id - const entityNodes = classes.filter(c => c.id?.startsWith('entity:')); - expect(entityNodes.length).toBeGreaterThanOrEqual(1); - expect(entityNodes.some(e => e.name === 'BlogPost')).toBe(true); + 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(); }); @@ -225,7 +239,7 @@ describe('Symfony end-to-end — console command detection', () => { tmpDir = undefined; }); - it('detects commands via #[AsCommand] attribute', async () => { + it('indexes an #[AsCommand] class once', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-symfony-cmd-')); fs.writeFileSync( path.join(tmpDir, 'composer.json'), @@ -253,14 +267,8 @@ class GenerateReportCommand extends Command const cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); - const classes = cg.getNodesByKind('class'); - const cmd = classes.find(c => c.name === 'GenerateReportCommand'); - expect(cmd).toBeDefined(); - // The Symfony resolver emits a console_command: prefixed node AND the - // tree-sitter parser emits a separate class: prefixed node. Both exist. - const cmdResolverNodes = classes.filter(c => c.id?.startsWith('console_command:')); - expect(cmdResolverNodes.length).toBeGreaterThanOrEqual(1); - expect(cmdResolverNodes.some(n => n.name === 'GenerateReportCommand')).toBe(true); + const commands = cg.getNodesByKind('class').filter(c => c.name === 'GenerateReportCommand'); + expect(commands).toHaveLength(1); cg.close(); }); @@ -403,15 +411,9 @@ class App_KernelContainer extends Container const attrRoute = routes.find(r => r.name === 'ANY /'); expect(attrRoute).toBeDefined(); - // Console command detection - const commands = cg.getNodesByKind('class').filter(c => c.id?.startsWith('console_command:')); - expect(commands.length).toBeGreaterThanOrEqual(1); - expect(commands.some(c => c.name === 'TestCommand')).toBe(true); - - // Entity detection - const entities = cg.getNodesByKind('class').filter(c => c.id?.startsWith('entity:')); - expect(entities.length).toBeGreaterThanOrEqual(1); - expect(entities.some(e => e.name === 'Product')).toBe(true); + // 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'); diff --git a/src/resolution/frameworks/symfony/attribute-routes.ts b/src/resolution/frameworks/symfony/attribute-routes.ts new file mode 100644 index 0000000000..59c2a6fb5b --- /dev/null +++ b/src/resolution/frameworks/symfony/attribute-routes.ts @@ -0,0 +1,135 @@ +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[] = []; + + const parser = getParser('php'); + if (!parser) return { nodes, references }; + const tree = parser.parse(content); + if (!tree) return { nodes, references }; + + 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) 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', + }); + } + } + } + }); + + 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, as the arg helpers expect. */ +function argsText(attr: SyntaxNode): string { + const args = attr.childForFieldName('parameters') ?? attr.namedChildren.find((c) => c?.type === 'arguments'); + return args ? args.text.replace(/^\(|\)$/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/index.ts b/src/resolution/frameworks/symfony/index.ts index 637cec7bf0..5ecc716825 100644 --- a/src/resolution/frameworks/symfony/index.ts +++ b/src/resolution/frameworks/symfony/index.ts @@ -1,15 +1,11 @@ import { Node } from '../../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../../types'; -import { stripCommentsForRegex } from '../../strip-comments'; -import { extractPath, extractMethods, extractName, extractDefaults, extractRequirements, extractHost, extractSchemes } from './attributes'; import { parseControllerServiceRef, resolveControllerMethod } from './controller'; import { isContainerFilePath } from './di'; import { extractYamlRoutes } from './yaml'; -import { extractDoctrineEntities, extractDoctrineRepositories } from './doctrine'; -import { extractEventSubscribers } from './events'; -import { extractConsoleCommands } from './commands'; import { extractTwigReferences } from './twig'; import { extractContainerRoutes } from './container-routes'; +import { extractAttributeRoutes } from './attribute-routes'; export const symfonyResolver: FrameworkResolver = { name: 'symfony', @@ -24,10 +20,11 @@ export const symfonyResolver: FrameworkResolver = { 'require-dev'?: Record; }; const deps = { ...json.require, ...(json['require-dev'] ?? {}) }; - if (Object.keys(deps).some(k => k === 'symfony/framework-bundle' || k === 'symfony/symfony')) { + // 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 !== 'symfony/polyfill-*')) { + 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; @@ -65,76 +62,9 @@ export const symfonyResolver: FrameworkResolver = { // ── PHP ────────────────────────────────────────────────────────────────── if (filePath.endsWith('.php')) { - const safe = stripCommentsForRegex(content, 'php'); - - // Class-level #[Route] prefix - let classPrefix = ''; - const classAttrMatch = safe.match( - /#\[\s*Route\s*\(([^)]*)\)\s*\](?:\s*\n\s*)*\b(?:final\s+)?(?:readonly\s+)?class\b/ - ); - if (classAttrMatch) { - const path = extractPath(classAttrMatch[1]!); - if (path) classPrefix = path; - } - - // Method-level #[Route] — `[^)]*` stops at first `)`, can't bleed into next attribute - const methodRouteRegex = - /#\[\s*Route\s*\(([^)]*)\)\s*\](?:\s*\n\s*(?:#\[(?!\s*Route\s*\()[\s\S]*?\]\s*\n\s*)*)?(?:public|private|protected)\s+function\s+(\w+)\s*\(/g; - let match: RegExpExecArray | null; - while ((match = methodRouteRegex.exec(safe)) !== null) { - const args = match[1]!.trim(); - const methodName = match[2]!; - const line = safe.slice(0, match.index).split('\n').length; - const path = extractPath(args); - const routeMethods = extractMethods(args); - const routeName = extractName(args); - - if (!path) continue; - - const fullPath = classPrefix ? classPrefix + path : path; - const httpMethods = routeMethods.length > 0 ? routeMethods : ['ANY']; - - 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: line, - startColumn: 0, - endColumn: match[0].length, - language: 'php', - updatedAt: now, - }; - - const defaults = extractDefaults(args); - const requirements = extractRequirements(args); - const host = extractHost(args); - const schemes = extractSchemes(args); - 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; - routeNode.signature = JSON.stringify(meta); - } - - nodes.push(routeNode); - - references.push({ - fromNodeId: routeNode.id, - referenceName: methodName, - referenceKind: 'references', - line, - column: 0, - filePath, - language: 'php', - }); - } - } + const attrRoutes = extractAttributeRoutes(filePath, content, now); + nodes.push(...attrRoutes.nodes); + references.push(...attrRoutes.references); // ── Compiled DI container ───────────────────────────────────────── if (isContainerFilePath(filePath, content)) { @@ -176,25 +106,6 @@ export const symfonyResolver: FrameworkResolver = { references.push(...containerRoutes.references); } - // ── Doctrine entities & repositories ────────────────────────────── - const entityResult = extractDoctrineEntities(content, filePath, now); - nodes.push(...entityResult.nodes); - references.push(...entityResult.references); - - const repoResult = extractDoctrineRepositories(content, filePath, now); - nodes.push(...repoResult.nodes); - references.push(...repoResult.references); - - // ── Event subscribers ───────────────────────────────────────────── - const eventResult = extractEventSubscribers(content, filePath, now); - nodes.push(...eventResult.nodes); - references.push(...eventResult.references); - - // ── Console commands ────────────────────────────────────────────── - const cmdResult = extractConsoleCommands(content, filePath, now); - nodes.push(...cmdResult.nodes); - references.push(...cmdResult.references); - // ── Twig template references ────────────────────────────────────── const twigResult = extractTwigReferences(content, filePath, now); nodes.push(...twigResult.nodes); diff --git a/src/resolution/frameworks/symfony/yaml.ts b/src/resolution/frameworks/symfony/yaml.ts index 2e90b190c8..958c666eae 100644 --- a/src/resolution/frameworks/symfony/yaml.ts +++ b/src/resolution/frameworks/symfony/yaml.ts @@ -18,9 +18,11 @@ export function extractYamlRoutes( 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) return; + if (!currentRoute || !currentPathVal || isImport) return; const effectivePath = currentPrefix ? currentPrefix + currentPathVal : currentPathVal; const httpMethods = currentMethods.length > 0 ? currentMethods : ['ANY']; for (const httpMethod of httpMethods) { @@ -73,6 +75,8 @@ export function extractYamlRoutes( currentController = null; currentMethods = []; currentPrefix = null; + routeChildIndent = null; + isImport = false; inServicesBlock = key === 'services'; if (!val || val.startsWith('#')) { @@ -84,9 +88,13 @@ export function extractYamlRoutes( continue; } - // Sub-key of current route - if (currentRoute && !inServicesBlock) { - if (key === 'path') { + // 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') { From 60fb3905641762ffa1f17f949365ed2086182234 Mon Sep 17 00:00:00 2001 From: Denis Kern Date: Thu, 8 Oct 2026 10:57:12 +0700 Subject: [PATCH 3/4] perf(symfony): skip the attribute-route parse when a file names no Route --- src/resolution/frameworks/symfony/attribute-routes.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/resolution/frameworks/symfony/attribute-routes.ts b/src/resolution/frameworks/symfony/attribute-routes.ts index 59c2a6fb5b..829bd6634c 100644 --- a/src/resolution/frameworks/symfony/attribute-routes.ts +++ b/src/resolution/frameworks/symfony/attribute-routes.ts @@ -18,6 +18,9 @@ export function extractAttributeRoutes( 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('Route')) return { nodes, references }; + const parser = getParser('php'); if (!parser) return { nodes, references }; const tree = parser.parse(content); From d330bc05192674e54418542f61868caaa3d455c3 Mon Sep 17 00:00:00 2001 From: Denis Kern Date: Thu, 8 Oct 2026 11:01:56 +0700 Subject: [PATCH 4/4] fix(symfony): keep defaults and requirements of a multi-line #[Route] --- __tests__/frameworks.test.ts | 2 ++ src/resolution/frameworks/symfony/attribute-routes.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 538fb0a7b4..b886804be9 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -2227,6 +2227,7 @@ class CheckoutController defaults: [ 'XmlHttpRequest' => true, '_loginRequired' => true, + '_template' => 'checkout/phone.html.twig', ], methods: ['POST'] )] @@ -2235,6 +2236,7 @@ class CheckoutController `; 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({ _template: 'checkout/phone.html.twig' }); }); it('does not read the directory of a nested resource import as a route', () => { diff --git a/src/resolution/frameworks/symfony/attribute-routes.ts b/src/resolution/frameworks/symfony/attribute-routes.ts index 829bd6634c..50cce263cb 100644 --- a/src/resolution/frameworks/symfony/attribute-routes.ts +++ b/src/resolution/frameworks/symfony/attribute-routes.ts @@ -123,10 +123,13 @@ function routeAttributes(decl: SyntaxNode): SyntaxNode[] { return out; } -/** The attribute's argument list without its parentheses, as the arg helpers expect. */ +/** + * 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, '').trim() : ''; + return args ? args.text.replace(/^\(|\)$/g, '').replace(/\s+/g, ' ').trim() : ''; } function walk(node: SyntaxNode, fn: (node: SyntaxNode) => void): void {