Skip to content

Proxies and interceptors #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 8, 2019
Merged
Show file tree
Hide file tree
Changes from 3 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
267 changes: 267 additions & 0 deletions Magento/Sniffs/Classes/ConstructorProxyInterceptorSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
<?php
/**
* Copyright © Magento. All rights reserved.
* See COPYING.txt for license details.
*/

namespace Magento\Sniffs\Classes;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;

/**
* Detects explicit request of proxies and interceptors in constructors
*
* Search is in variable names and namespaces, including indirect namespaces from use statements
*/
class ConstructorProxyInterceptorSniff implements Sniff
{
const CONSTRUCT_METHOD_NAME = '__construct';

/**
* String representation of warning.
*
* @var string
*/
protected $warningMessage = 'Proxies and interceptors MUST never be explicitly requested in constructors.';
Copy link
Contributor

@paliarush paliarush Mar 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to split this sniff into two sniffs to comply with single responsibility principle.

Or make it more generic and extensible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have concerns regarding your comment:

  1. This sniff covers one statement from Technical Guidelines and thats why it sounds logical to me to have this one rule.
  2. Splitting into two sniffs doesn't sound good in performance prospective (make same check twice).
  3. On the other hand, making it more generic sounds good to me. The name can be changed to DiscouragedDependenciesSniff (or something like this) and $warnNames property can be public and will be replaceable via ruleset.xml.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. On the other hand, making it more generic sounds good to me. The name can be changed to DiscouragedDependenciesSniff (or something like this) and $warnNames property can be public and will be replaceable via ruleset.xml.

I do like the sound of the latter - I didn't realise that this could be done. Nice :)


/**
* Warning violation code.
*
* @var string
*/
protected $warningCode = 'ConstructorProxyInterceptor';

/**
* @var array Aliases of proxies or plugins from use statements
*/
private $aliases = [];

/**
* @var array Terms to search for in variables and namespaces
*/
private $warnNames = [
'proxy',
'plugin'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably the task was not clear enough, the expectation was to prevent Interceptor classes being referenced directly. Interceptors are auto-generated classes.
It may make sense to run these tests against Magento codebase and see if Plugin reference detection also makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I'll add interceptors first and then look at plugins as you suggest.

];

/**
* @inheritDoc
*/
public function register()
{
return [
T_USE,
T_FUNCTION
];
}

/**
* @inheritDoc
*/
public function process(File $phpcsFile, $stackPtr)
{
// Match use statements and constructor (latter matches T_FUNCTION to find constructors
$tokens = $phpcsFile->getTokens();
if ($tokens[$stackPtr]['code'] == T_USE) {
$this->processUse($phpcsFile, $stackPtr, $tokens);
} elseif ($tokens[$stackPtr]['code'] == T_FUNCTION) {
$this->processFunction($phpcsFile, $stackPtr, $tokens);
}
}

/**
* Store plugin/proxy class names for use in matching constructor
*
* @param File $phpcsFile
* @param int $stackPtr
* @param array $tokens
*/
private function processUse(
File $phpcsFile,
$stackPtr,
$tokens
) {
// Find end of use statement and position of AS alias if exists
$endPos = $phpcsFile->findNext(T_SEMICOLON, $stackPtr);
$asPos = $phpcsFile->findNext(T_AS, $stackPtr, $endPos);
// Find whether this use statement includes any of the warning words
$includesWarnWord =
$this->includesWarnWordsInSTRINGs(
$phpcsFile,
$stackPtr,
min($asPos, $endPos),
$tokens,
$lastWord
);
if (! $includesWarnWord) {
return;
}
// If there is an alias then store this explicit alias for matching in constructor
if ($asPos) {
$aliasNamePos = $asPos + 2;
$this->aliases[] = strtolower($tokens[$aliasNamePos]['content']);
}
// Always store last word as alias for checking in constructor
$this->aliases[] = $lastWord;
}

/**
* If constructor, check for proxy/plugin names and warn
*
* @param File $phpcsFile
* @param int $stackPtr
* @param array $tokens
*/
private function processFunction(
File $phpcsFile,
$stackPtr,
$tokens
) {
// Find start and end of constructor signature based on brackets
if (! $this->getConstructorPosition($phpcsFile, $stackPtr, $tokens, $openParenth, $closeParenth)) {
return;
}
$positionInConstrSig = $openParenth;
$skipTillAfterVariable = false;
do {
// Find next part of namespace (string) or variable name
$positionInConstrSig = $phpcsFile->findNext(
[T_STRING, T_VARIABLE],
$positionInConstrSig + 1,
$closeParenth
);
$currentTokenIsString = $tokens[$positionInConstrSig]['code'] == T_STRING;
// If we've already found a match, continue till after variable
if ($skipTillAfterVariable) {
if (!$currentTokenIsString) {
$skipTillAfterVariable = false;
}
continue;
}
// If match in namespace or variable then add warning
$name = strtolower($tokens[$positionInConstrSig]['content']);
$namesToWarn = $this->mergedNamesToWarn($currentTokenIsString);
if ($this->containsWord($namesToWarn, $name)) {
$phpcsFile->addWarning($this->warningMessage, $positionInConstrSig, $this->warningCode, [$name]);
if ($currentTokenIsString) {
$skipTillAfterVariable = true;
}
}
} while ($positionInConstrSig !== false && $positionInConstrSig < $closeParenth);
}

/**
* Sets start and end of constructor signature or returns false
*
* @param File $phpcsFile
* @param int $stackPtr
* @param array $tokens
* @param int $openParenth
* @param int $closeParenth
*
* @return bool Whether a constructor
*/
private function getConstructorPosition(
File $phpcsFile,
$stackPtr,
array $tokens,
&$openParenth,
&$closeParenth
) {
$methodNamePos = $phpcsFile->findNext(T_STRING, $stackPtr - 1);
if ($methodNamePos === false) {
return false;
}
// There is a method name
if ($tokens[$methodNamePos]['content'] != self::CONSTRUCT_METHOD_NAME) {
return false;
}

// KNOWN: There is a constructor, so get position

$openParenth = $phpcsFile->findNext(T_OPEN_PARENTHESIS, $methodNamePos);
$closeParenth = $phpcsFile->findNext(T_CLOSE_PARENTHESIS, $openParenth);
if ($openParenth === false || $closeParenth === false) {
return false;
}

return true;
}

/**
* Whether $name is contained in any of $haystacks
*
* @param array $haystacks
* @param string $name
*
* @return bool
*/
private function containsWord($haystacks, $name)
{
$matchWarnWord = false;
foreach ($haystacks as $warn) {
if (strpos($name, $warn) !== false) {
$matchWarnWord = true;
break;
}
}

return $matchWarnWord;
}

/**
* Whether warn words are included in STRING tokens in the given range
*
* Populates $lastWord in set to get usable name from namespace
*
* @param File $phpcsFile
* @param int $startPosition
* @param int $endPosition
* @param array $tokens
* @param string|null $lastWord
*
* @return bool
*/
private function includesWarnWordsInSTRINGs(
File $phpcsFile,
$startPosition,
$endPosition,
$tokens,
&$lastWord
) {
$includesWarnWord = false;
$currentPosition = $startPosition;
do {
$currentPosition = $phpcsFile->findNext(T_STRING, $currentPosition + 1, $endPosition);
if ($currentPosition !== false) {
$word = strtolower($tokens[$currentPosition]['content']);
if ($this->containsWord($this->mergedNamesToWarn(false), $word)) {
$includesWarnWord = true;
}
$lastWord = $word;
}
} while ($currentPosition !== false && $currentPosition < $endPosition);

return $includesWarnWord;
}

/**
* Get array of names that if matched should raise warning.
*
* Includes aliases if required
*
* @param bool $includeAliases
*
* @return array
*/
private function mergedNamesToWarn($includeAliases = false)
{
$namesToWarn = $this->warnNames;
if ($includeAliases) {
$namesToWarn = array_merge($namesToWarn, $this->aliases);
}

return $namesToWarn;
}
}
54 changes: 54 additions & 0 deletions Magento/Tests/Classes/ConstructorProxyInterceptorUnitTest.1.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Vendor\Module\Plugin;

use \Vendor\Module\Plugin\AnyName as PlugAlias;
use \Exception as SafeAlias;

class AnyName {}

/**
* // Rule find: constructor use of plugin class
*/
class Foo
{
public function __construct(
$first,
\Vendor\Module\Plugin\AnyName $anything,
$plugin,
$another = []
)
{

}
public function notAConstruct(
\Vendor\Module\Plugin\AnyName $anything
)
{

}
}

/**
* // Rule find: constructor use of plugin class with alias
*/
class Foo2 {
public function __construct(PlugAlias $anything, $aPlugin) {}
}

/**
* // Rule find: constructor use of plugin class with use statement
*/
class Foo3 {
public function __construct(AnyName $anything) {}
}

/**
* // Rule find: This is fine
*/
class Foo4
{
public function __construct(SafeAlias $other) {

}
}
54 changes: 54 additions & 0 deletions Magento/Tests/Classes/ConstructorProxyInterceptorUnitTest.2.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Vendor\Module\Proxy;

use \Vendor\Module\Proxy\AnyName as ProxAlias;
use \Exception as SafeAlias;

class AnyName {}

/**
* // Rule find: constructor use of proxy class
*/
class Foo
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proxy classes are the one which have \Proxy suffix. Interceptor classes always have \Interceptor suffix. These can be accurately detected and should raise error.

Plugin classes have arbitrary names and thus are difficult to catch. These rules may have accuracy issues and can raise warnings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is based on names of constructor types and variables there will be a false-positive issue. In discussion with @lenaorobei it was felt that keeping these as warnings was of utility despite this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even is it's bad naming it's worth pointing developers that class name with Interceptor and Proxy suffix is not a good idea.

{
public function __construct(
$first,
\Vendor\Module\Proxy\AnyName $anything,
$proxy,
$another = []
)
{

}
public function notAConstruct(
\Vendor\Module\Proxy\AnyName $anything
)
{

}
}

/**
* // Rule find: constructor use of proxy class with alias
*/
class Foo2 {
public function __construct(ProxAlias $anything, $aProxy) {}
}

/**
* // Rule find: constructor use of proxy class with use statement
*/
class Foo3 {
public function __construct(AnyName $anything) {}
}

/**
* // Rule find: This is fine
*/
class Foo4
{
public function __construct(SafeAlias $other) {

}
}
Loading