diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 4366382..dc28480 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -242,10 +242,9 @@ public function loadConfiguration(bool $force=false): array $appPath = (string) $this->appManager->getAppPath(Application::APP_ID); $absolute = $appPath.'/lib/Settings/deskdesk_register.json'; $ncRoot = \OC::$SERVERROOT; + $relative = ltrim($absolute, '/'); if (str_starts_with($absolute, $ncRoot.'/') === true) { $relative = substr($absolute, strlen($ncRoot) + 1); - } else { - $relative = ltrim($absolute, '/'); } $version = '0.2.0'; diff --git a/phpcs-custom-sniffs/CustomSniffs/Sniffs/Commenting/SpecTagSniff.php b/phpcs-custom-sniffs/CustomSniffs/Sniffs/Commenting/SpecTagSniff.php new file mode 100644 index 0000000..ae6ad7e --- /dev/null +++ b/phpcs-custom-sniffs/CustomSniffs/Sniffs/Commenting/SpecTagSniff.php @@ -0,0 +1,377 @@ + + */ + private const MAGIC_METHODS = [ + '__construct', + '__destruct', + '__get', + '__set', + '__call', + '__callstatic', + '__isset', + '__unset', + '__tostring', + '__invoke', + '__clone', + '__sleep', + '__wakeup', + '__serialize', + '__unserialize', + '__set_state', + '__debuginfo', + ]; + + + /** + * Returns tokens this sniff listens for. + * + * @return array + */ + public function register(): array + { + return [T_CLASS, T_FUNCTION]; + + }//end register() + + + /** + * Process a T_CLASS or T_FUNCTION token. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the token. + * + * @return void + */ + public function process(File $phpcsFile, $stackPtr): void + { + // Skip test files. + if ($this->isTestFile(phpcsFile: $phpcsFile) === true) { + return; + } + + $tokens = $phpcsFile->getTokens(); + $code = $tokens[$stackPtr]['code']; + + if ($code === T_CLASS) { + $this->processClass(phpcsFile: $phpcsFile, stackPtr: $stackPtr); + return; + } + + if ($code === T_FUNCTION) { + $this->processFunction(phpcsFile: $phpcsFile, stackPtr: $stackPtr); + return; + } + + }//end process() + + + /** + * Check a class declaration for an @spec docblock tag. + * + * Skips anonymous classes (no name follows the T_CLASS keyword). + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_CLASS token. + * + * @return void + */ + private function processClass(File $phpcsFile, int $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + + // Anonymous classes — $var = new class { ... } — have no name; skip. + $namePtr = $phpcsFile->findNext(T_STRING, ($stackPtr + 1), null, false, null, true); + if ($namePtr === false) { + return; + } + + // Sanity: name should be on the same line or within a short window. + $openBracePtr = $phpcsFile->findNext(T_OPEN_CURLY_BRACKET, ($stackPtr + 1)); + if ($openBracePtr !== false && $namePtr > $openBracePtr) { + return; + } + + $className = $tokens[$namePtr]['content']; + + if ($this->hasSpecTag(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === true) { + return; + } + + $message = 'Class %s is missing @spec PHPDoc tag — link back to openspec/changes/{name}/tasks.md#task-N'; + $phpcsFile->addWarning($message, $stackPtr, 'MissingClassSpec', [$className]); + + }//end processClass() + + + /** + * Check a function declaration for an @spec docblock tag. + * + * Only flags public methods declared inside a class. Global functions, + * private/protected methods, and magic methods are skipped. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_FUNCTION token. + * + * @return void + */ + private function processFunction(File $phpcsFile, int $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + + // Must be inside a class scope. + $className = $this->getEnclosingClassName(phpcsFile: $phpcsFile, stackPtr: $stackPtr); + if ($className === null) { + return; + } + + // Get method name. + $namePtr = $phpcsFile->findNext(T_STRING, ($stackPtr + 1)); + if ($namePtr === false) { + return; + } + + $methodName = $tokens[$namePtr]['content']; + + // Skip magic methods. + if (in_array(strtolower($methodName), self::MAGIC_METHODS, true) === true) { + return; + } + + // Determine visibility: default is public when no modifier present. + if ($this->isPublicMethod(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === false) { + return; + } + + if ($this->hasSpecTag(phpcsFile: $phpcsFile, stackPtr: $stackPtr) === true) { + return; + } + + $message = 'Public method %s::%s() is missing @spec PHPDoc tag'; + $phpcsFile->addWarning($message, $stackPtr, 'MissingMethodSpec', [$className, $methodName]); + + }//end processFunction() + + + /** + * Check whether the docblock directly preceding $stackPtr contains an @spec tag. + * + * Walks backwards from the token skipping whitespace, attribute tokens, and + * visibility/abstract/final/static modifiers. If the next non-skipped token + * is the close of a doc comment, scan the block for @spec. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the class/function token. + * + * @return bool True when an @spec tag is present. + */ + private function hasSpecTag(File $phpcsFile, int $stackPtr): bool + { + $tokens = $phpcsFile->getTokens(); + + $skip = [ + T_WHITESPACE, + T_ABSTRACT, + T_FINAL, + T_STATIC, + T_PUBLIC, + T_PROTECTED, + T_PRIVATE, + T_READONLY, + T_ATTRIBUTE, + T_ATTRIBUTE_END, + ]; + + $ptr = ($stackPtr - 1); + while ($ptr >= 0) { + $code = $tokens[$ptr]['code']; + + // Skip over attribute blocks (PHP 8 #[Attribute]) in full. + if ($code === T_ATTRIBUTE_END && isset($tokens[$ptr]['attribute_opener']) === true) { + $ptr = ($tokens[$ptr]['attribute_opener'] - 1); + continue; + } + + if (in_array($code, $skip, true) === true) { + $ptr--; + continue; + } + + break; + } + + if ($ptr < 0) { + return false; + } + + if ($tokens[$ptr]['code'] !== T_DOC_COMMENT_CLOSE_TAG) { + return false; + } + + if (isset($tokens[$ptr]['comment_opener']) === false) { + return false; + } + + $opener = $tokens[$ptr]['comment_opener']; + for ($i = $opener; $i <= $ptr; $i++) { + if ($tokens[$i]['code'] === T_DOC_COMMENT_TAG + && strtolower($tokens[$i]['content']) === '@spec' + ) { + return true; + } + } + + return false; + + }//end hasSpecTag() + + + /** + * Determine if the function at $stackPtr is a public method. + * + * Methods default to public when no visibility modifier is present. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_FUNCTION token. + * + * @return bool True when the method is public (explicit or default). + */ + private function isPublicMethod(File $phpcsFile, int $stackPtr): bool + { + $tokens = $phpcsFile->getTokens(); + + $ptr = ($stackPtr - 1); + while ($ptr >= 0) { + $code = $tokens[$ptr]['code']; + if ($code === T_PUBLIC) { + return true; + } + + if ($code === T_PROTECTED || $code === T_PRIVATE) { + return false; + } + + if ($code === T_WHITESPACE + || $code === T_ABSTRACT + || $code === T_FINAL + || $code === T_STATIC + || $code === T_READONLY + ) { + $ptr--; + continue; + } + + // Skip attributes in full. + if ($code === T_ATTRIBUTE_END && isset($tokens[$ptr]['attribute_opener']) === true) { + $ptr = ($tokens[$ptr]['attribute_opener'] - 1); + continue; + } + + if ($code === T_DOC_COMMENT_CLOSE_TAG + || $code === T_COMMENT + || $code === T_OPEN_CURLY_BRACKET + || $code === T_CLOSE_CURLY_BRACKET + || $code === T_SEMICOLON + ) { + // No visibility modifier found — default public. + return true; + } + + $ptr--; + } + + return true; + + }//end isPublicMethod() + + + /** + * Return the name of the class/interface/trait/enum enclosing $stackPtr, or null. + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the token to inspect. + * + * @return string|null The enclosing class name, or null when at file scope. + */ + private function getEnclosingClassName(File $phpcsFile, int $stackPtr): ?string + { + $tokens = $phpcsFile->getTokens(); + + if (isset($tokens[$stackPtr]['conditions']) === false) { + return null; + } + + // Walk the conditions chain looking for the innermost class-like scope. + $classLike = [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM, T_ANON_CLASS]; + + foreach (array_reverse($tokens[$stackPtr]['conditions'], true) as $scopePtr => $scopeCode) { + if (in_array($scopeCode, $classLike, true) === true) { + $namePtr = $phpcsFile->findNext(T_STRING, ($scopePtr + 1)); + if ($namePtr === false) { + return '{anonymous}'; + } + + // Sanity: ensure the name is before the opening brace for that class. + if (isset($tokens[$scopePtr]['scope_opener']) === true + && $namePtr > $tokens[$scopePtr]['scope_opener'] + ) { + return '{anonymous}'; + } + + return $tokens[$namePtr]['content']; + } + } + + return null; + + }//end getEnclosingClassName() + + + /** + * Check whether the currently-scanned file is a test file. + * + * @param File $phpcsFile The file being scanned. + * + * @return bool True for files under /tests/ or /Tests/. + */ + private function isTestFile(File $phpcsFile): bool + { + $path = str_replace('\\', '/', $phpcsFile->getFilename()); + return (stripos($path, '/tests/') !== false); + + }//end isTestFile() + + +}//end class diff --git a/phpcs-custom-sniffs/CustomSniffs/Sniffs/Nextcloud/NoLegacyServerAccessorsSniff.php b/phpcs-custom-sniffs/CustomSniffs/Sniffs/Nextcloud/NoLegacyServerAccessorsSniff.php new file mode 100644 index 0000000..7697eae --- /dev/null +++ b/phpcs-custom-sniffs/CustomSniffs/Sniffs/Nextcloud/NoLegacyServerAccessorsSniff.php @@ -0,0 +1,180 @@ +getDatabaseConnection() + * \OC::$server->getSystemConfig() + * \OC::$server->getLogger() + * + * These named accessors were removed in Nextcloud 34. The replacement pattern + * is constructor dependency injection of the equivalent OCP interface. + * + * PSR-11 lookups such as \OC::$server->get(SomeClass::class) are NOT flagged + * here; service-locator deprecation is tracked separately (design.md, D4). + * + * @author Conduction + * @package CustomSniffs + */ + +namespace CustomSniffs\Sniffs\Nextcloud; + +use PHP_CodeSniffer\Sniffs\Sniff; +use PHP_CodeSniffer\Files\File; + +/** + * NoLegacyServerAccessorsSniff — forbids removed \OC::$server->getX() accessors. + */ +class NoLegacyServerAccessorsSniff implements Sniff +{ + + + /** + * Map of known named accessors to their approved OCP replacement interface. + * + * Covers the accessors that still appeared in this codebase plus the most + * frequently used Nextcloud 34 removals. The error message interpolates the + * accessor name and the replacement from this table so engineers see the + * intended DI target at the violation site. + * + * @var array + */ + private const REPLACEMENTS = [ + 'getSystemConfig' => '\OCP\IConfig', + 'getConfig' => '\OCP\IConfig', + 'getDatabaseConnection' => '\OCP\IDBConnection', + 'getLogger' => '\Psr\Log\LoggerInterface', + 'getL10NFactory' => '\OCP\L10N\IFactory', + 'getL10N' => '\OCP\IL10N (via \OCP\L10N\IFactory)', + 'getUserSession' => '\OCP\IUserSession', + 'getUserManager' => '\OCP\IUserManager', + 'getGroupManager' => '\OCP\IGroupManager', + 'getURLGenerator' => '\OCP\IURLGenerator', + 'getRequest' => '\OCP\IRequest', + 'getRootFolder' => '\OCP\Files\IRootFolder', + 'getAppManager' => '\OCP\App\IAppManager', + 'getSession' => '\OCP\ISession', + 'getMemCacheFactory' => '\OCP\ICacheFactory', + 'getEventDispatcher' => '\OCP\EventDispatcher\IEventDispatcher', + 'getNotificationManager' => '\OCP\Notification\IManager', + 'getTempManager' => '\OCP\ITempManager', + 'getMimeTypeDetector' => '\OCP\Files\IMimeTypeDetector', + 'getMimeTypeLoader' => '\OCP\Files\IMimeTypeLoader', + 'getActivityManager' => '\OCP\Activity\IManager', + 'getDateTimeFormatter' => '\OCP\IDateTimeFormatter', + 'getDateTimeZone' => '\OCP\IDateTimeZone', + 'getTrustedDomainHelper' => '\OCP\Security\ITrustedDomainHelper', + 'getRegisteredAppContainer' => 'explicit constructor injection of the specific service', + ]; + + /** + * Returns tokens this sniff listens for. + * + * Anchors on T_DOUBLE_COLON so we can reconstruct the full pattern + * \OC :: $server -> getX ( in a single process() call. + * + * @return array + */ + public function register(): array + { + return [T_DOUBLE_COLON]; + + }//end register() + + /** + * Process a T_DOUBLE_COLON token — flag if part of \OC::$server->getX(). + * + * @param File $phpcsFile The file being scanned. + * @param int $stackPtr Position of the T_DOUBLE_COLON token. + * + * @return void + */ + public function process(File $phpcsFile, $stackPtr): void + { + $tokens = $phpcsFile->getTokens(); + + // Previous non-whitespace token must be T_STRING "OC". + $prev = $phpcsFile->findPrevious( + types: [T_WHITESPACE], + start: ($stackPtr - 1), + end: null, + exclude: true + ); + if ($prev === false + || $tokens[$prev]['code'] !== T_STRING + || $tokens[$prev]['content'] !== 'OC' + ) { + return; + } + + // Next non-whitespace token must be T_VARIABLE "$server". + $afterColon = $phpcsFile->findNext( + types: [T_WHITESPACE], + start: ($stackPtr + 1), + end: null, + exclude: true + ); + if ($afterColon === false + || $tokens[$afterColon]['code'] !== T_VARIABLE + || $tokens[$afterColon]['content'] !== '$server' + ) { + return; + } + + // Expect T_OBJECT_OPERATOR '->'. + $arrow = $phpcsFile->findNext( + types: [T_WHITESPACE], + start: ($afterColon + 1), + end: null, + exclude: true + ); + if ($arrow === false || $tokens[$arrow]['code'] !== T_OBJECT_OPERATOR) { + return; + } + + // Expect T_STRING method name. + $methodPtr = $phpcsFile->findNext( + types: [T_WHITESPACE], + start: ($arrow + 1), + end: null, + exclude: true + ); + if ($methodPtr === false || $tokens[$methodPtr]['code'] !== T_STRING) { + return; + } + + // Must be followed by ( to be a call. + $openParen = $phpcsFile->findNext( + types: [T_WHITESPACE], + start: ($methodPtr + 1), + end: null, + exclude: true + ); + if ($openParen === false || $tokens[$openParen]['code'] !== T_OPEN_PARENTHESIS) { + return; + } + + $methodName = $tokens[$methodPtr]['content']; + + // PSR-11 ->get(...) is deferred (D4 in design.md) — not flagged here. + if ($methodName === 'get') { + return; + } + + // Only flag named accessors: getX where X starts with an uppercase letter. + if (preg_match(pattern: '/^get[A-Z]/', subject: $methodName) !== 1) { + return; + } + + $replacement = self::REPLACEMENTS[$methodName] ?? 'the corresponding OCP interface'; + + $error = 'Named accessor \\OC::$server->%s() is removed in Nextcloud 34. Inject %s via the constructor instead.'; + $phpcsFile->addError( + $error, + $stackPtr, + 'LegacyNamedAccessor', + [$methodName, $replacement] + ); + + }//end process() +}//end class diff --git a/phpcs.xml b/phpcs.xml index e1787a1..15e3578 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,13 +1,20 @@ - Coding standard for AppTemplate, based on the Conduction/OpenRegister standard. + Coding standard for Deskdesk, based on the Conduction/OpenRegister standard. lib */vendor/* + */vendor-bin/* */node_modules/* composer-setup.php + + lib/Resources/template/* + + + @@ -213,4 +220,15 @@ error + + + error + + + + + warning + + diff --git a/phpmd.xml b/phpmd.xml index 9c7ca02..18575be 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -1,12 +1,12 @@ - - This is a custom ruleset for AppTemplate Nextcloud. + This is a custom ruleset for Deskdesk Nextcloud. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..7743d0d --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,9 @@ +# phpstan baseline generated during Phase 2 canonical root-config sync. +# Each entry tracked in https://github.com/ConductionNL/deskdesk/issues/32 — +# do NOT add new entries here without a checkbox in that issue. +parameters: + ignoreErrors: + - + message: "#^Access to static property \\$SERVERROOT on an unknown class OC\\.$#" + count: 1 + path: lib/Service/SettingsService.php diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php index 4c32b41..b473c89 100644 --- a/phpstan-bootstrap.php +++ b/phpstan-bootstrap.php @@ -1,5 +1,4 @@ + + + + + + + + + + + + + + + + + + + +