From 46a8e8c76f9ad21e9f76a7412c5789608aa0489c Mon Sep 17 00:00:00 2001
From: Anja Leichsenring <aleichsenring@ab-softlab.de>
Date: Fri, 22 Mar 2024 10:02:03 +0100
Subject: [PATCH] [TASK] Provide common base for PHP-based integrity checker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

With one traverser class and several checkers all necessary
checks can be executed within one CI job, which saves time and
resources.

By feeding all checkers with the same set of files, we can be safe not
to lose any files that should be checked.

Resolves: #103465
Releases: main
Change-Id: I4a2f54c6fac37177c8387a999f5e60102951eba5
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/83568
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Andreas Kienast <a.fernandez@scripting-base.de>
Reviewed-by: Andreas Kienast <a.fernandez@scripting-base.de>
---
 Build/Scripts/checkNamespaceIntegrity.php     | 287 ------------------
 Build/Scripts/phpIntegrityChecker.php         | 217 +++++++++++++
 .../AbstractPhpIntegrityChecker.php           |  91 ++++++
 .../AnnotationChecker.php}                    | 115 +++----
 .../phpIntegrityChecks/NamespaceChecker.php   | 255 ++++++++++++++++
 .../TestClassFinalChecker.php                 |  72 +++++
 .../TestMethodPrefixChecker.php               |  73 +++++
 Build/Scripts/runTests.sh                     |  21 +-
 Build/Scripts/testClassFinalChecker.php       |  88 ------
 Build/Scripts/testMethodPrefixChecker.php     |  92 ------
 Build/gitlab-ci/nightly/integrity.yml         |   7 +-
 Build/gitlab-ci/pre-merge/integrity.yml       |   7 +-
 composer.json                                 |   1 +
 13 files changed, 759 insertions(+), 567 deletions(-)
 delete mode 100755 Build/Scripts/checkNamespaceIntegrity.php
 create mode 100755 Build/Scripts/phpIntegrityChecker.php
 create mode 100644 Build/Scripts/phpIntegrityChecks/AbstractPhpIntegrityChecker.php
 rename Build/Scripts/{annotationChecker.php => phpIntegrityChecks/AnnotationChecker.php} (66%)
 mode change 100755 => 100644
 create mode 100644 Build/Scripts/phpIntegrityChecks/NamespaceChecker.php
 create mode 100644 Build/Scripts/phpIntegrityChecks/TestClassFinalChecker.php
 create mode 100644 Build/Scripts/phpIntegrityChecks/TestMethodPrefixChecker.php
 delete mode 100644 Build/Scripts/testClassFinalChecker.php
 delete mode 100644 Build/Scripts/testMethodPrefixChecker.php

diff --git a/Build/Scripts/checkNamespaceIntegrity.php b/Build/Scripts/checkNamespaceIntegrity.php
deleted file mode 100755
index a38086cc09d9..000000000000
--- a/Build/Scripts/checkNamespaceIntegrity.php
+++ /dev/null
@@ -1,287 +0,0 @@
-#!/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 PhpParser\PhpVersion;
-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())->createForVersion(PhpVersion::fromComponents(8, 2));
-        $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/')) {
-            // for test fixture extensions, the relativeFileName will be shortened by the sysext file path,
-            // therefor the variable gets passed as reference here
-            $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)) {
-                        $relativePath = mb_substr($relativePath, mb_strlen('typo3/sysext/' . $systemExtensionKey . '/'));
-                        if (str_starts_with($relativeFileName, $relativePath)) {
-                            $relativeFileName = mb_substr($relativeFileName, mb_strlen($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(patterns: [
-                'typo3/sysext/core/Tests/Acceptance/Support/_generated',
-                // exclude some files not providing classes, so no namespace information is available
-                'typo3/sysext/*/Configuration',
-            ])
-            ->notName('ext_emconf.php')
-            // this test extension tests missing autoload infos, so of course it will break the integrity check
-            ->exclude('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());
diff --git a/Build/Scripts/phpIntegrityChecker.php b/Build/Scripts/phpIntegrityChecker.php
new file mode 100755
index 000000000000..c438bec3ef63
--- /dev/null
+++ b/Build/Scripts/phpIntegrityChecker.php
@@ -0,0 +1,217 @@
+#!/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 Composer\Console\Application;
+use Composer\Console\Input\InputOption;
+use PhpParser\Error;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\NameResolver;
+use PhpParser\Parser;
+use PhpParser\ParserFactory;
+use PhpParser\PhpVersion;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Finder\Finder;
+use TYPO3\CMS\PhpIntegrityChecks\AbstractPhpIntegrityChecker;
+use TYPO3\CMS\PhpIntegrityChecks\AnnotationChecker;
+use TYPO3\CMS\PhpIntegrityChecks\NamespaceChecker;
+use TYPO3\CMS\PhpIntegrityChecks\TestClassFinalChecker;
+use TYPO3\CMS\PhpIntegrityChecks\TestMethodPrefixChecker;
+
+require_once __DIR__ . '/../../vendor/autoload.php';
+
+final class PhpIntegrityChecker extends Command
+{
+    /**
+     * @var class-string[]
+     */
+    private array $registeredVisitors = [
+        AnnotationChecker::class,
+        NamespaceChecker::class,
+        TestMethodPrefixChecker::class,
+        TestClassFinalChecker::class,
+    ];
+
+    /**
+     * @var string[]
+     */
+    private array $finderFindIn = [
+        __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',
+    ];
+
+    /**
+     * @var string[]
+     */
+    private array $finderNotPath = [
+        'typo3/sysext/core/Tests/Acceptance/Support/_generated',
+        // exclude some files not providing classes
+        'typo3/sysext/*/Configuration',
+    ];
+
+    /**
+     * @var string[]
+     */
+    private array $finderNotName = [
+        'ext_emconf.php',
+    ];
+
+    /**
+     * @var array<class-string, AbstractPhpIntegrityChecker>
+     */
+    private array $visitors = [];
+
+    /**
+     * @var array<class-string, array<string, string[]>>
+     */
+    private array $issues = [];
+
+    protected function configure(): void
+    {
+        $this->addOption('php', 'p', InputOption::VALUE_OPTIONAL, 'the php version to use, like 8.2 or 7.4', '8.2');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $this->initVisitors();
+        $io = new SymfonyStyle($input, $output);
+        $parser = $this->getParser($input->getOption('php'));
+
+        foreach ($this->createFinder() as $file) {
+            $this->processFile($parser, $file);
+        }
+
+        return $this->displayIssues($io);
+    }
+
+    /**
+     * Display issues, grouped by visitor and return command exit code.
+     */
+    private function displayIssues(SymfonyStyle $io): int
+    {
+        $exitCode = Command::SUCCESS;
+        foreach ($this->issues as $visitorClassName => $issueCollection) {
+            if ($issueCollection !== []) {
+                $exitCode = Command::FAILURE;
+            }
+            // PHP Syntax parsing errors are not a visitor, and thus need adjusted handling here.
+            if ($visitorClassName === 'parsing') {
+                $io->title('Parsing errors');
+                $io->error(' Following files were not checked:');
+                foreach ($issueCollection as $file => $issue) {
+                    $io->writeln('  > ' . $file . ': ' . $issue);
+                }
+                continue;
+            }
+            $this->visitors[$visitorClassName]->outputResult($io, $issueCollection);
+            $io->newLine();
+        }
+
+        return $exitCode;
+    }
+
+    /**
+     * Process code integrity checks for specified file.
+     */
+    private function processFile(Parser $parser, SplFileInfo $file): void
+    {
+        try {
+            $ast = $parser->parse($file->getContents());
+        } catch (Error $error) {
+            $this->issues['parsing'][$file->getRealPath()] = 'Parse error: ' . $error->getMessage();
+            return;
+        }
+
+        /** @var array<class-string, AbstractPhpIntegrityChecker> $usedVisitors */
+        $usedVisitors = [];
+        $this->createTraverser($file, $usedVisitors)->traverse($ast);
+        $this->finishUsedVisitorsForFile($file, $usedVisitors);
+    }
+
+    /**
+     * @param SplFileInfo $file
+     * @param array<class-string, AbstractPhpIntegrityChecker> &$usedVisitors
+     * @return NodeTraverser
+     */
+    private function createTraverser(SplFileInfo $file, array &$usedVisitors): NodeTraverser
+    {
+        $nameResolver = new NameResolver();
+        $traverser = new NodeTraverser();
+        $traverser->addVisitor($nameResolver);
+        foreach ($this->visitors as $visitorClassName => $visitor) {
+            if (!$visitor->canHandle($file)) {
+                continue;
+            }
+            $usedVisitors[$visitorClassName] = $visitor;
+            $visitor->startProcessing($file);
+            $traverser->addVisitor($visitor);
+        }
+        return $traverser;
+    }
+
+    /**
+     * @param array<class-string, AbstractPhpIntegrityChecker> $usedVisitors
+     */
+    private function finishUsedVisitorsForFile(SplFileInfo $file, array $usedVisitors): void
+    {
+        foreach ($usedVisitors as $visitorClassName => $visitor) {
+            $visitor->finishProcessing();
+            $messages = $visitor->getMessages();
+            if ($messages !== []) {
+                $this->issues[$visitorClassName] = $messages;
+            }
+        }
+    }
+
+    private function createFinder(): Finder
+    {
+        return (new Finder())
+            ->files()
+            ->in($this->finderFindIn)
+            ->notPath($this->finderNotPath)
+            ->notName($this->finderNotName)
+            ->name('*.php')
+            ->sortByName();
+    }
+
+    private function initVisitors(): void
+    {
+        foreach ($this->registeredVisitors as $registeredVisitor) {
+            $this->visitors[$registeredVisitor] = new $registeredVisitor();
+            $this->issues[$registeredVisitor] = [];
+        }
+    }
+
+    private function getParser(string $phpVersion): Parser
+    {
+        $version = explode('.', $phpVersion);
+        return (new ParserFactory())->createForVersion(PhpVersion::fromComponents((int)$version[0], (int)$version[1]));
+    }
+}
+
+$application = new Application('Integrity Check');
+$name = 'integrity_checker';
+$application->add(new PhpIntegrityChecker($name));
+$application->setDefaultCommand($name, true);
+$application->run();
diff --git a/Build/Scripts/phpIntegrityChecks/AbstractPhpIntegrityChecker.php b/Build/Scripts/phpIntegrityChecks/AbstractPhpIntegrityChecker.php
new file mode 100644
index 000000000000..1ab537b77fa0
--- /dev/null
+++ b/Build/Scripts/phpIntegrityChecks/AbstractPhpIntegrityChecker.php
@@ -0,0 +1,91 @@
+<?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!
+ */
+
+namespace TYPO3\CMS\PhpIntegrityChecks;
+
+use PhpParser\NodeVisitorAbstract;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+abstract class AbstractPhpIntegrityChecker extends NodeVisitorAbstract
+{
+    /**
+     * @var array<int|string, mixed>
+     */
+    protected array $messages = [];
+
+    /**
+     * @var string[]
+     */
+    protected array $excludedDirectories = [];
+
+    /**
+     * @var string[]
+     */
+    protected array $excludedFileNames = [];
+
+    protected \SplFileInfo $file;
+
+    public function canHandle(\SplFileInfo $file): bool
+    {
+        if (in_array($file->getFilename(), $this->excludedFileNames, true)) {
+            return false;
+        }
+        foreach ($this->excludedDirectories as $path) {
+            if (str_starts_with($this->removeRootPathFromPath($file->getRealPath()), $path)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public function startProcessing(\SplFileInfo $file): void
+    {
+        $this->file = $file;
+        $this->messages = [];
+    }
+
+    public function finishProcessing(): void
+    {
+        // override in concrete classes, if needed
+    }
+
+    public function getMessages(): array
+    {
+        return $this->messages;
+    }
+
+    protected function getRelativeFileNameFromRepositoryRoot(): string
+    {
+        return $this->removeRootPathFromPath($this->file->getRealPath());
+    }
+
+    protected function removeRootPathFromPath(string $path): string
+    {
+        $rootPath = rtrim($this->getRootPath(), '/') . '/';
+        return str_starts_with($path, $rootPath)
+            ? mb_substr($path, mb_strlen($rootPath))
+            : $path;
+    }
+
+    protected function getRootPath(): string
+    {
+        return dirname(__FILE__, 4);
+    }
+
+    abstract public function outputResult(SymfonyStyle $io, array $issueCollection): void;
+
+}
diff --git a/Build/Scripts/annotationChecker.php b/Build/Scripts/phpIntegrityChecks/AnnotationChecker.php
old mode 100755
new mode 100644
similarity index 66%
rename from Build/Scripts/annotationChecker.php
rename to Build/Scripts/phpIntegrityChecks/AnnotationChecker.php
index 6e4ef6131aad..35453c1ad730
--- a/Build/Scripts/annotationChecker.php
+++ b/Build/Scripts/phpIntegrityChecks/AnnotationChecker.php
@@ -1,4 +1,3 @@
-#!/usr/bin/env php
 <?php
 
 declare(strict_types=1);
@@ -16,22 +15,25 @@ declare(strict_types=1);
  * The TYPO3 project - inspiring people to share!
  */
 
+namespace TYPO3\CMS\PhpIntegrityChecks;
+
 use PhpParser\Comment\Doc;
-use PhpParser\Error;
 use PhpParser\Node;
-use PhpParser\NodeTraverser;
-use PhpParser\NodeVisitorAbstract;
-use PhpParser\ParserFactory;
-use PhpParser\PhpVersion;
-use Symfony\Component\Console\Output\ConsoleOutput;
-
-require_once __DIR__ . '/../../vendor/autoload.php';
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Style\SymfonyStyle;
 
-class NodeVisitor extends NodeVisitorAbstract
+/**
+ * Check for allowed annotations in classes, report on any not whitelisted
+ */
+final class AnnotationChecker extends AbstractPhpIntegrityChecker
 {
-    public array $matches = [];
+    // black list some unit test fixture files from extension scanner that test matchers of old annotations
+    protected array $excludedFileNames = [
+        'MethodAnnotationMatcherFixture.php',
+        'PropertyAnnotationMatcherFixture.php',
+    ];
 
-    public function enterNode(Node $node)
+    public function enterNode(Node $node): void
     {
         switch (get_class($node)) {
             case Node\Stmt\Class_::class:
@@ -88,9 +90,12 @@ class NodeVisitor extends NodeVisitorAbstract
                     $matches
                 );
                 if (!empty($matches['annotations'])) {
-                    $this->matches[$node->getLine()] = array_map(static function (string $value): string {
-                        return '@' . $value;
-                    }, $matches['annotations']);
+                    $this->messages[$this->getRelativeFileNameFromRepositoryRoot()][$node->getLine()] = array_map(
+                        static function (string $value): string {
+                            return '@' . $value;
+                        },
+                        $matches['annotations']
+                    );
                 }
 
                 break;
@@ -98,64 +103,30 @@ class NodeVisitor extends NodeVisitorAbstract
                 break;
         }
     }
-}
-
-$parser = (new ParserFactory())->createForVersion(PhpVersion::fromComponents(8, 2));
-
-$finder = new Symfony\Component\Finder\Finder();
-$finder->files()
-    ->in(__DIR__ . '/../../typo3/')
-    ->name('/\.php$/')
-    // black list some unit test fixture files from extension scanner that test matchers of old annotations
-    ->notName('MethodAnnotationMatcherFixture.php')
-    ->notName('PropertyAnnotationMatcherFixture.php')
-;
-
-$output = new ConsoleOutput();
-
-$errors = [];
-foreach ($finder as $file) {
-    try {
-        $ast = $parser->parse($file->getContents());
-    } catch (Error $error) {
-        $output->writeln('<error>Parse error: ' . $error->getMessage() . '</error>');
-        exit(1);
-    }
 
-    $visitor = new NodeVisitor();
-
-    $traverser = new NodeTraverser();
-    $traverser->addVisitor($visitor);
-
-    $ast = $traverser->traverse($ast);
-
-    if (!empty($visitor->matches)) {
-        $errors[$file->getRealPath()] = $visitor->matches;
-        $output->write('<error>F</error>');
-    } else {
-        $output->write('<fg=green>.</>');
-    }
-}
-
-$output->writeln('');
-
-if (!empty($errors)) {
-    $output->writeln('');
-
-    foreach ($errors as $file => $matchesPerLine) {
-        $output->writeln('');
-        $output->writeln('<error>' . $file . '</error>');
-
-        /**
-         * @var array $matchesPerLine
-         * @var int $line
-         * @var array $matches
-         */
-        foreach ($matchesPerLine as $line => $matches) {
-            $output->writeln($line . ': ' . implode(', ', $matches));
+    public function outputResult(SymfonyStyle $io, array $issueCollection): void
+    {
+        $io->title('Annotation checker result');
+        if ($issueCollection !== []) {
+            $io->error('Following annotations are invalid. Remove them:');
+            $table = new Table($io);
+            $table->setHeaders([
+                'File',
+                'Line',
+                'Annotation(s)',
+            ]);
+            foreach ($issueCollection as $file => $issues) {
+                foreach ($issues as $line => $annotations) {
+                    $table->addRow([
+                        $file,
+                        $line,
+                        implode(', ', $annotations),
+                    ]);
+                }
+            }
+            $table->render();
+        } else {
+            $io->success('Annotation integrity is in good shape.');
         }
     }
-    exit(1);
 }
-
-exit(0);
diff --git a/Build/Scripts/phpIntegrityChecks/NamespaceChecker.php b/Build/Scripts/phpIntegrityChecks/NamespaceChecker.php
new file mode 100644
index 000000000000..d7e582d9cb80
--- /dev/null
+++ b/Build/Scripts/phpIntegrityChecks/NamespaceChecker.php
@@ -0,0 +1,255 @@
+<?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!
+ */
+
+namespace TYPO3\CMS\PhpIntegrityChecks;
+
+use PhpParser\Node;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+/**
+ * check namespaces in classes comply with PSR-4, report on any not doing so
+ */
+final class NamespaceChecker extends AbstractPhpIntegrityChecker
+{
+    /**
+     * @var string[]
+     */
+    protected array $excludedFileNames = [
+        'ConditionMatcherUserFuncs.php',
+        'PropertyExistsStaticMatcherFixture.php',
+    ];
+
+    /**
+     * @var string[]
+     */
+    protected array $excludedDirectories = [
+        'typo3/sysext/core/Tests/Unit/Core/Fixtures/test_extension/',
+    ];
+
+    protected string $filePathPrefix = 'typo3/sysext/';
+    protected string $filePathPrefixRegex = '/.*typo3\/sysext\/(.*)$/';
+
+    private string $type = '';
+    private string $fullQualifiedObjectNamespace = '';
+    private string $sysExtName;
+    private string $expectedFullQualifiedNamespace;
+    private string $relativeFilenameFromRoot;
+
+    public function startProcessing(\SplFileInfo $file): void
+    {
+        parent::startProcessing($file);
+        $this->type = '';
+        $this->fullQualifiedObjectNamespace = '';
+        $this->sysExtName = '';
+        $this->expectedFullQualifiedNamespace = '';
+        $this->relativeFilenameFromRoot = '';
+        $ignoreNamespaceParts = ['Classes'];
+        $fullFilename = $file->getRealPath();
+
+        preg_match($this->filePathPrefixRegex, $fullFilename, $matches);
+        $this->relativeFilenameFromRoot = $this->filePathPrefix . $matches[1];
+        $parts = explode('/', $matches[1]);
+        $this->sysExtName = $parts[0];
+        unset($parts[0]);
+        if (in_array($parts[1], $ignoreNamespaceParts, true)) {
+            unset($parts[1]);
+        }
+
+        $relativeFilenameWithoutSystemExtensionRoot = substr($this->relativeFilenameFromRoot, (mb_strlen($this->filePathPrefix . $this->sysExtName . '/')));
+        $this->expectedFullQualifiedNamespace = $this->determineExpectedFullQualifiedNamespace($this->sysExtName, $relativeFilenameWithoutSystemExtensionRoot);
+    }
+
+    public function enterNode(Node $node): void
+    {
+        if ($this->type !== '') {
+            return;
+        }
+        if ($node instanceof Node\Stmt\Class_ && !$node->isAnonymous()) {
+            $this->type = 'class';
+            $this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
+            return;
+        }
+        if ($node instanceof Node\Stmt\Interface_) {
+            $this->type = 'interface';
+            $this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
+            return;
+        }
+        if ($node instanceof Node\Stmt\Enum_) {
+            $this->type = 'enum';
+            $this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
+            return;
+        }
+        if ($node instanceof Node\Stmt\Trait_) {
+            $this->type = 'trait';
+            $this->fullQualifiedObjectNamespace = (string)$node->namespacedName;
+        }
+    }
+
+    public function finishProcessing(): void
+    {
+        $fileObjectType = $this->getType();
+        $fileObjectFullQualifiedObjectNamespace = $this->getFullQualifiedObjectNamespace();
+        if ($fileObjectType !== ''
+            && $this->expectedFullQualifiedNamespace !== $fileObjectFullQualifiedObjectNamespace
+        ) {
+            $this->messages[$this->sysExtName][] = [
+                'file' => $this->relativeFilenameFromRoot,
+                'shouldBe' => $this->expectedFullQualifiedNamespace,
+                'actualIs' => $fileObjectFullQualifiedObjectNamespace,
+            ];
+        }
+    }
+
+    public function outputResult(SymfonyStyle $io, array $issueCollection): void
+    {
+        $io->title('Namespace Checker result');
+        if ($issueCollection !== []) {
+            $io->error('Namespace integrity broken.');
+            $table = new Table($io);
+            $table->setHeaders([
+                'EXT',
+                'File',
+                'should be',
+                'actual is',
+            ]);
+            foreach ($issueCollection as $extKey => $issues) {
+                foreach ($issues as $result) {
+                    $table->addRow([
+                        $extKey,
+                        $result['file'],
+                        $result['shouldBe'] ?: 'no proper registered PSR-4 namespace',
+                        $result['actualIs'],
+                    ]);
+                }
+            }
+            $table->render();
+        } else {
+            $io->success(' Namespace integrity is in good shape.');
+        }
+    }
+
+    protected function getType(): string
+    {
+        return $this->type;
+    }
+
+    protected function getFullQualifiedObjectNamespace(): string
+    {
+        return $this->fullQualifiedObjectNamespace;
+    }
+
+    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/')) {
+            // for test fixture extensions, the relativeFileName will be shortened by the sysext file path,
+            // therefor the variable gets passed as reference here
+            $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__ . '/../../../' . $this->filePathPrefix . $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)) {
+            return '';
+        }
+        try {
+            $composerInfo = \json_decode(
+                json: (string)file_get_contents($fullComposerJsonFilePath),
+                flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR,
+            );
+            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($this->filePathPrefix . $systemExtensionKey . '/' . $relativeFileName, $relativePath)) {
+                        $relativePath = mb_substr($relativePath, mb_strlen($this->filePathPrefix . $systemExtensionKey . '/'));
+                        if (str_starts_with($relativeFileName, $relativePath)) {
+                            $relativeFileName = mb_substr($relativeFileName, mb_strlen($relativePath));
+                        }
+                        return $namespace;
+                    }
+                    if (str_starts_with($relativeFileName, $relativePath)) {
+                        return $namespace;
+                    }
+                }
+            }
+        } catch (\JsonException) {
+        }
+
+        return '';
+    }
+}
diff --git a/Build/Scripts/phpIntegrityChecks/TestClassFinalChecker.php b/Build/Scripts/phpIntegrityChecks/TestClassFinalChecker.php
new file mode 100644
index 000000000000..b0e7fe5ce853
--- /dev/null
+++ b/Build/Scripts/phpIntegrityChecks/TestClassFinalChecker.php
@@ -0,0 +1,72 @@
+<?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!
+ */
+
+namespace TYPO3\CMS\PhpIntegrityChecks;
+
+use PhpParser\Node;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+/**
+ * check all test classes based on phpunit are final, report on any that are not
+ */
+final class TestClassFinalChecker extends AbstractPhpIntegrityChecker
+{
+    public function canHandle(\SplFileInfo $file): bool
+    {
+        if (!str_contains($file->getRealPath(), 'Tests/Unit') && !str_contains($file->getRealPath(), 'Tests/Functional')) {
+            return false;
+        }
+        if (!str_ends_with($file->getBasename(), 'Test.php')) {
+            return false;
+        }
+        return true;
+    }
+
+    public function enterNode(Node $node): void
+    {
+        if (($node instanceof Node\Stmt\Class_) && !$node->isFinal() && !$node->isAnonymous() && !$node->isAbstract()) {
+            $this->messages[$this->getRelativeFileNameFromRepositoryRoot()][$node->getLine()] = $node->name;
+        }
+    }
+
+    public function outputResult(SymfonyStyle $io, array $issueCollection): void
+    {
+        $io->title('Final Checker for test classes result');
+        if ($issueCollection !== []) {
+            $io->error('Following test classes should be marked as final:');
+            $table = new Table($io);
+            $table->setHeaders([
+                'File',
+                'Line',
+                'Class',
+            ]);
+            foreach ($issueCollection as $file => $issues) {
+                foreach ($issues as $line => $issue) {
+                    $table->addRow([
+                        $file,
+                        $line,
+                        $issue,
+                    ]);
+                }
+            }
+            $table->render();
+        } else {
+            $io->success('Test class \'final\' integrity is in good shape.');
+        }
+    }
+}
diff --git a/Build/Scripts/phpIntegrityChecks/TestMethodPrefixChecker.php b/Build/Scripts/phpIntegrityChecks/TestMethodPrefixChecker.php
new file mode 100644
index 000000000000..f403a16872e7
--- /dev/null
+++ b/Build/Scripts/phpIntegrityChecks/TestMethodPrefixChecker.php
@@ -0,0 +1,73 @@
+<?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!
+ */
+
+namespace TYPO3\CMS\PhpIntegrityChecks;
+
+use PhpParser\Node;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+/**
+ * check all test classes based on phpunit contain only test methods with annotation or attribute, but not with the `test` prefix.
+ * report on any method using a prefix
+ */
+final class TestMethodPrefixChecker extends AbstractPhpIntegrityChecker
+{
+    public function canHandle(\SplFileInfo $file): bool
+    {
+        if (!str_contains($file->getRealPath(), 'Tests/Unit') && !str_contains($file->getRealPath(), 'Tests/Functional')) {
+            return false;
+        }
+        if (!str_ends_with($file->getBasename(), 'Test.php')) {
+            return false;
+        }
+        return true;
+    }
+
+    public function enterNode(Node $node): void
+    {
+        if (($node instanceof Node\Stmt\ClassMethod) && str_starts_with($node->name->name, 'test')) {
+            $this->messages[$this->getRelativeFileNameFromRepositoryRoot()][$node->getLine()] = $node->name->name;
+        }
+    }
+
+    public function outputResult(SymfonyStyle $io, array $issueCollection): void
+    {
+        $io->title('Test Method prefix checker result');
+        if ($issueCollection !== []) {
+            $io->error('Following test methods should not start with "test". Use @test phpDoc annotation or #[Test] attribute instead.');
+            $table = new Table($io);
+            $table->setHeaders([
+                'File',
+                'Line',
+                'Method',
+            ]);
+            foreach ($issueCollection as $file => $issues) {
+                foreach ($issues as $line => $issue) {
+                    $table->addRow([
+                        $file,
+                        $line,
+                        $issue,
+                    ]);
+                }
+            }
+            $table->render();
+        } else {
+            $io->success('Test method prefix integrity is in good shape.');
+        }
+    }
+}
diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh
index ab6f1834366b..117cee9307b8 100755
--- a/Build/Scripts/runTests.sh
+++ b/Build/Scripts/runTests.sh
@@ -191,7 +191,7 @@ Options:
             - cglGit: test and fix latest committed patch for CGL compliance
             - cglHeader: test and fix file header for all core php files
             - cglHeaderGit: test and fix latest committed patch for CGL file header compliance
-            - checkAnnotations: check php code for allowed annotations
+            - checkIntegrity: check php code for with registered integrity rules
             - checkBom: check UTF-8 files do not contain BOM
             - checkComposer: check composer.json files for version integrity
             - checkExceptionCodes: test core for duplicate exception codes
@@ -200,11 +200,8 @@ Options:
             - checkGitSubmodule: test core git has no sub modules defined
             - checkGruntClean: Verify "grunt build" is clean. Warning: Executes git commands! Usually used in CI only.
             - checkIsoDatabase: Verify "updateIsoDatabase.php" does not change anything.
-            - 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
-            - checkTestClassFinal: check test case classes are final
-            - checkTestMethodsPrefix: check tests methods do not start with "test"
             - clean: clean up build, cache and testing related files and folders
             - cleanBuild: clean up build related files and folders
             - cleanCache: clean up cache related files and folders
@@ -843,16 +840,8 @@ case ${TEST_SUITE} in
         ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name cgl-header-git-${SUFFIX} ${IMAGE_PHP} Build/Scripts/cglFixMyCommitFileHeader.sh ${CGLCHECK_DRY_RUN}
         SUITE_EXIT_CODE=$?
         ;;
-    checkAnnotations)
-        ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-annotations-${SUFFIX} ${IMAGE_PHP} php -dxdebug.mode=off Build/Scripts/annotationChecker.php
-        SUITE_EXIT_CODE=$?
-        ;;
-    checkTestClassFinal)
-        ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-test-classes-final-${SUFFIX} ${IMAGE_PHP} php -dxdebug.mode=off Build/Scripts/testClassFinalChecker.php
-        SUITE_EXIT_CODE=$?
-        ;;
-    checkTestMethodsPrefix)
-        ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-test-methods-prefix-${SUFFIX} ${IMAGE_PHP} php -dxdebug.mode=off Build/Scripts/testMethodPrefixChecker.php
+    checkIntegrityPhp)
+        ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-annotations-${SUFFIX} ${XDEBUG_MODE} -e XDEBUG_CONFIG="${XDEBUG_CONFIG}" ${IMAGE_PHP} php Build/Scripts/phpIntegrityChecker.php -p ${PHP_VERSION}
         SUITE_EXIT_CODE=$?
         ;;
     checkBom)
@@ -890,10 +879,6 @@ case ${TEST_SUITE} in
         ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-iso-database-${SUFFIX} ${IMAGE_PHP} /bin/sh -c "${COMMAND}"
         SUITE_EXIT_CODE=$?
         ;;
-    checkNamespaceIntegrity)
-        ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-namespaces-${SUFFIX} ${IMAGE_PHP} php -dxdebug.mode=off Build/Scripts/checkNamespaceIntegrity.php
-        SUITE_EXIT_CODE=$?
-        ;;
     checkPermissions)
         ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name check-permissions-${SUFFIX} ${IMAGE_PHP} Build/Scripts/checkFilePermissions.sh
         SUITE_EXIT_CODE=$?
diff --git a/Build/Scripts/testClassFinalChecker.php b/Build/Scripts/testClassFinalChecker.php
deleted file mode 100644
index e429c5a68801..000000000000
--- a/Build/Scripts/testClassFinalChecker.php
+++ /dev/null
@@ -1,88 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use PhpParser\Node;
-use PhpParser\NodeTraverser;
-use PhpParser\NodeVisitorAbstract;
-use PhpParser\ParserFactory;
-use PhpParser\PhpVersion;
-use Symfony\Component\Console\Output\ConsoleOutput;
-
-require_once __DIR__ . '/../../vendor/autoload.php';
-
-/**
- * This script checks all class tests are declared final.
- */
-class NodeVisitor extends NodeVisitorAbstract
-{
-    public array $matches = [];
-
-    public function enterNode(Node $node): void
-    {
-        if (($node instanceof Node\Stmt\Class_) && !$node->isFinal() && !$node->isAnonymous() && !$node->isAbstract()) {
-            $this->matches[$node->getLine()] = $node->name;
-        }
-    }
-}
-
-$parser = (new ParserFactory())->createForVersion(PhpVersion::fromComponents(8, 2));
-
-$finder = new Symfony\Component\Finder\Finder();
-$finder->files()
-    ->in([
-        __DIR__ . '/../../typo3/sysext/*/Tests/Unit/',
-        __DIR__ . '/../../typo3/sysext/*/Tests/UnitDeprecated/',
-        __DIR__ . '/../../typo3/sysext/*/Tests/Functional/',
-        __DIR__ . '/../../typo3/sysext/*/Tests/FunctionalDeprecated/',
-    ])
-    ->name('/Test\.php$/');
-
-$output = new ConsoleOutput();
-
-$errors = [];
-foreach ($finder as $file) {
-    try {
-        $ast = $parser->parse($file->getContents());
-    } catch (Error $error) {
-        $output->writeln('<error>Parse error: ' . $error->getMessage() . '</error>');
-        exit(1);
-    }
-
-    $visitor = new NodeVisitor();
-
-    $traverser = new NodeTraverser();
-    $traverser->addVisitor($visitor);
-
-    $ast = $traverser->traverse($ast);
-
-    if (!empty($visitor->matches)) {
-        $errors[$file->getRealPath()] = $visitor->matches;
-        $output->write('<error>F</error>');
-    } else {
-        $output->write('<fg=green>.</>');
-    }
-}
-
-$output->writeln('');
-
-if (!empty($errors)) {
-    $output->writeln('');
-
-    foreach ($errors as $file => $matchesPerLine) {
-        $output->writeln('');
-        $output->writeln('<error>Test class should be marked as final. Found in ' . $file . '</error>');
-
-        /**
-         * @var array $matchesPerLine
-         * @var int $line
-         * @var array $matches
-         */
-        foreach ($matchesPerLine as $line => $methodName) {
-            $output->writeln('Method:' . $methodName . ' Line:' . $line);
-        }
-    }
-    exit(1);
-}
-
-exit(0);
diff --git a/Build/Scripts/testMethodPrefixChecker.php b/Build/Scripts/testMethodPrefixChecker.php
deleted file mode 100644
index ef71201e2874..000000000000
--- a/Build/Scripts/testMethodPrefixChecker.php
+++ /dev/null
@@ -1,92 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-use PhpParser\Node;
-use PhpParser\NodeTraverser;
-use PhpParser\NodeVisitorAbstract;
-use PhpParser\ParserFactory;
-use PhpParser\PhpVersion;
-use Symfony\Component\Console\Output\ConsoleOutput;
-
-require_once __DIR__ . '/../../vendor/autoload.php';
-
-/**
- * This script checks tests do not start with "test" like
- * "public function testSomething". Instead they should be
- *  like "public function SomethinIsLike()" and have a @test annotation.
- *
- * This is done for general consistency and the "splitter"
- * scripts that chunk tests to multiple nodes for functional tests.
- */
-class NodeVisitor extends NodeVisitorAbstract
-{
-    public array $matches = [];
-
-    public function enterNode(Node $node): void
-    {
-        if (($node instanceof Node\Stmt\ClassMethod) && str_starts_with($node->name->name, 'test')) {
-            $this->matches[$node->getLine()] = $node->name->name;
-        }
-    }
-}
-
-$parser = (new ParserFactory())->createForVersion(PhpVersion::fromComponents(8, 2));
-
-$finder = new Symfony\Component\Finder\Finder();
-$finder->files()
-    ->in([
-        __DIR__ . '/../../typo3/sysext/*/Tests/Unit/',
-        __DIR__ . '/../../typo3/sysext/*/Tests/UnitDeprecated/',
-        __DIR__ . '/../../typo3/sysext/*/Tests/Functional/',
-    ])
-    ->name('/Test\.php$/');
-
-$output = new ConsoleOutput();
-
-$errors = [];
-foreach ($finder as $file) {
-    try {
-        $ast = $parser->parse($file->getContents());
-    } catch (Error $error) {
-        $output->writeln('<error>Parse error: ' . $error->getMessage() . '</error>');
-        exit(1);
-    }
-
-    $visitor = new NodeVisitor();
-
-    $traverser = new NodeTraverser();
-    $traverser->addVisitor($visitor);
-
-    $ast = $traverser->traverse($ast);
-
-    if (!empty($visitor->matches)) {
-        $errors[$file->getRealPath()] = $visitor->matches;
-        $output->write('<error>F</error>');
-    } else {
-        $output->write('<fg=green>.</>');
-    }
-}
-
-$output->writeln('');
-
-if (!empty($errors)) {
-    $output->writeln('');
-
-    foreach ($errors as $file => $matchesPerLine) {
-        $output->writeln('');
-        $output->writeln('<error>Method shoud not start "test". Use @test phpDoc annotation instead. Found in ' . $file . '</error>');
-
-        /**
-         * @var array $matchesPerLine
-         * @var int $line
-         * @var array $matches
-         */
-        foreach ($matchesPerLine as $line => $methodName) {
-            $output->writeln('Method:' . $methodName . ' Line:' . $line);
-        }
-    }
-    exit(1);
-}
-
-exit(0);
diff --git a/Build/gitlab-ci/nightly/integrity.yml b/Build/gitlab-ci/nightly/integrity.yml
index 85235bbb07e8..df0551ef8d09 100644
--- a/Build/gitlab-ci/nightly/integrity.yml
+++ b/Build/gitlab-ci/nightly/integrity.yml
@@ -1,4 +1,4 @@
-annotations php 8.2:
+integrity php 8.2:
   stage: integrity
   tags:
     - metal2
@@ -7,7 +7,7 @@ annotations php 8.2:
     - schedules
   script:
     - Build/Scripts/runTests.sh -s composerInstall -p 8.2
-    - Build/Scripts/runTests.sh -s checkAnnotations -p 8.2
+    - Build/Scripts/runTests.sh -s checkIntegrityPhp -p 8.2
 
 cgl:
   stage: integrity
@@ -58,9 +58,6 @@ integration various:
     - Build/Scripts/runTests.sh -s checkExtensionScannerRst -p 8.2
     - Build/Scripts/runTests.sh -s checkBom -p 8.2
     - Build/Scripts/runTests.sh -s checkComposer -p 8.2
-    - Build/Scripts/runTests.sh -s checkTestClassFinal -p 8.2
-    - Build/Scripts/runTests.sh -s checkTestMethodsPrefix -p 8.2
-    - Build/Scripts/runTests.sh -s checkNamespaceIntegrity -p 8.2
 
 iso database max:
   stage: integrity
diff --git a/Build/gitlab-ci/pre-merge/integrity.yml b/Build/gitlab-ci/pre-merge/integrity.yml
index 62ae7841e554..21fd4dc576df 100644
--- a/Build/gitlab-ci/pre-merge/integrity.yml
+++ b/Build/gitlab-ci/pre-merge/integrity.yml
@@ -1,4 +1,4 @@
-annotations php 8.2 pre-merge:
+integrity php 8.2 pre-merge:
   stage: main
   tags:
     - metal2
@@ -8,7 +8,7 @@ annotations php 8.2 pre-merge:
       - main
   script:
     - Build/Scripts/runTests.sh -s composerInstall -p 8.2
-    - Build/Scripts/runTests.sh -s checkAnnotations -p 8.2
+    - Build/Scripts/runTests.sh -s checkIntegrityPhp -p 8.2
 
 cgl pre-merge:
   stage: main
@@ -62,9 +62,6 @@ integration various pre-merge:
     - Build/Scripts/runTests.sh -s checkExtensionScannerRst -p 8.2
     - Build/Scripts/runTests.sh -s checkBom -p 8.2
     - Build/Scripts/runTests.sh -s checkComposer -p 8.2
-    - Build/Scripts/runTests.sh -s checkTestClassFinal -p 8.2
-    - Build/Scripts/runTests.sh -s checkTestMethodsPrefix -p 8.2
-    - Build/Scripts/runTests.sh -s checkNamespaceIntegrity -p 8.2
 
 lint php 8.2 pre-merge:
   stage: main
diff --git a/composer.json b/composer.json
index dc82d590e8f7..eb602fba8bc9 100644
--- a/composer.json
+++ b/composer.json
@@ -271,6 +271,7 @@
 			"TYPO3\\CMS\\Beuser\\Tests\\": "typo3/sysext/beuser/Tests/",
 			"TYPO3\\CMS\\Core\\Tests\\": "typo3/sysext/core/Tests/",
 			"TYPO3\\CMS\\Composer\\Scripts\\": "Build/Scripts/composer/",
+			"TYPO3\\CMS\\PhpIntegrityChecks\\": "Build/Scripts/phpIntegrityChecks/",
 			"TYPO3\\CMS\\Dashboard\\Tests\\": "typo3/sysext/dashboard/Tests/",
 			"TYPO3\\CMS\\Extbase\\Tests\\": "typo3/sysext/extbase/Tests/",
 			"TYPO3\\CMS\\Extensionmanager\\Tests\\": "typo3/sysext/extensionmanager/Tests/",
-- 
GitLab