Commit 391646f3 authored by Stefan Bürk's avatar Stefan Bürk
Browse files

[TASK] Add PSR-4 namespace integrity check for code files

This patch adds a script to scan and verify namespace of
core class and test files to be PSR-4 compliant. It uses
provided namespace registration in core system extensions
and root composer file for test namespace registrations.

Test fixture test extensions are ignored for now. Check
for these will be enabled in a dedicated patch, after
streamling of fixture test extensions has been done.

Resolves: #97790
Releases: main, 11.5
Change-Id: I36d2946891f2e12dd140b98075a13a65f0b70bb4
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/74930

Tested-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Simon Schaufelberger's avatarSimon Schaufelberger <simonschaufi+typo3@gmail.com>
Tested-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Reviewed-by: Christian Kuhn's avatarChristian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Simon Schaufelberger's avatarSimon Schaufelberger <simonschaufi+typo3@gmail.com>
Reviewed-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
parent 69bf2c66
#!/usr/bin/env php
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\NodeVisitorAbstract;
use PhpParser\ParserFactory;
use Symfony\Component\Finder\Finder;
if (PHP_SAPI !== 'cli') {
die('Script must be called from command line.' . chr(10));
}
require __DIR__ . '/../../vendor/autoload.php';
/**
* Class to scan for invalid namespaces.
*/
class CheckNamespaceIntegrity
{
public function scan(): int
{
$ignoreFiles = [
// ignored, pure fixture file
'typo3/sysext/core/Tests/Unit/Configuration/TypoScript/ConditionMatching/Fixtures/ConditionMatcherUserFuncs.php',
// ignored, pure fixture file
'typo3/sysext/install/Tests/Unit/ExtensionScanner/Php/Matcher/Fixtures/PropertyExistsStaticMatcherFixture.php',
];
$ignoreNamespaceParts = ['Classes'];
$parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
$files = $this->createFinder();
$invalidNamespaces = [];
foreach ($files as $file) {
/** @var $file SplFileInfo */
$fullFilename = $file->getRealPath();
preg_match('/.*typo3\/sysext\/(.*)$/', $fullFilename, $matches);
$relativeFilenameFromRoot = 'typo3/sysext/' . $matches[1];
if (in_array($relativeFilenameFromRoot, $ignoreFiles, true)) {
continue;
}
$parts = explode('/', $matches[1]);
$sysExtName = $parts[0];
unset($parts[0]);
if (in_array($parts[1], $ignoreNamespaceParts, true)) {
unset($parts[1]);
}
$relativeFilenameWithoutSystemExtensionRoot = substr($relativeFilenameFromRoot, (mb_strlen('typo3/sysext/' . $sysExtName . '/')));
$expectedFullQualifiedObjectNamespace = $this->determineExpectedFullQualifiedNamespace($sysExtName, $relativeFilenameWithoutSystemExtensionRoot);
$ast = $parser->parse($file->getContents());
$traverser = new NodeTraverser();
$visitor = new NameResolver();
$traverser->addVisitor($visitor);
$visitor = new NamespaceValidationVisitor();
$traverser->addVisitor($visitor);
$traverser->traverse($ast);
$fileObjectType = $visitor->getType();
$fileObjectFullQualifiedObjectNamespace = $visitor->getFullQualifiedObjectNamespace();
if ($fileObjectType !== ''
&& $expectedFullQualifiedObjectNamespace !== $fileObjectFullQualifiedObjectNamespace
) {
$invalidNamespaces[$sysExtName][] = [
'file' => $relativeFilenameFromRoot,
'shouldBe' => $expectedFullQualifiedObjectNamespace,
'actualIs' => $fileObjectFullQualifiedObjectNamespace,
];
}
}
$output = new \Symfony\Component\Console\Output\ConsoleOutput();
$output->writeln('');
if ($invalidNamespaces !== []) {
$output->writeln(' ❌ Namespace integrity broken.');
$output->writeln('');
$table = new \Symfony\Component\Console\Helper\Table($output);
$table->setHeaders([
'EXT',
'File',
'should be',
'actual is',
]);
foreach ($invalidNamespaces as $extKey => $results) {
foreach ($results as $result) {
$table->addRow([
$extKey,
$result['file'],
$result['shouldBe'] ?: '❌ no proper registered PSR-4 namespace',
$result['actualIs'],
]);
}
}
$table->render();
$output->writeln('');
$output->writeln('');
return 1;
}
$output->writeln(' ✅ Namespace integrity is in good shape.');
$output->writeln('');
return 0;
}
protected function determineExpectedFullQualifiedNamespace(
string $systemExtensionKey,
string $relativeFilename,
): string {
$namespace = '';
if (str_starts_with($relativeFilename, 'Classes/')) {
$namespace = $this->getExtensionClassesNamespace($systemExtensionKey, $relativeFilename);
} elseif (str_starts_with($relativeFilename, 'Tests/')) {
$namespace = $this->getExtensionTestsNamespaces($systemExtensionKey, $relativeFilename);
}
$ignorePartValues= ['Classes', 'Tests'];
if ($namespace !== '') {
$parts = explode('/', $relativeFilename);
if (in_array($parts[0], $ignorePartValues, true)) {
unset($parts[0]);
}
foreach ($parts as $part) {
if (str_ends_with($part, '.php')) {
$namespace .= mb_substr($part, 0, -4);
break;
}
$namespace .= $part . '\\';
}
}
return $namespace;
}
protected function getExtensionClassesNamespace(
string $systemExtensionKey,
string $relativeFilename
): string {
return $this->getPSR4NamespaceFromComposerJson(
$systemExtensionKey,
__DIR__ . '/../../typo3/sysext/' . $systemExtensionKey . '/composer.json',
$relativeFilename
);
}
protected function getExtensionTestsNamespaces(
string $systemExtensionKey,
string $relativeFilename
): string {
return $this->getPSR4NamespaceFromComposerJson(
$systemExtensionKey,
__DIR__ . '/../../composer.json',
$relativeFilename,
true
);
}
protected function getPSR4NamespaceFromComposerJson(
string $systemExtensionKey,
string $fullComposerJsonFilePath,
string $relativeFileName,
bool $autoloadDev=false
): string {
$autoloadKey = 'autoload';
if ($autoloadDev) {
$autoloadKey .= '-dev';
}
if (file_exists($fullComposerJsonFilePath)) {
$composerInfo = \json_decode(
file_get_contents($fullComposerJsonFilePath),
true
);
if (is_array($composerInfo)) {
$autoloadPSR4 = $composerInfo[$autoloadKey]['psr-4'] ?? [];
$pathBasedAutoloadInformation = [];
foreach ($autoloadPSR4 as $namespace => $relativePath) {
$pathBasedAutoloadInformation[trim($relativePath, '/') . '/'] = $namespace;
}
$keys = array_map('mb_strlen', array_keys($pathBasedAutoloadInformation));
array_multisort($keys, SORT_DESC, $pathBasedAutoloadInformation);
foreach ($pathBasedAutoloadInformation as $relativePath => $namespace) {
if ($autoloadDev && str_starts_with('typo3/sysext/' . $systemExtensionKey . '/' . $relativeFileName, $relativePath)) {
return $namespace;
}
if (str_starts_with($relativeFileName, $relativePath)) {
return $namespace;
}
}
}
}
return '';
}
protected function createFinder(): Finder
{
return (new Finder())
->files()
->in(
dirs: [
__DIR__ . '/../../typo3/sysext/*/Classes',
__DIR__ . '/../../typo3/sysext/*/Tests/Unit',
__DIR__ . '/../../typo3/sysext/*/Tests/UnitDeprecated',
__DIR__ . '/../../typo3/sysext/*/Tests/Functional',
__DIR__ . '/../../typo3/sysext/*/Tests/FunctionalDeprecated',
__DIR__ . '/../../typo3/sysext/core/Tests/Acceptance',
]
)
->notPath('typo3/sysext/core/Tests/Acceptance/Support/_generated')
// @todo remove fixture extensions exclude and handle properly after fixture extensions has been streamlined
->notPath([
'Fixtures/Extensions',
'Fixtures/Extension',
'Fixture/Extensions',
'Fixture/Extension',
'Core/Fixtures/test_extension',
])
->name('*.php')
->sortByName();
}
}
/**
* nikic/php-parser node visitor fo find namespace information
*/
class NamespaceValidationVisitor extends NodeVisitorAbstract
{
private string $type = '';
private string $fullQualifiedObjectNamespace = '';
public function enterNode(Node $node)
{
if ($this->type === '') {
if ($node instanceof Node\Stmt\Class_
&& !$node->isAnonymous()
) {
$this->type = 'class';
$this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
}
if ($node instanceof Node\Stmt\Interface_) {
$this->type = 'interface';
$this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
}
if ($node instanceof Node\Stmt\Enum_) {
$this->type = 'enum';
$this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
}
if ($node instanceof Node\Stmt\Trait_) {
$this->type = 'trait';
$this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
}
}
}
public function getType(): string
{
return $this->type;
}
public function getFullQualifiedObjectNamespace(): string
{
return $this->fullQualifiedObjectNamespace;
}
}
// execute scan and return corresponding exit code.
// 0: everything ok
// 1: failed, one or more files has invalid namespace declaration
exit((new CheckNamespaceIntegrity())->scan());
......@@ -143,6 +143,7 @@ Options:
- checkFilePathLength: test core file paths do not exceed maximum length
- checkGitSubmodule: test core git has no sub modules defined
- checkGruntClean: Verify "grunt build" is clean. Warning: Executes git commands! Usually used in CI only.
- checkNamespaceIntegrity: Verify namespace integrity in class and test code files are in good shape.
- checkPermissions: test some core files for correct executable bits
- checkRst: test .rst files for integrity
- checkTestMethodsPrefix: check tests methods do not start with "test"
......@@ -628,6 +629,12 @@ case ${TEST_SUITE} in
SUITE_EXIT_CODE=$?
docker-compose down
;;
checkNamespaceIntegrity)
setUpDockerComposeDotEnv
docker-compose run check_namespace_integrity
SUITE_EXIT_CODE=$?
docker-compose down
;;
checkPermissions)
setUpDockerComposeDotEnv
docker-compose run check_permissions
......
......@@ -50,6 +50,7 @@ integration various:
- Build/Scripts/runTests.sh -s checkBom -p 8.1
- Build/Scripts/runTests.sh -s checkComposer -p 8.1
- Build/Scripts/runTests.sh -s checkTestMethodsPrefix -p 8.1
- Build/Scripts/runTests.sh -s checkNamespaceIntegrity -p 8.1
lint php 8.1:
stage: integrity
......
......@@ -54,6 +54,7 @@ integration various pre-merge:
- Build/Scripts/runTests.sh -s checkBom -p 8.1
- Build/Scripts/runTests.sh -s checkComposer -p 8.1
- Build/Scripts/runTests.sh -s checkTestMethodsPrefix -p 8.1
- Build/Scripts/runTests.sh -s checkNamespaceIntegrity -p 8.1
lint scss ts html pre-merge:
stage: main
......
......@@ -650,6 +650,20 @@ services:
git status | grep -q \"nothing to commit, working tree clean\"
"
check_namespace_integrity:
image: typo3/core-testing-${DOCKER_PHP_IMAGE}:latest
user: "${HOST_UID}"
volumes:
- ${CORE_ROOT}:${CORE_ROOT}
working_dir: ${CORE_ROOT}
command: >
/bin/sh -c "
if [ ${SCRIPT_VERBOSE} -eq 1 ]; then
set -x
fi
php -dxdebug.mode=off Build/Scripts/checkNamespaceIntegrity.php;
"
check_permissions:
image: typo3/core-testing-${DOCKER_PHP_IMAGE}:latest
user: "${HOST_UID}"
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment