Files
codegraph/__tests__/php-this-hierarchy.test.ts
Colby Mchenry dcbbd1687d fix(php): $this / self / static / parent calls stay in the class hierarchy (#2142)
Extraction records `$this->m()` under the bare name, so exact and fuzzy
matching took ANY same-named method: Drupal core's tests' `$this->assertEquals()`
(PHPUnit's) went to a comparator class 8,832 times, `$this->assertSession()`
to WebDriverTestBase 9,556 times, `$this->t()` to `Views::t` 6,811 times;
Laravel's `$this->assertCount()` to FilesystemAdapter 1,722 times.

A call written through `$this->` / `self::` / `static::` (or `parent::`) now
takes only a METHOD of the enclosing class's ancestry — `extends` and trait
`use`, read from source and resolved through the file's `namespace` / `use`
imports (aliases included) to fully qualified classes — or a subclass's
(a base calling what a subclass defines). Past an ancestor outside the
repository (Orchestra's TestCase) a repository trait's method still counts,
an unrelated class's never does. Inside a trait, `$this` is the using class,
so trait bodies are not scoped. Multi-line `use A,\n B;` trait lists parse.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:58:39 +00:00

150 lines
5.1 KiB
TypeScript

/**
* `$this->m()`, `self::m()`, `static::m()` and `parent::m()` name a method of
* the class the call is written in, a class it extends, or a trait any of them
* uses — never another class's method that shares the name.
*
* Drupal core's tests call PHPUnit's `$this->assertEquals()` (a vendor base
* class), which went to the one in-repo `assertEquals`, a comparator's, 8,832
* times; `$this->t()` from StringTranslationTrait went to `Views::t`.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
describe('PHP calls on $this, self and parent', () => {
it('reach the class hierarchy and its traits, and nothing outside it', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-php-this-'));
roots.push(root);
const files: Record<string, string> = {
'core/lib/StringTranslationTrait.php': `<?php
namespace Drupal\\Core\\StringTranslation;
trait StringTranslationTrait {
protected function t($string) { return $string; }
}
`,
'core/lib/Views.php': `<?php
namespace Drupal\\views;
class Views {
public static function t($s) { return $s; }
}
`,
'core/lib/MarkupComparator.php': `<?php
namespace Drupal\\TestTools\\Comparator;
class MarkupInterfaceComparator {
public function assertEquals($expected, $actual) {}
}
`,
'core/lib/FormBase.php': `<?php
namespace Drupal\\Core\\Form;
use Drupal\\Core\\StringTranslation\\StringTranslationTrait;
abstract class FormBase {
use StringTranslationTrait;
public function setUp() {}
protected function helper() {}
}
`,
'core/modules/my/MyForm.php': `<?php
namespace Drupal\\my\\Form;
use Drupal\\Core\\Form\\FormBase;
class MyForm extends FormBase {
public function setUp() {
parent::setUp();
$this->helper();
return $this->t('Hi');
}
}
`,
'core/tests/NodeTest.php': `<?php
namespace Drupal\\Tests\\node;
use PHPUnit\\Framework\\TestCase;
class NodeTest extends TestCase {
public function testTitle() {
$this->assertEquals(1, 1);
}
}
`,
// Laravel's own tests extend Orchestra's TestCase, which isn't in the
// repository but composes the repository's testing traits.
'src/Testing/InteractsWithTime.php': `<?php
namespace Illuminate\\Foundation\\Testing\\Concerns;
trait InteractsWithTime {
public function travelTo($date) {}
}
`,
// Inside a trait, \$this is the class that uses it.
'src/Validation/ValidatesAttributes.php': `<?php
namespace Illuminate\\Validation\\Concerns;
trait ValidatesAttributes {
public function validateSame($attribute) { return $this->getValue($attribute); }
}
`,
'src/Validation/Validator.php': `<?php
namespace Illuminate\\Validation;
use Illuminate\\Validation\\Concerns\\ValidatesAttributes;
class Validator {
use ValidatesAttributes;
public function getValue($attribute) { return null; }
}
`,
// A base class calling what its subclass defines.
'app/Entity.php': `<?php
namespace BookStack\\Entities;
abstract class Entity {
public function parentBook() { return $this->book(); }
}
`,
'app/Page.php': `<?php
namespace BookStack\\Entities;
class Page extends Entity {
public function book() { return null; }
}
`,
'tests/QueueTest.php': `<?php
namespace Illuminate\\Tests\\Integration;
use Orchestra\\Testbench\\TestCase;
class QueueTest extends TestCase {
public function testDelay() {
$this->travelTo('2000-01-02');
}
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
const cg = await CodeGraph.init(root, { index: true });
try {
const callees = (file: string, method: string) => {
const from = cg.getNodesInFile(file).find((n) => n.kind === 'method' && n.name === method)!;
return cg
.getOutgoingEdges(from.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!.qualifiedName)
.sort();
};
expect(callees('core/modules/my/MyForm.php', 'setUp')).toEqual([
'Drupal\\Core\\Form::FormBase::helper',
'Drupal\\Core\\Form::FormBase::setUp',
'Drupal\\Core\\StringTranslation::StringTranslationTrait::t',
]);
// PHPUnit's TestCase is not in the repository, and a comparator's
// `assertEquals` is no method of it.
expect(callees('core/tests/NodeTest.php', 'testTitle')).toEqual([]);
expect(callees('src/Validation/ValidatesAttributes.php', 'validateSame')).toEqual(['Illuminate\\Validation::Validator::getValue']);
expect(callees('app/Entity.php', 'parentBook')).toEqual(['BookStack\\Entities::Page::book']);
// Past an ancestor this cannot see, the repository's traits still count.
expect(callees('tests/QueueTest.php', 'testDelay')).toEqual(['Illuminate\\Foundation\\Testing\\Concerns::InteractsWithTime::travelTo']);
} finally {
cg.close();
}
});
});