Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 95 additions & 78 deletions __tests__/frameworks.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2166,110 +2184,109 @@ 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 = `<?php
#[Route(defaults: ['_routeScope' => ['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 = `<?php
#[Entity]
class BlogPost
class AddressController
{
#[Id, Column(type: 'integer'), GeneratedValue]
private int \$id;
#[Route(path: '/account/address/create', name: 'frontend.account.address.create', methods: ['POST'])]
#[Route(path: '/account/address/{addressId}', name: 'frontend.account.address.edit.save', methods: ['POST'])]
public function saveAddress(): Response {}
}
`;
const { nodes } = symfonyResolver.extract!('src/Entity/BlogPost.php', src);
const entity = nodes.find(n => 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 = `<?php
#[ORM\\Entity]
class Category
class CheckoutController
{
#[ORM\\Id, ORM\\Column(type: 'integer'), ORM\\GeneratedValue]
private int \$id;
#[Route(
path: '/checkout/update-phone',
name: 'frontend.checkout.update-phone',
defaults: [
'XmlHttpRequest' => true,
'_loginRequired' => true,
'_template' => 'checkout/phone.html.twig',
],
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']);
expect(JSON.parse(nodes[0]!.signature!).defaults).toEqual({ _template: 'checkout/phone.html.twig' });
});
});

describe('symfonyResolver.extract — Doctrine repositories', () => {
it('extracts repository extending ServiceEntityRepository', () => {
const src = `<?php
class BlogPostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry \$registry)
{
parent::__construct(\$registry, BlogPost::class);
}
}
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!('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 = `<?php
class ExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [KernelEvents::EXCEPTION => '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 = `<?php
#[AsCommand(name: 'app:generate-report')]
class GenerateReportCommand extends Command
{
protected function execute(InputInterface \$input, OutputInterface \$output): int
{
return Command::SUCCESS;
}
}
#[ORM\\Entity(repositoryClass: PostRepository::class)]
class Post {}

class PostRepository extends ServiceEntityRepository {}

class PostSubscriber implements EventSubscriberInterface {}

#[AsCommand(name: 'app:posts')]
class PostCommand extends Command {}
`;
const { nodes } = symfonyResolver.extract!('src/Command/GenerateReportCommand.php', src);
const cmd = nodes.find(n => 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([]);
});
});

Expand Down
52 changes: 27 additions & 25 deletions __tests__/symfony-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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'),
`<?php
namespace App\\Mapping;

#[\\Attribute]
class Entity
{
}
`
);
fs.writeFileSync(
path.join(tmpDir, 'src/Entity/BlogPost.php'),
`<?php
use App\\Mapping\\Entity;

#[Entity]
class BlogPost
{
Expand All @@ -206,13 +220,13 @@ class BlogPost
await cg.indexAll();

const classes = cg.getNodesByKind('class');
const blogPost = classes.find(c => 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();
});
Expand All @@ -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'),
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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');
Expand Down
27 changes: 12 additions & 15 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -2949,8 +2950,8 @@ export class TreeSitterExtractor {
let name = getNodeText(target, this.source);
const lt = name.indexOf('<'); // strip generic args: `@Argument<T>` → `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({
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
Loading