-
tests/lint/check-architecture.php<pre>@@ -35,9 +35,11 @@* isAccessible() only checks `userID > 0`, it has to check access to the parent object* - link-application: `{link controller='X'}` in templates/ of an application without `application=` for a controller* (Page/Form/Action) of the plugin itself, it resolves against wcf- * - sql-concat: `->query()`/`->prepare()` with a string built by concatenation (literal `.` $var or a variable that- * was built with `.=`), instead of `->prepareStatement()` with bound parameters. Same risk as classic- * SQL injection regardless of where the value comes from (Marcel 21.10.2026, Sitzung). `query()` on a+ * - sql-concat: `->query()`/`->prepare()`/`->prepareStatement()`/`->prepareUnmanaged()` with a string built by+ * concatenation (literal `.` $var or a variable that was built with `.=`) instead of a literal SQL+ * string with `?` placeholders bound through execute(). Same risk as classic SQL injection regardless+ * of where the value comes from (Marcel 21.10.2026, Sitzung). Use `prepare()`: prepareStatement() is+ * deprecated in 6.2 and, like prepareUnmanaged(), does not resolve `app1_` table names. `query()` on a* DOMXPath variable is XPath, not SQL, and is skipped** Rules that need the types of PHPStan are PHPStan rules (tests/phpstan): getPermission() compared with true/false@@ -113,25 +115,25 @@$report($relative, 'endpoint-validate-action', 'RPC endpoint calls validateAction(), call the check directly (e.g. validateDelete()) or check in the endpoint/command');}- // ->query()/->prepare() (not the parameter-bound ->prepareStatement() wrapper) with a string built by- // concatenation: same risk as classic SQL injection, whether or not the value comes from user input+ // SQL built by concatenation instead of `?` placeholders: same risk as classic SQL injection, whether or not the+ // value comes from user input\preg_match_all('~\$(\w+)\s*(?:\.=|=\s*(?:\$\1\s*)?\.)~', $source, $concatMatches);$concatenatedVars = \array_unique($concatMatches[1]);// DOMXPath::query() takes an XPath expression, not SQL\preg_match_all('~\bDOMXPath\s+\$(\w+)|\$(\w+)\s*=\s*new\s+\\\\?DOMXPath\b~', $source, $xpathMatches);$xpathVars = \array_filter([...$xpathMatches[1], ...$xpathMatches[2]]);- \preg_match_all('~(?:\$(\w+))?->(query|prepare)\s*\(\s*([^)]*)~', $source, $sqlCalls, \PREG_SET_ORDER);+ \preg_match_all('~(?:\$(\w+))?->(query|prepare|prepareStatement|prepareUnmanaged)\s*\(\s*([^)]*)~', $source, $sqlCalls, \PREG_SET_ORDER);foreach ($sqlCalls as $call) {[, $receiver, $method, $argument] = $call;if ($method === 'query' && \in_array($receiver, $xpathVars, true)) {continue;}if (\str_contains($argument, '.')) {- $report($relative, 'sql-concat', "->{$method}() with inline string concatenation, use ->prepareStatement() with bound parameters");+ $report($relative, 'sql-concat', "->{$method}() with inline string concatenation, use ->prepare() with a literal SQL string and ? placeholders");continue;}if (\preg_match('~^\$(\w+)~', \trim($argument), $variable) && \in_array($variable[1], $concatenatedVars, true)) {- $report($relative, 'sql-concat', "->{$method}(\${$variable[1]}) where \${$variable[1]} is built by concatenation, use ->prepareStatement() with bound parameters");+ $report($relative, 'sql-concat', "->{$method}(\${$variable[1]}) where \${$variable[1]} is built by concatenation, use ->prepare() with a literal SQL string and ? placeholders");}}
-
tests/lint/check-comments.php<pre>@@ -37,13 +37,29 @@$messages["{$file}|{$rule}"] ??= "{$file}: [{$rule}] {$message}";};-/** Strips comment delimiters and asterisks, returns the descriptive lines (without PHPStan/TSDoc tags). */+/**+ * Strips comment delimiters and asterisks, returns the descriptive lines: without PHPStan/TSDoc tags and the lines that+ * continue a tag up to the next empty line (e.g. a multi-line array shape of @return), like OverrideOrDocblockRule.+ */$descriptive = static function (string $text): array {- $lines = \preg_split('~\R~', $text) ?: [];- $lines = \array_map(static fn (string $line): string => \trim($line, " \t*/"), $lines);- $lines = \array_values(\array_filter($lines, static fn (string $line): bool => $line !== ''));+ $descriptive = [];+ $inTag = false;+ foreach (\preg_split('~\R~', $text) ?: [] as $line) {+ $line = \trim($line, " \t*/");+ if ($line === '') {+ $inTag = false;+ continue;+ }+ if (\preg_match('~^\{?@[a-zA-Z]~', $line)) {+ $inTag = true;+ continue;+ }+ if (!$inTag) {+ $descriptive[] = $line;+ }+ }- return \array_values(\array_filter($lines, static fn (string $line): bool => !\preg_match('~^@[a-zA-Z]~', $line)));+ return $descriptive;};/** Returns null if the descriptive text of a comment is at most one line. */@@ -165,7 +181,17 @@while (\is_array($tokens[$k] ?? null) && $tokens[$k][0] === \T_WHITESPACE) {$k++;}+ if (($tokens[$k] ?? null) === '&') {+ $k++;+ while (\is_array($tokens[$k] ?? null) && $tokens[$k][0] === \T_WHITESPACE) {+ $k++;+ }+ }$name = \is_array($tokens[$k] ?? null) ? $tokens[$k][1] : '';+ // a closure has no name and cannot carry a docblock+ if ($name === '') {+ continue;+ }$onlyInheritDoc = $comment !== null && \str_contains($comment, '@inheritDoc') && $descriptive($comment) === [];// with #[\Override] the PHPStan rule yourecom.method.inheritDoc forbids the @inheritDoc docblockif (\str_starts_with($name, '__') && !$hasOverride) {