diff --git a/.github/workflows/static-code-analysis.yml b/.github/workflows/static-code-analysis.yml
index f2efe3ea70e6e..c259a2b086898 100644
--- a/.github/workflows/static-code-analysis.yml
+++ b/.github/workflows/static-code-analysis.yml
@@ -111,3 +111,29 @@ jobs:
- name: Show potential changes in Psalm baseline
if: always()
run: git diff --exit-code -- . ':!lib/composer'
+
+ static-code-analysis-ncu:
+ runs-on: ubuntu-latest
+
+ if: ${{ github.event_name != 'push' && github.repository_owner != 'nextcloud-gmbh' }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
+ with:
+ submodules: true
+
+ - name: Set up php
+ uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 #v2.31.1
+ with:
+ php-version: '8.1'
+ extensions: ctype,curl,dom,fileinfo,gd,imagick,intl,json,mbstring,openssl,pdo_sqlite,posix,sqlite,xml,zip
+ coverage: none
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Composer install
+ run: composer i
+
+ - name: Psalm
+ run: composer run psalm:ncu -- --threads=1 --monochrome --no-progress --output-format=github
diff --git a/build/files-checker.php b/build/files-checker.php
index 3d3ec923eae59..3231334efc331 100644
--- a/build/files-checker.php
+++ b/build/files-checker.php
@@ -69,6 +69,7 @@
'ocs-provider',
'package-lock.json',
'package.json',
+ 'psalm-ncu.xml',
'psalm-ocp.xml',
'psalm.xml',
'public.php',
diff --git a/build/psalm/NcuExperimentalChecker.php b/build/psalm/NcuExperimentalChecker.php
new file mode 100644
index 0000000000000..d5c24333d9ed4
--- /dev/null
+++ b/build/psalm/NcuExperimentalChecker.php
@@ -0,0 +1,122 @@
+getStmt();
+ $statementsSource = $event->getStatementsSource();
+
+ self::checkClassComment($stmt, $statementsSource);
+
+ foreach ($stmt->getMethods() as $method) {
+ self::checkMethodOrConstantComment($method, $statementsSource, 'method');
+ }
+
+ foreach ($stmt->getConstants() as $constant) {
+ self::checkMethodOrConstantComment($constant, $statementsSource, 'constant');
+ }
+ }
+
+ private static function checkClassComment(ClassLike $stmt, FileSource $statementsSource): void {
+ $docblock = $stmt->getDocComment();
+
+ if ($docblock === null) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ 'PHPDoc is required for classes/interfaces in NCU.',
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ return;
+ }
+
+ try {
+ $parsedDocblock = DocComment::parsePreservingLength($docblock);
+ } catch (DocblockParseException $e) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ $e->getMessage(),
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ return;
+ }
+
+ if (!isset($parsedDocblock->tags['experimental'])) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ '@experimental is required for classes/interfaces in NCU.',
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ }
+
+ if (isset($parsedDocblock->tags['depreacted'])) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ 'Typo in @deprecated for classes/interfaces in NCU.',
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ }
+ }
+
+ private static function checkMethodOrConstantComment(Stmt $stmt, FileSource $statementsSource, string $type): void {
+ $docblock = $stmt->getDocComment();
+
+ if ($docblock === null) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ 'PHPDoc is required for ' . $type . 's in NCU.',
+ new CodeLocation($statementsSource, $stmt)
+ ),
+ );
+ return;
+ }
+
+ try {
+ $parsedDocblock = DocComment::parsePreservingLength($docblock);
+ } catch (DocblockParseException $e) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ $e->getMessage(),
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ return;
+ }
+
+ if (!isset($parsedDocblock->tags['experimental'])) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ '@experimental is required for ' . $type . 's in NCU.',
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ }
+
+ if (isset($parsedDocblock->tags['depreacted'])) {
+ IssueBuffer::maybeAdd(
+ new InvalidDocblock(
+ 'Typo in @deprecated for ' . $type . ' in NCU.',
+ new CodeLocation($statementsSource, $stmt)
+ )
+ );
+ }
+ }
+}
diff --git a/composer.json b/composer.json
index f11ced48b6780..ff381015d7b06 100644
--- a/composer.json
+++ b/composer.json
@@ -61,6 +61,7 @@
"lint": "find . -name \\*.php -not -path './lib/composer/*' -not -path './build/stubs/*' -print0 | xargs -0 -n1 php -l",
"psalm": "psalm --no-cache --threads=$(nproc)",
"psalm:ocp": "psalm --no-cache --threads=$(nproc) -c psalm-ocp.xml",
+ "psalm:ncu": "psalm --no-cache --threads=$(nproc) -c psalm-ncu.xml",
"psalm:security": "psalm --no-cache --threads=$(nproc) --taint-analysis --use-baseline=build/psalm-baseline-security.xml",
"psalm:update-baseline": "psalm --no-cache --threads=$(nproc) --update-baseline",
"serve": [
diff --git a/psalm-ncu.xml b/psalm-ncu.xml
new file mode 100644
index 0000000000000..f7577435b8893
--- /dev/null
+++ b/psalm-ncu.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/psalm-ocp.xml b/psalm-ocp.xml
index de4741aa2ddce..e49f7fd929b50 100644
--- a/psalm-ocp.xml
+++ b/psalm-ocp.xml
@@ -19,7 +19,6 @@
-