From e1ac6efa25f3a330e131431efcdaea45fdc0650e Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sun, 2 Aug 2026 20:48:25 +0200 Subject: [PATCH] chore(quality): make composer check:strict an honest gate The strict gate did not work. Measured on nextcloud:32-apache (PHP 8.3.32, imagick), matching the CI php-version 8.3 pin, against a clean composer install from composer.lock: lint exit 0 phpcs exit 3 NOT swallowed - the gate has been RED, not green phpmd exit 127 'phpmd: not found' (bare binary), swallowed by || echo psalm exit 1 swallowed by || echo phpstan exit 1 swallowed by || echo (no config at all) test:all exit 1 swallowed by || echo Every failure was a configuration error, not a code finding: this is a Python ExApp sidecar wrapper and lib/ does not exist, yet phpcs.xml, psalm.xml and the phpmd script all pointed at it. No CI workflow invokes check:strict, so nobody saw the red. The only PHP this repo authors is the 408-line NamedParametersSniff, which had therefore never been linted. Point the tools at it: - phpcs.xml / psalm.xml / phpmd + phpstan paths -> phpcs-custom-sniffs - add phpstan.neon (level 5) - phpstan is applicable, not N/A: there is real PHP here and the config costs nothing - add analysis-bootstrap.php supplying OCP/NCU and PHP_CodeSniffer symbols (neither package declares a composer autoload section). This removed a 52 -> 19 -> 0 phpstan cascade and a 1 -> 19 -> 0 psalm cascade by supplying the missing symbol source, not by suppressing diagnostics. - drop the || echo fallbacks from phpmd/psalm/phpstan; use ./vendor/bin/X Findings on the real target, and what was done with each: phpcs 5 (CRLF line endings, @package/@author order, @author format, missing @license, @license needs a URL) - ALL FIXED phpmd 4 - 2 fixed ($j -> $ptr), 2 baselined (class complexity 65>50, hasUnnamedArguments 20>15); both are complexity/style inherent to token-stream parsing, tracked in an issue, not defects psalm 0 phpstan 0 Positive control: a probe method with var_dump, a short variable and a call to an undefined method makes all four analyzers fail, and the phpmd baseline does not mask it - so the green result is not vacuous. test:all is left dead on purpose (no phpunit.xml, no tests/), but its skip message no longer claims a Nextcloud environment is the reason. --- analysis-bootstrap.php | 28 + composer.json | 18 +- .../Sniffs/Functions/NamedParametersSniff.php | 818 +++++++++--------- phpcs.xml | 7 +- phpmd.baseline.xml | 5 + phpstan.neon | 17 + psalm.xml | 17 +- 7 files changed, 491 insertions(+), 419 deletions(-) create mode 100644 analysis-bootstrap.php create mode 100644 phpmd.baseline.xml create mode 100644 phpstan.neon diff --git a/analysis-bootstrap.php b/analysis-bootstrap.php new file mode 100644 index 0000000..39d79c4 --- /dev/null +++ b/analysis-bootstrap.php @@ -0,0 +1,28 @@ +addPsr4('OCP\\', __DIR__ . '/vendor/nextcloud/ocp/OCP/'); +$autoloader->addPsr4('NCU\\', __DIR__ . '/vendor/nextcloud/ocp/NCU/'); + +require_once __DIR__ . '/vendor/squizlabs/php_codesniffer/autoload.php'; +class_exists('PHP_CodeSniffer\Util\Tokens'); diff --git a/composer.json b/composer.json index 53a3098..6272975 100644 --- a/composer.json +++ b/composer.json @@ -20,13 +20,13 @@ "cs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml", "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml", - "phpmd": "phpmd lib text phpmd.xml || echo 'PHPMD not installed, skipping...'", - "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/", - "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/", - "psalm": "./vendor/bin/psalm --threads=1 --no-cache || echo 'Psalm not installed, skipping...'", - "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G || echo 'PHPStan not installed, skipping...'", - "test:unit": "./vendor/bin/phpunit --colors=always || echo 'Tests require Nextcloud environment, skipping...'", - "test:all": "./vendor/bin/phpunit --colors=always || echo 'Tests require Nextcloud environment, skipping...'", + "phpmd": "./vendor/bin/phpmd phpcs-custom-sniffs text phpmd.xml", + "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics phpcs-custom-sniffs", + "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml phpcs-custom-sniffs", + "psalm": "./vendor/bin/psalm --threads=1 --no-cache", + "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G", + "test:unit": "./vendor/bin/phpunit --colors=always || echo 'No PHP test suite in this repo (no phpunit.xml, no tests/) - skipping...'", + "test:all": "./vendor/bin/phpunit --colors=always || echo 'No PHP test suite in this repo (no phpunit.xml, no tests/) - skipping...'", "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", "check:strict": "E=0; for CMD in lint phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", @@ -35,8 +35,8 @@ ], "test:coverage": "./vendor/bin/phpunit --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always", "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"", - "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"", - "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'", + "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json phpcs-custom-sniffs | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"", + "quality:phpmd-score": "./vendor/bin/phpmd phpcs-custom-sniffs json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'", "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'", "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'", "quality:score": [ diff --git a/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php b/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php index 331bf24..91bd559 100644 --- a/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php +++ b/phpcs-custom-sniffs/CustomSniffs/Sniffs/Functions/NamedParametersSniff.php @@ -1,408 +1,410 @@ -method(name: $value) - * - self::method(name: $value) / static::method(name: $value) - * - parent::method(name: $value) - * - new OurClass(name: $value) — classes from the same app namespace - * - * Allows positional arguments for external code: - * - PHP built-in functions (strlen, array_map, sprintf, etc.) - * - Nextcloud/third-party method calls ($variable->method() where $variable !== $this) - * - Any call we cannot determine is "our code" - * - * @author Conduction - * @package CustomSniffs - */ - -namespace CustomSniffs\Sniffs\Functions; - -use PHP_CodeSniffer\Sniffs\Sniff; -use PHP_CodeSniffer\Files\File; - -/** - * NamedParametersSniff — enforces named parameters for internal code. - */ -class NamedParametersSniff implements Sniff -{ - - - /** - * Returns tokens this sniff listens for. - * - * @return array - */ - public function register(): array - { - return [T_STRING]; - - }//end register() - - - /** - * Process a T_STRING token — check if it's a function/method call to our code. - * - * @param File $phpcsFile The file being scanned. - * @param int $stackPtr Position of the T_STRING token. - * - * @return void - */ - public function process(File $phpcsFile, $stackPtr): void - { - $tokens = $phpcsFile->getTokens(); - $functionName = $tokens[$stackPtr]['content']; - - // Must be followed by ( to be a function/method call. - $openParen = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true); - if ($openParen === false || $tokens[$openParen]['code'] !== T_OPEN_PARENTHESIS) { - return; - } - - // Skip function/method definitions. - if ($this->isFunctionDefinition(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === true) { - return; - } - - // Only check calls to our own code. - if ($this->isInternalCall(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === false) { - return; - } - - // Get the closing parenthesis. - if (isset($tokens[$openParen]['parenthesis_closer']) === false) { - return; - } - - $closeParen = $tokens[$openParen]['parenthesis_closer']; - - // Check if there are any arguments. - $firstContent = $phpcsFile->findNext( - types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], - start: ($openParen + 1), - end: $closeParen, - exclude: true - ); - if ($firstContent === false) { - return; - } - - // Check if all arguments use named parameters. - if ($this->hasUnnamedArguments(phpcsFile: $phpcsFile, openParen: $openParen, closeParen: $closeParen) === true) { - $error = 'All arguments in calls to internal code must use named parameters: %s(paramName: $value)'; - $phpcsFile->addError($error, $stackPtr, 'RequireNamedParameters', [$functionName]); - } - - }//end process() - - - /** - * Check if the T_STRING at $stackPtr is part of a function/method definition (not a call). - * - * @param File $phpcsFile The file being scanned. - * @param int $stackPtr Position of the T_STRING token. - * - * @return bool True if this is a definition, false if it's a call. - */ - private function isFunctionDefinition(File $phpcsFile, int $stackPtr): bool - { - $tokens = $phpcsFile->getTokens(); - $prev = ($stackPtr - 1); - while ($prev >= 0) { - $code = $tokens[$prev]['code']; - if ($code === T_FUNCTION) { - return true; - } - - // Stop at statement/block boundaries. - if ($code === T_SEMICOLON - || $code === T_OPEN_CURLY_BRACKET - || $code === T_CLOSE_CURLY_BRACKET - ) { - return false; - } - - $prev--; - } - - return false; - - }//end isFunctionDefinition() - - - /** - * Determine if the function/method call at $stackPtr is to our own code. - * - * Matches: - * - $this->method() - * - self::method() / static::method() / parent::method() - * - new OurClass() where OurClass is from the same app namespace - * - * @param File $phpcsFile The file being scanned. - * @param int $stackPtr Position of the T_STRING (function/method name). - * - * @return bool True if call is to internal code. - */ - private function isInternalCall(File $phpcsFile, int $stackPtr): bool - { - $tokens = $phpcsFile->getTokens(); - - $prev = $phpcsFile->findPrevious( - types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], - start: ($stackPtr - 1), - end: null, - exclude: true - ); - if ($prev === false) { - return false; - } - - $prevCode = $tokens[$prev]['code']; - - // Case 1: $this->method(). - if ($prevCode === T_OBJECT_OPERATOR) { - $beforeArrow = $phpcsFile->findPrevious( - types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], - start: ($prev - 1), - end: null, - exclude: true - ); - return ($beforeArrow !== false - && $tokens[$beforeArrow]['code'] === T_VARIABLE - && $tokens[$beforeArrow]['content'] === '$this'); - } - - // Case 2: self::method() / static::method() / parent::method(). - if ($prevCode === T_DOUBLE_COLON) { - $beforeColon = $phpcsFile->findPrevious( - types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], - start: ($prev - 1), - end: null, - exclude: true - ); - return ($beforeColon !== false - && in_array($tokens[$beforeColon]['code'], [T_SELF, T_STATIC, T_PARENT], true) === true); - } - - // Case 3: new OurClass(). - if ($prevCode === T_NEW) { - return $this->isClassFromOurNamespace(phpcsFile: $phpcsFile, classNamePtr: $stackPtr); - } - - return false; - - }//end isInternalCall() - - - /** - * Check if the class at $classNamePtr is from the same app namespace as the current file. - * - * Compares the class's use-import path against the file's OCA\AppName\ prefix. - * - * @param File $phpcsFile The file being scanned. - * @param int $classNamePtr Position of the class name token. - * - * @return bool True if the class is from our app namespace. - */ - private function isClassFromOurNamespace(File $phpcsFile, int $classNamePtr): bool - { - $tokens = $phpcsFile->getTokens(); - $className = $tokens[$classNamePtr]['content']; - - // Find the file's namespace declaration. - $appPrefix = $this->getAppNamespacePrefix(phpcsFile: $phpcsFile); - if ($appPrefix === null) { - return false; - } - - // Scan use-statements for an import matching this class name from our namespace. - for ($i = 0; $i < $phpcsFile->numTokens; $i++) { - if ($tokens[$i]['code'] === T_USE) { - $usePath = $this->getUseStatementPath(phpcsFile: $phpcsFile, usePtr: $i); - if ($usePath === null) { - continue; - } - - // Extract the imported short name (last segment). - $segments = explode(separator: '\\', string: $usePath); - $importedName = end($segments); - - if ($importedName === $className - && str_starts_with(haystack: $usePath, needle: $appPrefix) === true - ) { - return true; - } - }//end if - - // Stop scanning after the class declaration. - if (in_array($tokens[$i]['code'], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true) === true) { - break; - } - }//end for - - return false; - - }//end isClassFromOurNamespace() - - - /** - * Get the app namespace prefix (e.g., "OCA\LaunchPad") from the file's namespace declaration. - * - * @param File $phpcsFile The file being scanned. - * - * @return string|null The app prefix or null if not found. - */ - private function getAppNamespacePrefix(File $phpcsFile): ?string - { - $tokens = $phpcsFile->getTokens(); - for ($i = 0; $i < $phpcsFile->numTokens; $i++) { - if ($tokens[$i]['code'] === T_NAMESPACE) { - $namespace = ''; - $j = ($i + 1); - while ($j < $phpcsFile->numTokens && $tokens[$j]['code'] !== T_SEMICOLON) { - if ($tokens[$j]['code'] !== T_WHITESPACE) { - $namespace .= $tokens[$j]['content']; - } - - $j++; - } - - // Extract first two segments: OCA\AppName. - $parts = explode(separator: '\\', string: $namespace); - if (count($parts) >= 2) { - return $parts[0].'\\'.$parts[1]; - } - - return null; - }//end if - }//end for - - return null; - - }//end getAppNamespacePrefix() - - - /** - * Extract the full path from a use-statement starting at $usePtr. - * - * @param File $phpcsFile The file being scanned. - * @param int $usePtr Position of the T_USE token. - * - * @return string|null The use path or null if parsing failed. - */ - private function getUseStatementPath(File $phpcsFile, int $usePtr): ?string - { - $tokens = $phpcsFile->getTokens(); - $usePath = ''; - $j = ($usePtr + 1); - while ($j < $phpcsFile->numTokens) { - $code = $tokens[$j]['code']; - if ($code === T_SEMICOLON || $code === T_OPEN_CURLY_BRACKET) { - break; - } - - // Stop at 'as' keyword (aliases) — use the path before it. - if ($code === T_AS) { - break; - } - - if ($code !== T_WHITESPACE) { - $usePath .= $tokens[$j]['content']; - } - - $j++; - } - - $usePath = trim(string: $usePath); - return ($usePath !== '') ? $usePath : null; - - }//end getUseStatementPath() - - - /** - * Check if the arguments between $openParen and $closeParen contain any unnamed arguments. - * - * An argument is "named" if its first significant token is followed by T_COLON. - * Handles nested parentheses, brackets, and braces correctly. - * - * @param File $phpcsFile The file being scanned. - * @param int $openParen Position of the opening parenthesis. - * @param int $closeParen Position of the closing parenthesis. - * - * @return bool True if any argument is positional (unnamed). - */ - private function hasUnnamedArguments(File $phpcsFile, int $openParen, int $closeParen): bool - { - $tokens = $phpcsFile->getTokens(); - $parenDepth = 0; - $bracketDepth = 0; - $braceDepth = 0; - $atArgumentStart = true; - - for ($i = ($openParen + 1); $i < $closeParen; $i++) { - $code = $tokens[$i]['code']; - - // Track nesting depth. - if ($code === T_OPEN_PARENTHESIS) { - $parenDepth++; - } elseif ($code === T_CLOSE_PARENTHESIS) { - $parenDepth--; - } elseif ($code === T_OPEN_SHORT_ARRAY || $code === T_OPEN_SQUARE_BRACKET) { - $bracketDepth++; - } elseif ($code === T_CLOSE_SHORT_ARRAY || $code === T_CLOSE_SQUARE_BRACKET) { - $bracketDepth--; - } elseif ($code === T_OPEN_CURLY_BRACKET) { - $braceDepth++; - } elseif ($code === T_CLOSE_CURLY_BRACKET) { - $braceDepth--; - } - - // Only examine tokens at the top level of the argument list. - if ($parenDepth > 0 || $bracketDepth > 0 || $braceDepth > 0) { - continue; - } - - // Comma at top level → next argument starts. - if ($code === T_COMMA) { - $atArgumentStart = true; - continue; - } - - // Skip whitespace and comments at argument start. - if ($atArgumentStart === true - && in_array($code, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true) === true - ) { - continue; - } - - if ($atArgumentStart === true) { - $atArgumentStart = false; - - // Skip spread operator (...$args). - if ($code === T_ELLIPSIS) { - continue; - } - - // Check if this argument is named: token followed by ':'. - $nextNonWs = $phpcsFile->findNext( - types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], - start: ($i + 1), - end: $closeParen, - exclude: true - ); - - $isNamed = ($nextNonWs !== false && $tokens[$nextNonWs]['code'] === T_COLON); - - if ($isNamed === false) { - return true; - } - }//end if - }//end for - - return false; - - }//end hasUnnamedArguments() - - -}//end class +method(name: $value) + * - self::method(name: $value) / static::method(name: $value) + * - parent::method(name: $value) + * - new OurClass(name: $value) — classes from the same app namespace + * + * Allows positional arguments for external code: + * - PHP built-in functions (strlen, array_map, sprintf, etc.) + * - Nextcloud/third-party method calls ($variable->method() where $variable !== $this) + * - Any call we cannot determine is "our code" + * + * @package CustomSniffs + * @author Conduction b.v. + * @copyright 2026 Conduction b.v. + * @license https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 EUPL-1.2 + */ + +namespace CustomSniffs\Sniffs\Functions; + +use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Files\File; + +/** + * NamedParametersSniff — enforces named parameters for internal code. + */ +class NamedParametersSniff implements Sniff +{ + + + /** + * Returns tokens this sniff listens for. + * + * @return array + */ + public function register(): array + { + return [T_STRING]; + + }//end register() + + + /** + * Process a T_STRING token — check if it's a function/method call to our code. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_STRING token. + * + * @return void + */ + public function process(File $phpcsFile, $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + $functionName = $tokens[$stackPtr]['content']; + + // Must be followed by ( to be a function/method call. + $openParen = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true); + if ($openParen === false || $tokens[$openParen]['code'] !== T_OPEN_PARENTHESIS) { + return; + } + + // Skip function/method definitions. + if ($this->isFunctionDefinition(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === true) { + return; + } + + // Only check calls to our own code. + if ($this->isInternalCall(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === false) { + return; + } + + // Get the closing parenthesis. + if (isset($tokens[$openParen]['parenthesis_closer']) === false) { + return; + } + + $closeParen = $tokens[$openParen]['parenthesis_closer']; + + // Check if there are any arguments. + $firstContent = $phpcsFile->findNext( + types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], + start: ($openParen + 1), + end: $closeParen, + exclude: true + ); + if ($firstContent === false) { + return; + } + + // Check if all arguments use named parameters. + if ($this->hasUnnamedArguments(phpcsFile: $phpcsFile, openParen: $openParen, closeParen: $closeParen) === true) { + $error = 'All arguments in calls to internal code must use named parameters: %s(paramName: $value)'; + $phpcsFile->addError($error, $stackPtr, 'RequireNamedParameters', [$functionName]); + } + + }//end process() + + + /** + * Check if the T_STRING at $stackPtr is part of a function/method definition (not a call). + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_STRING token. + * + * @return bool True if this is a definition, false if it's a call. + */ + private function isFunctionDefinition(File $phpcsFile, int $stackPtr): bool + { + $tokens = $phpcsFile->getTokens(); + $prev = ($stackPtr - 1); + while ($prev >= 0) { + $code = $tokens[$prev]['code']; + if ($code === T_FUNCTION) { + return true; + } + + // Stop at statement/block boundaries. + if ($code === T_SEMICOLON + || $code === T_OPEN_CURLY_BRACKET + || $code === T_CLOSE_CURLY_BRACKET + ) { + return false; + } + + $prev--; + } + + return false; + + }//end isFunctionDefinition() + + + /** + * Determine if the function/method call at $stackPtr is to our own code. + * + * Matches: + * - $this->method() + * - self::method() / static::method() / parent::method() + * - new OurClass() where OurClass is from the same app namespace + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_STRING (function/method name). + * + * @return bool True if call is to internal code. + */ + private function isInternalCall(File $phpcsFile, int $stackPtr): bool + { + $tokens = $phpcsFile->getTokens(); + + $prev = $phpcsFile->findPrevious( + types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], + start: ($stackPtr - 1), + end: null, + exclude: true + ); + if ($prev === false) { + return false; + } + + $prevCode = $tokens[$prev]['code']; + + // Case 1: $this->method(). + if ($prevCode === T_OBJECT_OPERATOR) { + $beforeArrow = $phpcsFile->findPrevious( + types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], + start: ($prev - 1), + end: null, + exclude: true + ); + return ($beforeArrow !== false + && $tokens[$beforeArrow]['code'] === T_VARIABLE + && $tokens[$beforeArrow]['content'] === '$this'); + } + + // Case 2: self::method() / static::method() / parent::method(). + if ($prevCode === T_DOUBLE_COLON) { + $beforeColon = $phpcsFile->findPrevious( + types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], + start: ($prev - 1), + end: null, + exclude: true + ); + return ($beforeColon !== false + && in_array($tokens[$beforeColon]['code'], [T_SELF, T_STATIC, T_PARENT], true) === true); + } + + // Case 3: new OurClass(). + if ($prevCode === T_NEW) { + return $this->isClassFromOurNamespace(phpcsFile: $phpcsFile, classNamePtr: $stackPtr); + } + + return false; + + }//end isInternalCall() + + + /** + * Check if the class at $classNamePtr is from the same app namespace as the current file. + * + * Compares the class's use-import path against the file's OCA\AppName\ prefix. + * + * @param File $phpcsFile The file being scanned. + * @param int $classNamePtr Position of the class name token. + * + * @return bool True if the class is from our app namespace. + */ + private function isClassFromOurNamespace(File $phpcsFile, int $classNamePtr): bool + { + $tokens = $phpcsFile->getTokens(); + $className = $tokens[$classNamePtr]['content']; + + // Find the file's namespace declaration. + $appPrefix = $this->getAppNamespacePrefix(phpcsFile: $phpcsFile); + if ($appPrefix === null) { + return false; + } + + // Scan use-statements for an import matching this class name from our namespace. + for ($i = 0; $i < $phpcsFile->numTokens; $i++) { + if ($tokens[$i]['code'] === T_USE) { + $usePath = $this->getUseStatementPath(phpcsFile: $phpcsFile, usePtr: $i); + if ($usePath === null) { + continue; + } + + // Extract the imported short name (last segment). + $segments = explode(separator: '\\', string: $usePath); + $importedName = end($segments); + + if ($importedName === $className + && str_starts_with(haystack: $usePath, needle: $appPrefix) === true + ) { + return true; + } + }//end if + + // Stop scanning after the class declaration. + if (in_array($tokens[$i]['code'], [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true) === true) { + break; + } + }//end for + + return false; + + }//end isClassFromOurNamespace() + + + /** + * Get the app namespace prefix (e.g., "OCA\LaunchPad") from the file's namespace declaration. + * + * @param File $phpcsFile The file being scanned. + * + * @return string|null The app prefix or null if not found. + */ + private function getAppNamespacePrefix(File $phpcsFile): ?string + { + $tokens = $phpcsFile->getTokens(); + for ($i = 0; $i < $phpcsFile->numTokens; $i++) { + if ($tokens[$i]['code'] === T_NAMESPACE) { + $namespace = ''; + $ptr = ($i + 1); + while ($ptr < $phpcsFile->numTokens && $tokens[$ptr]['code'] !== T_SEMICOLON) { + if ($tokens[$ptr]['code'] !== T_WHITESPACE) { + $namespace .= $tokens[$ptr]['content']; + } + + $ptr++; + } + + // Extract first two segments: OCA\AppName. + $parts = explode(separator: '\\', string: $namespace); + if (count($parts) >= 2) { + return $parts[0].'\\'.$parts[1]; + } + + return null; + }//end if + }//end for + + return null; + + }//end getAppNamespacePrefix() + + + /** + * Extract the full path from a use-statement starting at $usePtr. + * + * @param File $phpcsFile The file being scanned. + * @param int $usePtr Position of the T_USE token. + * + * @return string|null The use path or null if parsing failed. + */ + private function getUseStatementPath(File $phpcsFile, int $usePtr): ?string + { + $tokens = $phpcsFile->getTokens(); + $usePath = ''; + $ptr = ($usePtr + 1); + while ($ptr < $phpcsFile->numTokens) { + $code = $tokens[$ptr]['code']; + if ($code === T_SEMICOLON || $code === T_OPEN_CURLY_BRACKET) { + break; + } + + // Stop at 'as' keyword (aliases) — use the path before it. + if ($code === T_AS) { + break; + } + + if ($code !== T_WHITESPACE) { + $usePath .= $tokens[$ptr]['content']; + } + + $ptr++; + } + + $usePath = trim(string: $usePath); + return ($usePath !== '') ? $usePath : null; + + }//end getUseStatementPath() + + + /** + * Check if the arguments between $openParen and $closeParen contain any unnamed arguments. + * + * An argument is "named" if its first significant token is followed by T_COLON. + * Handles nested parentheses, brackets, and braces correctly. + * + * @param File $phpcsFile The file being scanned. + * @param int $openParen Position of the opening parenthesis. + * @param int $closeParen Position of the closing parenthesis. + * + * @return bool True if any argument is positional (unnamed). + */ + private function hasUnnamedArguments(File $phpcsFile, int $openParen, int $closeParen): bool + { + $tokens = $phpcsFile->getTokens(); + $parenDepth = 0; + $bracketDepth = 0; + $braceDepth = 0; + $atArgumentStart = true; + + for ($i = ($openParen + 1); $i < $closeParen; $i++) { + $code = $tokens[$i]['code']; + + // Track nesting depth. + if ($code === T_OPEN_PARENTHESIS) { + $parenDepth++; + } elseif ($code === T_CLOSE_PARENTHESIS) { + $parenDepth--; + } elseif ($code === T_OPEN_SHORT_ARRAY || $code === T_OPEN_SQUARE_BRACKET) { + $bracketDepth++; + } elseif ($code === T_CLOSE_SHORT_ARRAY || $code === T_CLOSE_SQUARE_BRACKET) { + $bracketDepth--; + } elseif ($code === T_OPEN_CURLY_BRACKET) { + $braceDepth++; + } elseif ($code === T_CLOSE_CURLY_BRACKET) { + $braceDepth--; + } + + // Only examine tokens at the top level of the argument list. + if ($parenDepth > 0 || $bracketDepth > 0 || $braceDepth > 0) { + continue; + } + + // Comma at top level → next argument starts. + if ($code === T_COMMA) { + $atArgumentStart = true; + continue; + } + + // Skip whitespace and comments at argument start. + if ($atArgumentStart === true + && in_array($code, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true) === true + ) { + continue; + } + + if ($atArgumentStart === true) { + $atArgumentStart = false; + + // Skip spread operator (...$args). + if ($code === T_ELLIPSIS) { + continue; + } + + // Check if this argument is named: token followed by ':'. + $nextNonWs = $phpcsFile->findNext( + types: [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], + start: ($i + 1), + end: $closeParen, + exclude: true + ); + + $isNamed = ($nextNonWs !== false && $tokens[$nextNonWs]['code'] === T_COLON); + + if ($isNamed === false) { + return true; + } + }//end if + }//end for + + return false; + + }//end hasUnnamedArguments() + + +}//end class diff --git a/phpcs.xml b/phpcs.xml index de725ae..efb9f39 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -2,7 +2,12 @@ Coding standard for Nextcloud wrapper apps. - lib + + phpcs-custom-sniffs */vendor/* */node_modules/* diff --git a/phpmd.baseline.xml b/phpmd.baseline.xml new file mode 100644 index 0000000..11a721f --- /dev/null +++ b/phpmd.baseline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6250e2a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,17 @@ +parameters: + level: 5 + # This app is a Python ExApp sidecar wrapper and ships no PHP under lib/ + # (that directory does not exist). The only PHP we author is the custom + # PHPCS sniff, so that is the analysis target. Add `- lib` here the moment + # any PHP app code lands in this repo. + paths: + - phpcs-custom-sniffs + excludePaths: + - vendor + # Supplies OCP/NCU and PHP_CodeSniffer symbols, which their packages do not + # expose through Composer autoloading. See analysis-bootstrap.php. + bootstrapFiles: + - analysis-bootstrap.php + scanDirectories: + - vendor/nextcloud/ocp + reportUnmatchedIgnoredErrors: false diff --git a/psalm.xml b/psalm.xml index 7c25db5..bfd26f6 100644 --- a/psalm.xml +++ b/psalm.xml @@ -7,9 +7,16 @@ findUnusedBaselineEntry="true" findUnusedCode="true" findUnusedVariablesAndParams="true" + autoloader="analysis-bootstrap.php" > + - + @@ -92,5 +99,13 @@ + +