Skip to content

Commit

Permalink
Set up initial Phan plugin
Browse files Browse the repository at this point in the history
Create a Phan plugin for use with MediaWiki extensions or skins that checks
that classes that can have dependencies injected do indeed have those
dependencies injected.

SEL-758
  • Loading branch information
DanielWTQ committed Jul 11, 2024
0 parents commit 5dc4d1a
Show file tree
Hide file tree
Showing 22 changed files with 442 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text eol=lf
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Continuous Integration

on:
push:
branches:
- main
pull_request:

jobs:
style-php:
name: Code Style (PHP)
runs-on: ubuntu-latest
steps:
- uses: wikiteq/php-lint-action@main

test:
name: PHPUnit
runs-on: ubuntu-latest
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, intl
coverage: none
tools: composer

- uses: actions/checkout@v4

- name: Setup Composer
run: composer update
shell: bash

- name: Run PHPUnit
uses: php-actions/phpunit@v4
with:
configuration: phpunit.xml
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
vendor
composer.lock
.phpunit.result.cache
9 changes: 9 additions & 0 deletions .phpcs.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<ruleset>
<rule ref="./vendor/mediawiki/mediawiki-codesniffer/MediaWiki" />
<file>.</file>
<arg name="extensions" value="php"/>
<arg name="encoding" value="UTF-8"/>
<exclude-pattern type="relative">^tests/cases/*</exclude-pattern>
<exclude-pattern type="relative">^tests/stubs/*</exclude-pattern>
</ruleset>
23 changes: 23 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
WikiTeq Proprietary License

Copyright (c) 2024 WikiTeq

All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are not permitted without prior written consent from WikiTeq.

Neither the name of WikiTeq nor the names of its contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.

THIS SOFTWARE IS PROVIDED BY WIKITEQ "AS IS" AND ANY EXPRESS OR IMPLIED
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL WIKITEQ BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
100 changes: 100 additions & 0 deletions MediaWikiServicesCheckPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare( strict_types=1 );

namespace WikiTeq\MediaWikiServicesCheckPlugin;

use ast\Node;
use Phan\AST\UnionTypeVisitor;
use Phan\Language\Type;
use Phan\Language\UnionType;
use Phan\PluginV3;
use Phan\PluginV3\PluginAwarePostAnalysisVisitor;
use Phan\PluginV3\PostAnalyzeNodeCapability;

/**
* Plugin to add our MediaWikiServicesVisitor that gets used for every AST node
*/
class MediaWikiServicesCheckPlugin extends PluginV3 implements
PostAnalyzeNodeCapability
{

public static function getPostAnalyzeNodeVisitorClassName(): string {
return MediaWikiServicesVisitor::class;
}
}

/**
* Visitor that detects MediaWikiServices::getInstance()->getService() and
* similar in places where services can (and should) be injected.
*/
// phpcs:ignore Generic.Files.OneObjectStructurePerFile.MultipleFound
class MediaWikiServicesVisitor extends PluginAwarePostAnalysisVisitor {

public function visitMethodCall( Node $node ): void {
$typeUnion = UnionTypeVisitor::unionTypeFromNode(
$this->code_base,
$this->context,
$node->children['expr']
);

// Use UnionType to simplify comparison with the type of expr
$mwServicesType = UnionType::fromFullyQualifiedPHPDocString(
'\\MediaWiki\\MediaWikiServices'
);
$isMWServices = $mwServicesType->isEqualTo( $typeUnion );
if ( !$isMWServices ) {
return;
}

// Check that we are in a non-static method in a class that should
// support dependency injection
if ( !$this->context->isInClassScope()
|| !$this->context->isInFunctionLikeScope()
) {
return;
}

$funcLike = $this->context->getFunctionLikeInScope( $this->code_base );
if ( $funcLike->isStatic() ) {
return;
}

$method = $node->children['method'];
$methodStart = substr( $method, 0, 3 );
if ( $methodStart !== 'get' && $methodStart !== 'has' ) {
// Something more complicated like peeking, ignore
return;
}

// Map of class to detect extending => placeholder for message
// The *FIRST* matching message is used, to allow for more specific
// messages
$baseClassMap = [
'\\SpecialPage' => 'Special pages',
'\\ApiQueryBase' => 'API query modules',
'\\ApiBase' => 'API modules',
];

$scope = $this->context->getScope();
$currentClass = $scope->getClassFQSEN()->asType();
foreach ( $baseClassMap as $baseClass => $msg ) {
$baseClassType = Type::fromFullyQualifiedString( $baseClass );
if ( !$currentClass->isSubclassOf(
$baseClassType,
$this->code_base
) ) {
continue;
}
$this->emit(
'MediaWikiServicesAccessed',
'%s should have services injected with dependency injection',
[ $msg ]
);
return;
}
}
}

// Plugins return an instance of themselves at the end of their definition files
return new MediaWikiServicesCheckPlugin();
35 changes: 35 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "wikiteq/phan-mediawikiservices-check-plugin",
"description": "Phan plugin for checking uses of MediaWiki's MediaWikiServices class",
"authors": [
{
"name": "Daniel Scherzer",
"email": "[email protected]"
}
],
"require": {
"phan/phan": "5.4.3",
"php": ">=7.4.0"
},
"require-dev": {
"mediawiki/mediawiki-codesniffer": "43.0.0",
"php-parallel-lint/php-console-highlighter": "1.0.0",
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpunit/phpunit": "9.6.16"
},
"scripts": {
"test": [
"composer phpcs",
"phpunit"
],
"phpcs": "phpcs -p -s",
"fix": [
"phpcbf"
]
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}
15 changes: 15 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
>
<coverage/>
<testsuites>
<testsuite name="MediaWikiServicesCheckPlugin tests">
<directory>./tests</directory>
</testsuite>
</testsuites>
</phpunit>
45 changes: 45 additions & 0 deletions tests/PluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace WikiTeq\MediaWikiServicesCheckPlugin\Tests;

use DirectoryIterator;
use PHPUnit\Framework\TestCase;

/**
* @covers \WikiTeq\MediaWikiServicesCheckPlugin\MediaWikiServicesCheckPlugin
*/
class PluginTest extends TestCase {

/** @dataProvider provideTestCases */
public function testScenarios( $testCaseDir, $expectedIssues ) {
$testDirPath = realpath( './tests' );
// $this->assertSame( $testDirPath, '' );
// Go back to the main directory
chdir( __DIR__ . '/../' );
// Build the command to run
$cmd = "php vendor/phan/phan/phan" .
" --allow-polyfill-parser" .
" -d \"$testDirPath\"" .
" -k \"test-config.php\"" .
" -l \"stubs\"" .
" -l \"cases/$testCaseDir\"";
// phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.shell_exec
$phanOutput = shell_exec( $cmd ) ?? '';
// Trim to avoid issues with newlines
$this->assertSame( trim( $expectedIssues ), trim( $phanOutput ) );
}

public function provideTestCases() {
$dirIter = new DirectoryIterator( __DIR__ . '/cases' );
foreach ( $dirIter as $directory ) {
if ( !$directory->isDot() ) {
$folder = $directory->getPathname();
// In `/cases` we have a bunch of sub directories, each with
// PHP files to analyze and then `expected.txt` with the results
$testName = basename( $folder );
$expected = file_get_contents( $folder . '/expected.txt' );
yield $testName => [ $testName, $expected ];
}
}
}
}
13 changes: 13 additions & 0 deletions tests/cases/Api/NormalApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

use MediaWiki\MediaWikiServices;

class NormalApi extends ApiBase {

public function test1( $par ) {
// getService() with arbitrary service name
$service = MediaWikiServices::getInstance()->getService( 'MyService' );
$service->run( $par );
}

}
13 changes: 13 additions & 0 deletions tests/cases/Api/QueryApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

use MediaWiki\MediaWikiServices;

class QueryApi extends ApiQueryBase {

public function test1( $par ) {
// getService() with arbitrary service name
$service = MediaWikiServices::getInstance()->getService( 'MyService' );
$service->run( $par );
}

}
2 changes: 2 additions & 0 deletions tests/cases/Api/expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cases/Api/NormalApi.php:9 MediaWikiServicesAccessed API modules should have services injected with dependency injection
cases/Api/QueryApi.php:9 MediaWikiServicesAccessed API query modules should have services injected with dependency injection
13 changes: 13 additions & 0 deletions tests/cases/SpecialPage/FormSpecial.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

use MediaWiki\MediaWikiServices;

class MyFormSpecial extends FormSpecialPage {

public function test1( $par ) {
// getService() with arbitrary service name
$service = MediaWikiServices::getInstance()->getService( 'MyService' );
$service->run( $par );
}

}
25 changes: 25 additions & 0 deletions tests/cases/SpecialPage/MySpecial.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

use MediaWiki\MediaWikiServices;

class MySpecial extends SpecialPage {

public function test1( $par ) {
// getService() with arbitrary service name
$service = MediaWikiServices::getInstance()->getService( 'MyService' );
$service->run( $par );
}

public function test2( $par ) {
// get() with arbitrary service name
$service = MediaWikiServices::getInstance()->get( 'MyService' );
$service->run( $par );
}

public function test3( $par ) {
// service-specific getter
$service = MediaWikiServices::getInstance()->getUserFactory();
$service->run( $par );
}

}
4 changes: 4 additions & 0 deletions tests/cases/SpecialPage/expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
cases/SpecialPage/FormSpecial.php:9 MediaWikiServicesAccessed Special pages should have services injected with dependency injection
cases/SpecialPage/MySpecial.php:9 MediaWikiServicesAccessed Special pages should have services injected with dependency injection
cases/SpecialPage/MySpecial.php:15 MediaWikiServicesAccessed Special pages should have services injected with dependency injection
cases/SpecialPage/MySpecial.php:21 MediaWikiServicesAccessed Special pages should have services injected with dependency injection
16 changes: 16 additions & 0 deletions tests/cases/ValidUsage/MisleadingName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace FooBar;

use MediaWiki\MediaWikiServices;

// Not the real SpecialPage class since we are in a namespace
// @phan-suppress-next-line PhanUndeclaredExtendedClass
class MySpecial extends SpecialPage {

public function test1() {
$service = MediaWikiServices::getInstance()->getService( 'MyService' );
$service->run( $par );
}

}
17 changes: 17 additions & 0 deletions tests/cases/ValidUsage/MySpecial.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

use MediaWiki\MediaWikiServices;

class MySpecial extends SpecialPage {

public function testPeek() {
return MediaWikiServices::getInstance()->peekService( 'MyService' );
}

public static function testStatic( $par ) {
// Static method should be ignored
$service = MediaWikiServices::getInstance()->getService( 'MyService' );
$service->run( $par );
}

}
Empty file.
Loading

0 comments on commit 5dc4d1a

Please sign in to comment.