-
phpunit.xml<pre>@@ -0,0 +1,19 @@+<?xml version="1.0" encoding="UTF-8"?>+<!-- Unit tests without a WoltLab installation (also in the CI). -->+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"+ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"+ bootstrap="tests/bootstrap.php"+ colors="true"+ cacheDirectory=".phpunit.cache"+ failOnEmptyTestSuite="true">+ <testsuites>+ <testsuite name="unit">+ <directory>tests/Unit</directory>+ </testsuite>+ </testsuites>+ <source>+ <include>+ <directory>files/lib</directory>+ </include>+ </source>+</phpunit>
-
tests/Integration/bootstrap.php<pre>@@ -0,0 +1,21 @@+<?php++/**+ * Entry point of the integration suite. Loads the local WoltLab Suite installation, the tests use the plugin code+ * installed there (deploy first), not the files of this repository.+ */++$wcfDir = \getenv('WCF_DIR');+$wcfDir = \rtrim(\is_string($wcfDir) && $wcfDir !== '' ? $wcfDir : 'C:/laragon/www/wsc6.2', '/\\');+if (!\is_file($wcfDir . '/global.php')) {+ \fwrite(\STDERR, "No WoltLab Suite installation found in '{$wcfDir}', set WCF_DIR.\n");++ exit(1);+}++$_SERVER['HTTP_HOST'] = 'localhost';+$_SERVER['SERVER_NAME'] = 'localhost';+$_SERVER['REQUEST_METHOD'] = 'GET';+require $wcfDir . '/global.php';++require_once __DIR__ . '/../support/IntegrationTestCase.php';
-
tests/Unit/.gitkeep
-
tests/Unit/BundleTest.php<pre>@@ -0,0 +1,31 @@+<?php++namespace yourecom\tests\mpdf;++use PHPUnit\Framework\TestCase;++final class BundleTest extends TestCase+{+ private const BUNDLE = __DIR__ . '/../../files/lib/system/api/mpdf';++ public function testComposerJsonRequiresMpdf(): void+ {+ $composer = \json_decode((string)\file_get_contents(self::BUNDLE . '/composer.json'), true, 512, \JSON_THROW_ON_ERROR);++ self::assertIsArray($composer);+ self::assertIsArray($composer['require'] ?? null);+ self::assertArrayHasKey('mpdf/mpdf', $composer['require']);+ }++ public function testAutoloadLoadsTheComposerAutoloader(): void+ {+ $autoload = (string)\file_get_contents(self::BUNDLE . '/autoload.php');++ self::assertStringContainsString("/vendor/autoload.php", $autoload);+ }++ public function testLockFileIsCommitted(): void+ {+ self::assertFileExists(self::BUNDLE . '/composer.lock');+ }+}
-
tests/bootstrap.php<pre>@@ -0,0 +1,26 @@+<?php++/**+ * Entry point of the PHPUnit unit suite. The unit tests run without a WoltLab installation, so only the plugin's own+ * classes are loaded: `<namespace>\foo\Bar` => `<dir>/lib/foo/Bar.class.php`.+ */++// the plugin's own vendor (CI) or the shared tools of the plugins folder (local)+require \is_file(__DIR__ . '/../vendor/autoload.php') ? __DIR__ . '/../vendor/autoload.php' : __DIR__ . '/../../_dev/tools/vendor/autoload.php';++\spl_autoload_register(static function (string $class): void {+ $directories = ['Composer\\' => 'files', 'Mpdf\\' => 'files', 'DeepCopy\\' => 'files', 'Psr\\' => 'files', 'setasign\\' => 'files'];++ foreach ($directories as $prefix => $directory) {+ if (!\str_starts_with($class, $prefix)) {+ continue;+ }++ $file = __DIR__ . '/../' . $directory . '/lib/' . \str_replace('\\', '/', \substr($class, \strlen($prefix))) . '.class.php';+ if (\is_file($file)) {+ require_once $file;++ return;+ }+ }+});
-
tests/coverage-minimum.txt<pre>@@ -0,0 +1 @@+0
-
tests/lint/check-architecture.php<pre>@@ -0,0 +1,204 @@+<?php++/**+ * YoureCom Standard: architecture rules (close to the core of WoltLab Suite 6.2). Managed by _dev/standard (apply.php),+ * do not edit here.+ * - list-page: lists are AbstractGridViewPage (ACP) or AbstractListViewPage, not SortablePage/MultipleLinkPage+ * - object-action: no `{objectAction}`/`jsObjectActionContainer` in templates, actions are interactions with RPC endpoints+ * - clipboard: no clipboard actions, mass actions are bulk interactions of the grid/list view+ * - toggle-action: no IToggleAction/TDatabaseObjectToggle, enabling and disabling are RPC endpoints with a command+ * - ajax-method: no custom `validate*()` AJAX entry points in AbstractDatabaseObjectAction classes (RPC endpoints)+ * - endpoint-validate-action: RPC endpoints never call validateAction() (it rejects every name of `$requireACP`)+ * - require-acp: create/update/delete cannot be called through AJAX from the frontend: in `$requireACP` unless the+ * action denies them anyway (no `$permissions*` and no own `validate*()`)+ * - acp-menu: ACP menu items are registered with the ItemCollecting event (listener in the bootstrap), acpMenu.xml+ * only holds <delete> entries for removing old items+ * - cache-builder: caches are eager (AbstractEagerCache) or tolerant (AbstractTolerantCache) caches of 6.2, not+ * AbstractCacheBuilder/AbstractLegacyCacheBuilder+ * - legacy-form: forms use the form builder (AbstractFormBuilderForm), not AbstractForm/AbstractAcpForm+ * - listener-instanceof: event listener classes check `$eventObj` with instanceof+ * - legacy-listener: eventListener.xml imports legacy listeners, use PSR-14 events and listeners registered in the+ * bootstrap (keep only <delete>)+ * - legacy-ajax: TypeScript, JavaScript and templates call DatabaseObjectActions (`WoltLabSuite/Core/Ajax`,+ * `dboAction`, `className: "…\data\…Action"`, `WCF.Action.*`, `ajax-proxy`), use RPC endpoints+ *+ * Known findings that cannot be changed yet are listed in tests/lint/config.php (`architectureKnown`: relative file+ * => list of rules); entries that no longer match make the check fail, so the list can only shrink.+ *+ * Usage: php tests/lint/check-architecture.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++const LEGACY_LIST_PAGES = 'SortablePage|MultipleLinkPage|AbstractListPage';++/** calls of DatabaseObjectActions from TypeScript, JavaScript and templates (the AJAX API before RPC endpoints) */+const LEGACY_AJAX = [+ '~[\x22\x27]WoltLabSuite/Core/Ajax(?:/DboAction)?[\x22\x27]~' => 'WoltLabSuite/Core/Ajax',+ '~\b(?:define|require)\s*\(\s*\[[^\]]*[\x22\x27]Ajax[\x22\x27]~' => 'AMD module Ajax',+ '~\bdboAction\b~' => 'dboAction',+ '~[\x22\x27][a-z]\w*(?:\x5C{1,2}\w+)*\x5C{1,2}data\x5C{1,2}(?:\w+\x5C{1,2})*\w+Action[\x22\x27]~' => 'class name of a DatabaseObjectAction',+ '~\bWCF\.Action\.\w+~' => 'WCF.Action',+ '~ajax-proxy~' => 'ajax-proxy',+];++/** @var array<string, list<string>> $findings relative file => rules */+$findings = [];+/** @var array<string, string> $messages "file|rule" => message */+$messages = [];++$report = static function (string $file, string $rule, string $message) use (&$findings, &$messages): void {+ $findings[$file][] = $rule;+ $messages["{$file}|{$rule}"] ??= "{$file}: [{$rule}] {$message}";+};++foreach (files(['files', 'files_wcf'], ['.php']) as $relative => $path) {+ $source = (string)\file_get_contents($path);++ if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\page\\\\)?(' . LEGACY_LIST_PAGES . ')\b~', $source, $match)) {+ $report($relative, 'list-page', "extends {$match[1]}, use AbstractGridViewPage (ACP) or AbstractListViewPage with a grid/list view");+ }++ if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\system\\\\clipboard\\\\action\\\\)?AbstractClipboardAction\b~', $source)) {+ $report($relative, 'clipboard', 'clipboard action, use bulk interactions of the grid/list view');+ }++ if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\system\\\\cache\\\\builder\\\\)?(?:AbstractCacheBuilder|AbstractLegacyCacheBuilder|AbstractSortedUserCacheBuilder)\b~', $source)) {+ $report($relative, 'cache-builder', 'cache builder, use an eager (AbstractEagerCache) or tolerant (AbstractTolerantCache) cache');+ }++ if (\preg_match('~\bextends\s+\\\\?(?:wcf\\\\(?:acp\\\\)?form\\\\)?(AbstractForm|AbstractAcpForm|AbstractCaptchaForm)\b~', $source, $match)) {+ $report($relative, 'legacy-form', "extends {$match[1]}, use the form builder (AbstractFormBuilderForm)");+ }++ if (+ \preg_match('~\bimplements\b[^{]*\bIParameterizedEventListener\b|\bextends\s+\\\\?(?:wcf\\\\system\\\\event\\\\listener\\\\)?AbstractEventListener\b~', $source)+ && !\str_contains($source, 'instanceof')+ ) {+ $report($relative, 'listener-instanceof', 'event listener without an instanceof check of $eventObj');+ }++ // an RPC request is never an ACP request: validateAction() rejects every name in $requireACP there+ if (\str_contains($relative, '/system/endpoint/') && \preg_match('~->validateAction\(\)~', $source)) {+ $report($relative, 'endpoint-validate-action', 'RPC endpoint calls validateAction(), call the check directly (e.g. validateDelete()) or check in the endpoint/command');+ }++ if (!\preg_match('~\bextends\s+\\\\?(?:wcf\\\\data\\\\)?AbstractDatabaseObjectAction\b~', $source)) {+ continue;+ }++ if (\preg_match('~\bIToggleAction\b|\bTDatabaseObjectToggle\b~', $source)) {+ $report($relative, 'toggle-action', 'IToggleAction/TDatabaseObjectToggle, use enable/disable RPC endpoints with a command');+ }++ \preg_match_all('~public\s+function\s+(validate\w+)\s*\(~', $source, $matches);+ $custom = \array_diff($matches[1], ['validateCreate', 'validateUpdate', 'validateDelete']);+ if ($custom !== []) {+ $report($relative, 'ajax-method', 'AJAX entry points ' . \implode(', ', $custom) . '(), use RPC endpoints');+ }++ // AJAX (AJAXProxyAction -> validateAction()) reaches create/update/delete from the frontend only if the name is not+ // in $requireACP and the action can pass validate*(): with $permissions* set or an own validate*() method+ // (the core's validate*() deny everything without $permissions*); RPC endpoints never go through this check+ $requireAcp = [];+ if (\preg_match('~\$requireACP\s*=\s*\[([^\]]*)\]~', $source, $match)) {+ \preg_match_all('~[\'"](\w+)[\'"]~', $match[1], $names);+ $requireAcp = $names[1];+ }+ $reachable = [];+ foreach (['create', 'update', 'delete'] as $name) {+ if (\in_array($name, $requireAcp, true)) {+ continue;+ }+ $permissions = '~\$permissions' . \ucfirst($name) . '\s*=\s*\[\s*[\'"]~';+ $validator = '~function\s+validate' . \ucfirst($name) . '\s*\(~';+ if (\preg_match($permissions, $source) || \preg_match($validator, $source)) {+ $reachable[] = $name;+ }+ }+ if ($reachable !== []) {+ $report($relative, 'require-acp', 'the frontend can call ' . \implode(', ', $reachable) . ' through AJAX, add them to $requireACP (RPC endpoints call validate' . \ucfirst($reachable[0]) . '() directly)');+ }+}++foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {+ $source = (string)\file_get_contents($path);++ if (\preg_match('~\{objectAction\b|jsObjectActionContainer~', $source)) {+ $report($relative, 'object-action', '{objectAction}/jsObjectActionContainer, use interactions (quick interactions for toggles)');+ }++ if (\preg_match('~jsClipboard(?:Container|Item|Editor|MarkAll)|Controller/Clipboard~', $source)) {+ $report($relative, 'clipboard', 'clipboard markup, use bulk interactions of the grid/list view');+ }+}++$legacyAjax = static function (string $relative, string $source) use ($report): void {+ $found = [];+ foreach (LEGACY_AJAX as $pattern => $label) {+ if (\preg_match($pattern, $source)) {+ $found[] = $label;+ }+ }+ if ($found !== []) {+ $report($relative, 'legacy-ajax', 'legacy AJAX (' . \implode(', ', $found) . '), use RPC endpoints');+ }+};++foreach (files(['ts'], ['.ts']) as $relative => $path) {+ if (!\str_ends_with($relative, '.test.ts') && !\str_ends_with($relative, '.d.ts')) {+ $legacyAjax($relative, (string)\file_get_contents($path));+ }+}++// hand-written JavaScript; the committed build of ts/ is checked through its sources+$jsBuildDir = config()['jsBuildDir'];+foreach (files(['files', 'files_wcf'], ['.js']) as $relative => $path) {+ if (\str_ends_with($relative, '.min.js') || ($jsBuildDir !== null && \str_starts_with($relative, \rtrim($jsBuildDir, '/') . '/'))) {+ continue;+ }+ $legacyAjax($relative, (string)\file_get_contents($path));+}++// templates with {objectAction}/jsObjectActionContainer are already reported as object-action+foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {+ if (!\in_array('object-action', $findings[$relative] ?? [], true)) {+ $legacyAjax($relative, (string)\file_get_contents($path));+ }+}++$clipboardXml = ROOT . '/clipboardAction.xml';+if (\is_file($clipboardXml) && \str_contains((string)\file_get_contents($clipboardXml), '<import>')) {+ $report('clipboardAction.xml', 'clipboard', 'installs clipboard actions, use bulk interactions (keep only <delete>)');+}++$eventListenerXml = ROOT . '/eventListener.xml';+if (\is_file($eventListenerXml) && \str_contains((string)\file_get_contents($eventListenerXml), '<import>')) {+ $report('eventListener.xml', 'legacy-listener', 'imports event listeners, use PSR-14 events and listeners registered in the bootstrap (keep only <delete>)');+}++$acpMenuXml = ROOT . '/acpMenu.xml';+if (\is_file($acpMenuXml) && \str_contains((string)\file_get_contents($acpMenuXml), '<import>')) {+ $report('acpMenu.xml', 'acp-menu', 'imports ACP menu items, register them with the ItemCollecting event (keep only <delete>)');+}++$known = config()['architectureKnown'];+$errors = [];+foreach ($findings as $file => $rules) {+ foreach (\array_unique($rules) as $rule) {+ if (!\in_array($rule, $known[$file] ?? [], true)) {+ $errors[] = $messages["{$file}|{$rule}"];+ }+ }+}+foreach ($known as $file => $rules) {+ foreach ($rules as $rule) {+ if (!\in_array($rule, $findings[$file] ?? [], true)) {+ $errors[] = "tests/lint/config.php: architectureKnown {$file} [{$rule}] no longer applies, remove it";+ }+ }+}++finish($errors, \count($findings) === 0 ? 'no legacy patterns' : \count($known) . ' known');
-
tests/lint/check-coverage.php<pre>@@ -0,0 +1,32 @@+<?php++/**+ * YoureCom Standard: line coverage of the PHPUnit unit suite (Clover report) against the floor in+ * tests/coverage-minimum.txt. The floor only goes up. Managed by _dev/standard (apply.php), do not edit here.+ *+ * Usage: php tests/lint/check-coverage.php var/coverage.xml+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$report = $argv[1] ?? 'var/coverage.xml';+if (!\is_file($report)) {+ fail(["The coverage report {$report} does not exist (run PHPUnit with --coverage-clover first)"]);+}++$xml = \simplexml_load_file($report);+if ($xml === false || !isset($xml->project->metrics)) {+ fail(['The coverage report cannot be read']);+}++$statements = (int)$xml->project->metrics['statements'];+$covered = (int)$xml->project->metrics['coveredstatements'];+$percent = $statements > 0 ? 100 * $covered / $statements : 100.0;+$minimum = (float)\trim((string)@\file_get_contents(ROOT . '/tests/coverage-minimum.txt'));++\printf("Coverage of the unit suite: %.2f%% (%d of %d statements), floor %.2f%%\n", $percent, $covered, $statements, $minimum);+if ($percent + 0.005 < $minimum) {+ fail(['The coverage is below the floor.']);+}
-
tests/lint/check-deletes.php<pre>@@ -0,0 +1,434 @@+<?php++/**+ * YoureCom Standard: removed files and language items are deleted on update. Managed by _dev/standard (apply.php),+ * do not edit here.+ * - every file of a released version that the package does not ship any more is listed in fileDelete.xml+ * (files/, files_wcf/), templateDelete.xml (templates/) or acpTemplateDelete.xml (acptemplates/)+ * - every language item of a released version that is gone is listed in the <delete> block of each language file+ * - nothing that the package still ships is deleted (a file moved from files/ to files_wcf/ keeps its target)+ *+ * A released version is the state of the git history before <version> in package.xml changed (and the first commit),+ * as long as package.xml names this package: the history of another package the repository started from does not+ * count. Only those files can be on the systems of the customers; the core also deletes files that no package owns.+ *+ * Needs the full git history (CI: actions/checkout with fetch-depth: 0).+ *+ * Usage: php tests/lint/check-deletes.php [--fix] --fix adds the missing entries (and the update instructions)+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++const PIPS = [+ 'files' => ['fileDelete', 'file'],+ 'files_wcf' => ['fileDelete', 'file'],+ 'templates' => ['templateDelete', 'template'],+ 'acptemplates' => ['acpTemplateDelete', 'template'],+];++$fix = \in_array('--fix', $argv, true);++if (git(['rev-parse', '--is-inside-work-tree']) === null) {+ fail(['not a git repository, the delete check needs the history']);+}+if (git(['rev-parse', '--is-shallow-repository']) === 'true') {+ fail(['shallow clone, the delete check needs the full history (actions/checkout with fetch-depth: 0)']);+}++$applications = applications();+$errors = [];++// what the package ships now: pip => "application|entry" => true+$shipped = [];+foreach (\explode("\n", (string)git(['-c', 'core.quotePath=false', 'ls-files'])) as $path) {+ if (($target = deleteTarget($path, $applications)) !== null) {+ $shipped[$target[0]]["{$target[1]}|{$target[2]}"] = true;+ }+}++$releases = releases();++// what a released version shipped and the package does not ship any more: pip => "application|entry" => [application, entry]+$removed = [];+foreach (releasedFiles($releases) as $path) {+ $target = deleteTarget($path, $applications);+ if ($target !== null && !isset($shipped[$target[0]]["{$target[1]}|{$target[2]}"])) {+ $removed[$target[0]]["{$target[1]}|{$target[2]}"] = [$target[1], $target[2]];+ }+}++foreach (['fileDelete' => 'file', 'templateDelete' => 'template', 'acpTemplateDelete' => 'template'] as $pip => $element) {+ // the core appends .tpl itself, an entry "name.tpl" looks for "name.tpl.tpl"+ if ($element === 'template' && \is_file(ROOT . "/{$pip}.xml")) {+ $source = (string)\file_get_contents(ROOT . "/{$pip}.xml");+ if (\preg_match_all('~<template\b[^>]*>([^<]*)\.tpl</template>~', $source, $suffixed) > 0) {+ if ($fix) {+ \file_put_contents(ROOT . "/{$pip}.xml", (string)\preg_replace('~(<template\b[^>]*>[^<]*)\.tpl(</template>)~', '$1$2', $source));+ echo "{$pip}.xml: removed .tpl from " . \count($suffixed[1]) . " entries\n";+ } else {+ foreach ($suffixed[1] as $name) {+ $errors[] = "{$pip}.xml: {$name}.tpl, the core appends .tpl itself (write {$name})";+ }+ }+ }+ }++ $listed = deleteEntries($pip, $element, $applications);+ $missing = [];+ foreach ($removed[$pip] ?? [] as [$application, $entry]) {+ if (!isset($listed["{$application}|{$entry}"]) && !isset($listed["|{$entry}"])) {+ $missing[] = [$application, $entry];+ }+ }+ foreach ($listed as $key => $label) {+ [$application, $entry] = \explode('|', $key, 2);+ if (isset($shipped[$pip]["{$application}|{$entry}"]) || ($application === '' && isShippedAnywhere($shipped[$pip] ?? [], $entry))) {+ $errors[] = "{$pip}.xml: deletes {$label}, which the package still ships";+ }+ }+ if ($missing === []) {+ continue;+ }++ if ($fix) {+ addDeleteEntries($pip, $element, $missing);+ addUpdateInstruction($pip);+ echo "{$pip}.xml: added " . \count($missing) . " entr" . (\count($missing) === 1 ? 'y' : 'ies') . "\n";+ } else {+ foreach ($missing as [$application, $entry]) {+ $errors[] = "{$pip}.xml: {$entry} ({$application}) was removed, delete it on update";+ }+ }+}++// language items of the released versions+$languageFiles = \glob(ROOT . '/language/*.xml') ?: [];+$requests = [];+foreach (\array_keys($releases) as $commit) {+ foreach ($languageFiles as $file) {+ $requests[] = "{$commit}:language/" . \basename($file);+ }+}+$removedItems = [];+// many releases share the same version of a language file: read every version once+$blobs = \array_values(\array_unique(\array_filter(gitObjects($requests, contents: false))));+foreach (gitObjects($blobs) as $source) {+ \preg_match_all('~<item name="([^"]+)"~', (string)\preg_replace('~<delete>.*?</delete>~s', '', (string)$source), $matches);+ foreach ($matches[1] as $name) {+ // WoltLab rejects names with leading or trailing whitespace, such an item never existed in a database+ if ($name === \trim($name)) {+ $removedItems[$name] = true;+ }+ }+}+foreach ($languageFiles as $file) {+ $languageFile = 'language/' . \basename($file);+ $language = xml($file);+ if ($language === null) {+ continue;+ }+ $imported = [];+ foreach ($language->query('//w:category/w:item') ?: [] as $item) {+ $imported[$item instanceof \DOMElement ? $item->getAttribute('name') : ''] = true;+ }+ $deleted = [];+ foreach ($language->query('/w:language/w:delete/w:item') ?: [] as $item) {+ $name = $item instanceof \DOMElement ? $item->getAttribute('name') : '';+ $deleted[$name] = true;+ if (isset($imported[$name])) {+ $errors[] = "{$languageFile}: deletes {$name}, which the file still imports";+ }+ }++ $missing = \array_keys(\array_diff_key($removedItems, $imported, $deleted));+ \sort($missing);+ if ($missing === []) {+ continue;+ }+ if ($language->query('/w:language/w:import')?->length === 0) {+ $errors[] = "{$languageFile}: uses the old format without <import>, convert it to delete the removed items " . \implode(', ', $missing);+ } elseif ($fix) {+ addLanguageDeletes($file, $missing);+ echo "{$languageFile}: added " . \count($missing) . " delete entr" . (\count($missing) === 1 ? 'y' : 'ies') . "\n";+ } else {+ foreach ($missing as $name) {+ $errors[] = "{$languageFile}: the item {$name} was removed, delete it on update (<delete><item name=\"{$name}\" /></delete>)";+ }+ }+}++finish($errors, \count($releases) . ' released versions');++/**+ * Returns the commits of the released versions of this package (commit => true): the first commit and the commit+ * before every change of <version>, on the first-parent line, where package.xml names this package.+ *+ * @return array<string, true>+ */+function releases(): array+{+ $commits = \array_values(\array_filter(\explode("\n", (string)git(['rev-list', '--first-parent', '--reverse', 'HEAD']))));+ if ($commits === []) {+ return [];+ }+ $order = \array_flip($commits);++ $candidates = [$commits[0] => true];+ $changes = \array_filter(\explode("\n", (string)git(['log', '--first-parent', '--format=%H', '-G<version>', '--', 'package.xml'])));+ foreach ($changes as $change) {+ $index = $order[$change] ?? 0;+ if ($index > 0) {+ $candidates[$commits[$index - 1]] = true;+ }+ }++ $name = packageName();+ $candidates = \array_keys($candidates);+ $releases = [];+ foreach (gitObjects(\array_map(static fn (string $commit): string => "{$commit}:package.xml", $candidates)) as $index => $source) {+ if ($source !== null && \preg_match('~<package name="([^"]+)"~', $source, $match) && \strcasecmp($match[1], $name) === 0) {+ $releases[$candidates[$index]] = true;+ }+ }++ // in the order of the history+ \uksort($releases, static fn (string $a, string $b): int => $order[$a] <=> $order[$b]);++ return $releases;+}++/**+ * Returns the paths below files/, files_wcf/, templates/ and acptemplates/ that one of the releases contained.+ * Replays the changes of the first-parent history once instead of listing the tree of every release.+ *+ * @param array<string, true> $releases in the order of the history+ * @return list<string>+ */+function releasedFiles(array $releases): array+{+ $pending = \array_keys($releases);+ $log = (string)git(['-c', 'core.quotePath=false', 'log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--relative', '--no-renames', '--name-status', '--format=>%H', '--', ...\array_keys(PIPS)]);+ $commits = \array_flip(\array_values(\array_filter(\explode("\n", (string)git(['rev-list', '--first-parent', '--reverse', 'HEAD'])))));++ $state = [];+ $released = [];+ foreach (\explode("\n", $log) as $line) {+ if (\str_starts_with($line, '>')) {+ // a release before this commit sees the state up to here+ $index = $commits[\substr($line, 1)] ?? \PHP_INT_MAX;+ while ($pending !== [] && ($commits[$pending[0]] ?? -1) < $index) {+ $released += $state;+ \array_shift($pending);+ }+ continue;+ }+ $parts = \explode("\t", $line, 2);+ if (\count($parts) !== 2) {+ continue;+ }+ if ($parts[0] === 'D') {+ unset($state[$parts[1]]);+ } else {+ $state[$parts[1]] = true;+ }+ }+ if ($pending !== []) {+ $released += $state;+ }++ return \array_map('strval', \array_keys($released));+}++/**+ * Reads objects like "<commit>:<path>" from the repository in one git process: their contents, or with+ * `contents: false` their object ids; null for a missing object.+ *+ * @param list<string> $objects+ * @return list<string|null>+ */+function gitObjects(array $objects, bool $contents = true): array+{+ if ($objects === []) {+ return [];+ }++ // requests and answer go through files: a pipe could block while git writes, and pipes are slow on Windows+ $requests = (string)\tempnam(\sys_get_temp_dir(), 'yourecom-git-');+ $answer = (string)\tempnam(\sys_get_temp_dir(), 'yourecom-git-');+ \file_put_contents($requests, \implode("\n", $objects) . "\n");+ $process = \proc_open(+ ['git', '-C', (string)\realpath(ROOT), 'cat-file', $contents ? '--batch' : '--batch-check'],+ [0 => ['file', $requests, 'r'], 1 => ['file', $answer, 'w'], 2 => ['file', \PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w']],+ $pipes+ );+ if (!\is_resource($process)) {+ \unlink($requests);+ \unlink($answer);+ fail(['git cat-file failed']);+ }+ \proc_close($process);+ $output = (string)\file_get_contents($answer);+ \unlink($requests);+ \unlink($answer);++ $results = [];+ $offset = 0;+ foreach ($objects as $object) {+ $end = \strpos($output, "\n", $offset);+ if ($end === false) {+ $results[] = null;+ continue;+ }+ $header = \substr($output, $offset, $end - $offset);+ $offset = $end + 1;+ if (!\preg_match('~^([0-9a-f]+) \w+ (\d+)$~', $header, $match)) {+ $results[] = null;+ continue;+ }+ if (!$contents) {+ $results[] = $match[1];+ continue;+ }+ $results[] = \substr($output, $offset, (int)$match[2]);+ $offset += (int)$match[2] + 1;+ }++ return $results;+}++/**+ * Returns the delete PIP, the application and the entry for a path of the repository, null if it is not installed.+ *+ * @param array<string, string> $applications+ * @return array{string, string, string}|null+ */+function deleteTarget(string $path, array $applications): ?array+{+ $parts = \explode('/', $path, 2);+ if (\count($parts) !== 2 || !isset(PIPS[$parts[0]])) {+ return null;+ }+ [$directory, $relative] = $parts;+ if (PIPS[$directory][1] === 'template') {+ if (!\str_ends_with($relative, '.tpl') || \str_contains($relative, '/')) {+ return null;+ }+ $relative = \substr($relative, 0, -4);+ }++ return [PIPS[$directory][0], $applications[$directory], $relative];+}++/**+ * Returns the entries of a delete PIP file: "application|entry" => label (application empty if not given).+ *+ * @param array<string, string> $applications+ * @return array<string, string>+ */+function deleteEntries(string $pip, string $element, array $applications): array+{+ $entries = [];+ foreach (xml(ROOT . "/{$pip}.xml")?->query("/w:data/w:delete/w:{$element}") ?: [] as $node) {+ if ($node instanceof \DOMElement) {+ $entry = \trim($node->textContent);+ $entries[$node->getAttribute('application') . '|' . $entry] = $entry . ($node->getAttribute('application') !== '' ? " ({$node->getAttribute('application')})" : '');+ }+ }++ return $entries;+}++/**+ * Tells whether an entry without application is shipped into any application.+ *+ * @param array<string, true> $shipped+ */+function isShippedAnywhere(array $shipped, string $entry): bool+{+ foreach (\array_keys($shipped) as $key) {+ if (\explode('|', $key, 2)[1] === $entry) {+ return true;+ }+ }++ return false;+}++/**+ * Adds entries to a delete PIP file (creates it if needed), keeps its line endings and indentation.+ *+ * @param list<array{string, string}> $entries [application, entry]+ */+function addDeleteEntries(string $pip, string $element, array $entries): void+{+ $file = ROOT . "/{$pip}.xml";+ $source = \is_file($file) ? (string)\file_get_contents($file) : "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"+ . "<data xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com https://www.woltlab.com/XSD/6.0/{$pip}.xsd\">\n"+ . "</data>\n";+ $eol = \str_contains($source, "\r\n") ? "\r\n" : "\n";+ $indent = \preg_match('~^([ \t]+)<delete>~m', $source, $match) ? $match[1] : "\t";+ \usort($entries, static fn (array $a, array $b): int => [$a[0], $a[1]] <=> [$b[0], $b[1]]);+ $lines = \array_map(+ static fn (array $entry): string => "{$indent}{$indent}<{$element} application=\"{$entry[0]}\">" . \htmlspecialchars($entry[1], \ENT_XML1) . "</{$element}>",+ $entries+ );++ \file_put_contents($file, insertLines($source, $lines, 'data', $indent, $eol));+}++/**+ * Inserts lines at the end of the <delete> block, or adds the block before the closing root tag.+ *+ * @param list<string> $lines+ */+function insertLines(string $source, array $lines, string $root, string $indent, string $eol): string+{+ $block = \implode($eol, $lines);+ if (\str_contains($source, '</delete>')) {+ return (string)\preg_replace_callback('~([ \t]*)</delete>~', static fn (array $match): string => $block . $eol . $match[1] . '</delete>', $source, 1);+ }++ return (string)\preg_replace_callback("~</{$root}>~", static fn (): string => "{$indent}<delete>{$eol}{$block}{$eol}{$indent}</delete>{$eol}</{$root}>", $source, 1);+}++/**+ * Adds `<instruction type="..." />` to every update block of package.xml that does not have it.+ */+function addUpdateInstruction(string $pip): void+{+ $file = ROOT . '/package.xml';+ $source = (string)\file_get_contents($file);+ $eol = \str_contains($source, "\r\n") ? "\r\n" : "\n";+ $updated = (string)\preg_replace_callback(+ '~(<instructions\s+type="update"[^>]*>)(.*?)(\r?\n[ \t]*</instructions>)~s',+ static function (array $block) use ($pip, $eol): string {+ if (\str_contains($block[2], "type=\"{$pip}\"")) {+ return $block[0];+ }+ $indent = \preg_match('~\n([ \t]+)<instruction\b~', $block[2], $match) ? $match[1] : "\t\t";++ return $block[1] . $block[2] . "{$eol}{$indent}<instruction type=\"{$pip}\" />" . $block[3];+ },+ $source+ );+ if ($updated !== $source) {+ \file_put_contents($file, $updated);+ }+}++/**+ * Adds `<item name="..."/>` entries to the <delete> block of a language file (creates the block if needed).+ *+ * @param list<string> $names+ */+function addLanguageDeletes(string $file, array $names): void+{+ $source = (string)\file_get_contents($file);+ $eol = \str_contains($source, "\r\n") ? "\r\n" : "\n";+ $indent = \preg_match('~^([ \t]+)<import>~m', $source, $match) ? $match[1] : "\t";+ $lines = \array_map(static fn (string $name): string => "{$indent}{$indent}<item name=\"" . \htmlspecialchars($name, \ENT_XML1) . '" />', $names);++ \file_put_contents($file, insertLines($source, $lines, 'language', $indent, $eol));+}
-
tests/lint/check-devsystem.php<pre>@@ -0,0 +1,117 @@+<?php++/**+ * YoureCom Standard: checks against the local dev system. Managed by _dev/standard (apply.php), do not edit here.+ * - every permission the package asks for exists (a typo locks everybody out without an error): getPermission(),+ * checkPermissions() and the $neededPermissions/$permissions* properties in PHP and templates, <permissions> in+ * the PIP files; known are the user group options of the dev system and of userGroupOption.xml+ * - every template and every language item compiles with the template compiler of the core+ *+ * Needs the dev system (environment variable WCF_DIR, default C:/laragon/www/wsc6.2), so it is not part of the CI;+ * check.sh runs it unless --fast is given.+ *+ * Usage: php tests/lint/check-devsystem.php+ */++namespace yourecom\standard;++use wcf\system\language\LanguageFactory;+use wcf\system\WCF;++require __DIR__ . '/lib.php';++$wcfDir = \getenv('WCF_DIR');+$wcfDir = \rtrim(\is_string($wcfDir) && $wcfDir !== '' ? $wcfDir : 'C:/laragon/www/wsc6.2', '/\\');+if (!\is_file($wcfDir . '/global.php')) {+ fail(["No WoltLab Suite installation found in '{$wcfDir}', set WCF_DIR."]);+}+$_SERVER['HTTP_HOST'] = 'localhost';+$_SERVER['SERVER_NAME'] = 'localhost';+$_SERVER['REQUEST_METHOD'] = 'GET';+require $wcfDir . '/global.php';++$errors = [];++// permissions: name => list of places+$used = [];+$collect = static function (string $fragment, string $place) use (&$used): void {+ \preg_match_all('~[\'"]([a-z]+(?:\.\w+)+)[\'"]~', $fragment, $names);+ foreach ($names[1] as $name) {+ $used[$name][] = $place;+ }+};+foreach (files(['files', 'files_wcf', 'templates', 'acptemplates'], ['.php', '.tpl']) as $relative => $path) {+ $source = (string)\file_get_contents($path);+ \preg_match_all(+ '~\b(?:getPermission|getNeutralPermission|checkPermissions|hasPermission)\s*\(\s*(\[[^\]]*\]|\'[^\']*\'|"[^"]*")'+ . '|\$(?:neededPermissions|permissions(?:Create|Update|Delete|View)?)\s*=\s*(\[[^\]]*\])~',+ $source,+ $matches,+ \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE+ );+ foreach ($matches as $match) {+ $line = \substr_count($source, "\n", 0, $match[0][1]) + 1;+ $collect($match[0][0], "{$relative}:{$line}");+ }+}+foreach (\glob(ROOT . '/*.xml') ?: [] as $file) {+ $source = (string)\file_get_contents($file);+ \preg_match_all('~<permissions>([^<]*)</permissions>~', $source, $matches, \PREG_OFFSET_CAPTURE);+ foreach ($matches[1] as [$list, $offset]) {+ $line = \substr_count($source, "\n", 0, $offset) + 1;+ foreach (\array_filter(\array_map('trim', \explode(',', $list))) as $name) {+ $used[$name][] = \basename($file) . ":{$line}";+ }+ }+}++$known = [];+$statement = WCF::getDB()->prepare('SELECT optionName FROM wcf1_user_group_option');+$statement->execute();+foreach ($statement->fetchAll(\PDO::FETCH_COLUMN) as $name) {+ $known[(string)$name] = true;+}+// options of this package that are not deployed yet+foreach (xml(ROOT . '/userGroupOption.xml')?->query('//w:option') ?: [] as $option) {+ if ($option instanceof \DOMElement) {+ $known[$option->getAttribute('name')] = true;+ }+}++\ksort($used);+foreach ($used as $name => $places) {+ if (!isset($known[$name])) {+ $errors[] = "unknown permission {$name} (" . \implode(', ', \array_unique($places)) . ')';+ }+}++// templates+$compiler = WCF::getTPL()->getCompiler();+$templates = 0;+foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {+ $templates++;+ try {+ $compiler->compileString(\basename($path, '.tpl'), (string)\file_get_contents($path), [], true);+ } catch (\Throwable $exception) {+ $errors[] = "{$relative}: does not compile: " . \trim($exception->getMessage());+ }+}++// language items+$languageCompiler = LanguageFactory::getInstance()->getScriptingCompiler();+$items = 0;+foreach (['de', 'en'] as $languageCode) {+ foreach (xml(ROOT . "/language/{$languageCode}.xml")?->query('//w:category/w:item') ?: [] as $item) {+ if (!$item instanceof \DOMElement || !\str_contains($item->textContent, '{')) {+ continue;+ }+ $items++;+ try {+ $languageCompiler->compileString($item->getAttribute('name'), $item->textContent, [], true);+ } catch (\Throwable $exception) {+ $errors[] = "language/{$languageCode}.xml: {$item->getAttribute('name')} does not compile: " . \trim($exception->getMessage());+ }+ }+}++finish($errors, \count($used) . " permissions, {$templates} templates, {$items} language items");
-
tests/lint/check-endpoints.php<pre>@@ -0,0 +1,39 @@+<?php++/**+ * YoureCom Standard: every RPC endpoint (class with a request attribute below `system/endpoint/controller`) is+ * registered in the bootstrap file, the core does not discover controllers on its own.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * Usage: php tests/lint/check-endpoints.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$bootstrap = bootstrap();+$errors = [];+$count = 0;++foreach (files(['files', 'files_wcf'], ['.class.php']) as $relative => $path) {+ if (!\str_contains($relative, '/system/endpoint/controller/')) {+ continue;+ }++ $source = (string)\file_get_contents($path);+ if (!\preg_match('~#\[(?:Get|Post|Put|Patch|Delete)Request\(~', $source)+ || !\preg_match('~^namespace ([^;]+);~m', $source, $namespace)+ || !\preg_match('~^(?:final )?class (\w+)~m', $source, $class)) {+ continue;+ }++ $count++;+ $fqcn = $namespace[1] . '\\' . $class[1];+ $short = $class[1] . '()';+ if (!\str_contains($bootstrap, $fqcn) && !\str_contains($bootstrap, 'new ' . $short)) {+ $errors[] = "endpoint not registered in the bootstrap: {$fqcn}";+ }+}++finish($errors, "{$count} endpoints");
-
tests/lint/check-js-build.mjs<pre>@@ -0,0 +1,64 @@+// YoureCom Standard: the committed JavaScript (`jsBuildDir` of tests/lint/config.php, e.g. files/js) is exactly what the+// TypeScript sources produce. Managed by _dev/standard (apply.php), do not edit here.+import { execFileSync } from "node:child_process";+import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";+import { tmpdir } from "node:os";+import { join, relative } from "node:path";+import { fileURLToPath } from "node:url";++const root = fileURLToPath(new URL("../..", import.meta.url));+const configSource = readFileSync(join(root, "tests/lint/config.php"), "utf8");+const buildDir = /'jsBuildDir'\s*=>\s*'([^']+)'/.exec(configSource)?.[1];++function files(dir) {+ return readdirSync(dir).flatMap((name) => {+ const path = join(dir, name);+ return statSync(path).isDirectory() ? files(path) : [path];+ });+}++if (!buildDir) {+ // without jsBuildDir nothing would be checked, so TypeScript sources require it+ const sources = existsSync(join(root, "ts"))+ ? files(join(root, "ts")).filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts") && !file.endsWith(".test.ts"))+ : [];+ if (sources.length > 0) {+ console.error(`ts/ has ${sources.length} TypeScript files, but tests/lint/config.php sets no jsBuildDir (e.g. 'files/js').`);+ process.exit(1);+ }+ console.log("OK (no TypeScript)");+ process.exit(0);+}++const tsc = [join(root, "node_modules/typescript/bin/tsc"), join(root, "../node_modules/typescript/bin/tsc")].find(existsSync);+const out = mkdtempSync(join(tmpdir(), "yourecom-js-"));++function read(dir) {+ return new Map(files(dir).map((file) => [relative(dir, file).replaceAll("\\", "/"), readFileSync(file, "utf8").replaceAll("\r\n", "\n")]));+}++try {+ // tsconfig.ci.json resolves the types from the plugin's node_modules (CI), tsconfig.json from ../node_modules (local)+ const project = existsSync(join(root, "node_modules/@woltlab/d.ts")) ? "tsconfig.ci.json" : "tsconfig.json";+ execFileSync(process.execPath, [tsc, "-p", project, "--noEmit", "false", "--outDir", out], { cwd: root, stdio: "inherit" });++ const fresh = read(out);+ const committed = existsSync(join(root, buildDir)) ? read(join(root, buildDir)) : new Map();+ const problems = [];+ for (const [name, content] of fresh) {+ if (!committed.has(name)) problems.push(`missing in ${buildDir}: ${name}`);+ else if (committed.get(name) !== content) problems.push(`outdated: ${buildDir}/${name}`);+ }+ for (const name of committed.keys()) {+ if (name.endsWith(".js") && !fresh.has(name) && !name.startsWith("3rdParty/")) problems.push(`no TypeScript source for: ${buildDir}/${name}`);+ }++ if (problems.length > 0) {+ console.error(problems.join("\n"));+ console.error("Run `npm run build` and commit the result.");+ process.exit(1);+ }+ console.log(`OK (${fresh.size} files)`);+} finally {+ rmSync(out, { recursive: true, force: true });+}
-
tests/lint/check-js-phrases.php<pre>@@ -0,0 +1,79 @@+<?php++/**+ * YoureCom Standard: every phrase the TypeScript modules read with getPhrase() exists in both language files and is+ * preloaded, either in the bootstrap (`PreloadPhrasesCollecting`, preferred) or with `{jsphrase}` / `registerPhrase()` in a template.+ * With `jsPhrasesDynamic` in tests/lint/config.php, phrases whose key is built at runtime are tolerated: calls without a+ * literal name and preloaded phrases without a literal use are no errors then.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * Usage: php tests/lint/check-js-phrases.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++\preg_match_all('~->preload\(\s*[\'"]([^\'"]+)[\'"]~', bootstrap(), $matches);+$preloaded = \array_fill_keys($matches[1], 'bootstrap');+foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {+ \preg_match_all('~\{jsphrase name=[\'"]([^\'"]+)[\'"]~', (string)\file_get_contents($path), $matches);+ $preloaded += \array_fill_keys($matches[1], $relative);+ \preg_match_all('~registerPhrase\(\s*[\'"]([^\'"]+)[\'"]~', (string)\file_get_contents($path), $matches);+ $preloaded += \array_fill_keys($matches[1], $relative);+}++// phrases whose key is built at runtime cannot be followed, tests/lint/config.php: 'jsPhrasesDynamic' => true+$dynamicKeys = (bool)(config()['jsPhrasesDynamic'] ?? false);++$dynamicKeys = (bool)(config()['jsPhrasesDynamic'] ?? false);+$languages = ['de' => \array_flip(languageKeys('de')), 'en' => \array_flip(languageKeys('en'))];+$used = [];+$errors = [];++foreach (files(['ts'], ['.ts']) as $relative => $path) {+ \preg_match_all('~getPhrase\(([^)]*)\)~', (string)\file_get_contents($path), $calls);+ foreach ($calls[1] as $arguments) {+ if (!\preg_match_all('~["\']([a-z]+\.[\w.]+)["\']~', $arguments, $keys)) {+ if ($dynamicKeys) {+ continue;+ }++ $errors[] = "{$relative}: getPhrase() without a literal phrase name, the check cannot follow it";+ continue;+ }++ foreach ($keys[1] as $key) {+ $used[$key] = $relative;+ }+ }+}++foreach ($used as $key => $file) {+ $isOwn = isset($languages['de'][$key]) || isset($languages['en'][$key]);+ if (!$isOwn && \str_starts_with($key, 'wcf.')) {+ // phrases of the core are preloaded by the core+ continue;+ }++ foreach ($languages as $languageCode => $items) {+ if (!isset($items[$key])) {+ $errors[] = "{$file}: the phrase {$key} is missing in language/{$languageCode}.xml";+ }+ }++ if (!isset($preloaded[$key])) {+ $errors[] = "{$file}: the phrase {$key} is not preloaded (bootstrap or {jsphrase})";+ }+}++foreach ($dynamicKeys ? [] : \array_diff_key($preloaded, $used) as $key => $source) {+ if (!isset($languages['de'][$key]) && !isset($languages['en'][$key])) {+ // a phrase of the core, preloaded for a core module (e.g. the worker dialog)+ continue;+ }++ $errors[] = "{$source} preloads {$key}, but no TypeScript module uses it";+}++finish($errors, \count($used) . ' phrases');
-
tests/lint/check-language.php<pre>@@ -0,0 +1,86 @@+<?php++/**+ * YoureCom Standard: checks the language files. Managed by _dev/standard (apply.php), do not edit here.+ * - de.xml and en.xml define the same variables+ * - German texts that address the reader have the informal and the formal variant+ * - every statically used variable with one of the prefixes of tests/lint/config.php (`languagePrefixes`) exists+ * - the texts use no `{@...}` output and compare strictly in `{if}`/`{elseif}` (like the templates)+ * (whether the texts compile is checked against the dev system: check-devsystem.php)+ *+ * Usage: php tests/lint/check-language.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$config = config();+$de = languageKeys('de');+$en = languageKeys('en');+$errors = [];++foreach (\array_diff($de, $en) as $key) {+ $errors[] = "missing in en.xml: {$key}";+}+foreach (\array_diff($en, $de) as $key) {+ $errors[] = "missing in de.xml: {$key}";+}++\preg_match_all('~<item name="([^"]+)"><!\[CDATA\[(.*?)\]\]></item>~s', \is_file(ROOT . '/language/de.xml') ? (string)\file_get_contents(ROOT . '/language/de.xml') : '', $items, \PREG_SET_ORDER);+foreach ($items as $item) {+ if (\str_contains($item[2], 'LANGUAGE_USE_INFORMAL_VARIANT')) {+ continue;+ }+ if (\preg_match('~(?<![\w-])(du|dich|dir|dein\w*|Sie|Ihnen|Ihr\w*)(?![\w-])~u', $item[2], $address) === 1) {+ $errors[] = "de.xml: {$item[1]} addresses the reader (\"{$address[1]}\") without the LANGUAGE_USE_INFORMAL_VARIANT check";+ }+}++foreach (['de', 'en'] as $languageCode) {+ foreach (xml(ROOT . "/language/{$languageCode}.xml")?->query('//w:category/w:item') ?: [] as $item) {+ if (!$item instanceof \DOMElement) {+ continue;+ }+ $name = $item->getAttribute('name');+ if (\str_contains($item->textContent, '{@')) {+ $errors[] = "{$languageCode}.xml: {$name} uses {@...}, use {unsafe:...}";+ }+ foreach (looseConditions($item->textContent) as [, $problem]) {+ $errors[] = "{$languageCode}.xml: {$name}: {$problem}";+ }+ }+}++$prefixes = $config['languagePrefixes'];+if ($prefixes !== []) {+ $known = \array_flip($de);+ // names that are a prefix of a defined variable are used to build dynamic names+ $partial = [];+ foreach ($de as $key) {+ $parts = \explode('.', $key);+ for ($i = 1, $count = \count($parts); $i < $count; $i++) {+ $partial[\implode('.', \array_slice($parts, 0, $i))] = true;+ }+ }++ $pattern = \implode('|', \array_map(static fn (string $prefix): string => \preg_quote($prefix, '~'), $prefixes));+ $used = [];+ foreach (files(['files', 'files_wcf', 'templates', 'acptemplates', 'ts'], ['.php', '.tpl', '.ts']) as $relative => $path) {+ \preg_match_all("~['\"{}]((?:{$pattern})[A-Za-z0-9_.]*)['\"{]~", (string)\file_get_contents($path), $matches);+ foreach ($matches[1] as $key) {+ if (!\str_ends_with($key, '.')) {+ $used[$key][$relative] = true;+ }+ }+ }++ \ksort($used);+ foreach ($used as $key => $files) {+ if (!isset($known[$key]) && !isset($partial[$key]) && !\in_array($key, $config['languageIgnore'], true)) {+ $errors[] = "unknown language variable {$key} (" . \implode(', ', \array_keys($files)) . ')';+ }+ }+}++finish($errors, \count($de) . ' variables');
-
tests/lint/check-package.php<pre>@@ -0,0 +1,80 @@+<?php++/**+ * YoureCom Standard: checks package.xml and the generated files of the package. Managed by _dev/standard (apply.php),+ * do not edit here.+ * - every required package is listed once, com.woltlab.wcf is one of them+ * - com.woltlab.wcf is excluded from the next minor version on (`<excludedpackage version="6.3.0 Alpha 1">` for a+ * minversion of 6.2.x), the package is not installable on a version it was not tested with+ * - the delete PIPs (fileDelete.xml, templateDelete.xml, acpTemplateDelete.xml) run in every update+ * - constants.php matches option.xml (written by apply.php; `--fix` writes it here)+ *+ * Usage: php tests/lint/check-package.php [--fix]+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$fix = \in_array('--fix', $argv, true);+$errors = [];++$package = xml(ROOT . '/package.xml');+if ($package === null) {+ fail(['package.xml is missing or not well-formed']);+}++$required = [];+foreach ($package->query('//w:requiredpackages/w:requiredpackage') ?: [] as $node) {+ if (!$node instanceof \DOMElement) {+ continue;+ }+ $name = \trim($node->textContent);+ if (isset($required[$name])) {+ $errors[] = "package.xml: {$name} is required twice";+ }+ $required[$name] = $node->getAttribute('minversion');+}++if (!isset($required['com.woltlab.wcf'])) {+ $errors[] = 'package.xml: com.woltlab.wcf is not a required package';+} elseif (\preg_match('~^(\d+)\.(\d+)\.~', $required['com.woltlab.wcf'], $version)) {+ $expected = $version[1] . '.' . ((int)$version[2] + 1) . '.0 Alpha 1';+ $excluded = null;+ foreach ($package->query('//w:excludedpackages/w:excludedpackage') ?: [] as $node) {+ if ($node instanceof \DOMElement && \trim($node->textContent) === 'com.woltlab.wcf') {+ $excluded = $node->getAttribute('version');+ }+ }+ if ($excluded !== $expected) {+ $errors[] = "package.xml: <excludedpackage version=\"{$expected}\">com.woltlab.wcf</excludedpackage> is missing"+ . ($excluded !== null ? " (found version=\"{$excluded}\")" : '');+ }+} else {+ $errors[] = "package.xml: com.woltlab.wcf has no minversion";+}++$updates = $package->query('//w:instructions[@type="update"]') ?: [];+foreach (['fileDelete', 'templateDelete', 'acpTemplateDelete'] as $pip) {+ if (!\is_file(ROOT . "/{$pip}.xml")) {+ continue;+ }+ foreach ($updates as $update) {+ if ($update instanceof \DOMElement && $package->query("w:instruction[@type=\"{$pip}\"]", $update)?->length === 0) {+ $errors[] = "package.xml: the update from {$update->getAttribute('fromversion')} has no <instruction type=\"{$pip}\" />, {$pip}.xml is not applied";+ }+ }+}++$constants = constantsSource(ROOT);+$current = \is_file(ROOT . '/constants.php') ? (string)\file_get_contents(ROOT . '/constants.php') : null;+if ($current === null || constantDefinitions($current) !== constantDefinitions($constants)) {+ if ($fix) {+ \file_put_contents(ROOT . '/constants.php', $constants);+ echo "constants.php written\n";+ } else {+ $errors[] = 'constants.php does not match option.xml, run: php tests/lint/check-package.php --fix';+ }+}++finish($errors, \count($required) . ' required packages');
-
tests/lint/check-php.php<pre>@@ -0,0 +1,157 @@+<?php++/**+ * YoureCom Standard: rules for the PHP code of the package. Managed by _dev/standard (apply.php), do not edit here.+ * - no debug output (`wcfDebug()`, `var_dump()`, `dump()`, `dd()`)+ * - no `WCF_N`, the database layer replaces `wcf1_` itself+ * - `json_decode()` with `JSON_THROW_ON_ERROR` (or `JSON::decode()`), a broken document must not become null silently+ * - `StringUtil::trim()` instead of the native `trim()` (it also removes Unicode whitespace); `trim()` with an own+ * character list stays allowed+ * - classes, interfaces, traits and enums have a docblock with a description, `@author` and `@license`+ *+ * Methods (`#[\Override]` or a docblock) and strict comparisons are checked by PHPStan (phpstan.standard.neon).+ *+ * Usage: php tests/lint/check-php.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$errors = [];+$count = 0;++foreach (files(['files', 'files_wcf'], ['.php']) as $relative => $path) {+ $source = (string)\file_get_contents($path);+ $count++;++ foreach (functionCalls($source, ['wcfdebug', 'var_dump', 'dump', 'dd', 'json_decode', 'trim']) as $call) {+ if ($call['name'] === 'trim') {+ if (argumentCount($call['arguments']) === 1) {+ $errors[] = "{$relative}:{$call['line']}: native trim(), use StringUtil::trim()";+ }+ } elseif ($call['name'] !== 'json_decode') {+ $errors[] = "{$relative}:{$call['line']}: debug output {$call['name']}()";+ } elseif (!\str_contains($call['arguments'], 'JSON_THROW_ON_ERROR')) {+ $errors[] = "{$relative}:{$call['line']}: json_decode() without JSON_THROW_ON_ERROR (or use JSON::decode())";+ }+ }++ $tokens = \token_get_all($source);+ foreach ($tokens as $index => $token) {+ if (!\is_array($token)) {+ continue;+ }++ if (\in_array($token[0], [\T_STRING, \T_NAME_FULLY_QUALIFIED], true) && \ltrim($token[1], '\\') === 'WCF_N') {+ $errors[] = "{$relative}:{$token[2]}: WCF_N, write wcf1_ (the database layer replaces the prefix)";+ continue;+ }++ if (!\in_array($token[0], [\T_CLASS, \T_INTERFACE, \T_TRAIT, \T_ENUM], true)) {+ continue;+ }+ // `Foo::class`, `new class` and enum used as an identifier are no declarations+ $previous = nextToken($tokens, $index, -1);+ $next = nextToken($tokens, $index, 1);+ if ($next === null || !\is_array($tokens[$next]) || $tokens[$next][0] !== \T_STRING) {+ continue;+ }+ if ($previous !== null && \is_array($tokens[$previous]) && \in_array($tokens[$previous][0], [\T_DOUBLE_COLON, \T_NEW], true)) {+ continue;+ }++ $name = $tokens[$next][1];+ $docComment = classDocComment($tokens, $index);+ if ($docComment === null) {+ $errors[] = "{$relative}:{$token[2]}: {$name} has no docblock (description, @author, @license)";+ continue;+ }++ $missing = [];+ if (!\preg_match('~^\s*\*\s*[^@\s*/]~m', $docComment)) {+ $missing[] = 'a description';+ }+ foreach (['@author', '@license'] as $tag) {+ if (!\str_contains($docComment, $tag)) {+ $missing[] = $tag;+ }+ }+ if ($missing !== []) {+ $errors[] = "{$relative}:{$token[2]}: the docblock of {$name} lacks " . \implode(', ', $missing);+ }+ }+}++finish($errors, "{$count} files");++/**+ * Returns the number of arguments of a call, given the source of its parenthesized argument list.+ */+function argumentCount(string $arguments): int+{+ $depth = 0;+ $count = 0;+ $hasContent = false;+ foreach (\token_get_all('<?php ' . $arguments) as $token) {+ if (\is_array($token) && \in_array($token[0], [\T_OPEN_TAG, \T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {+ continue;+ }+ $text = \is_array($token) ? $token[1] : $token;+ if ($text === '(' || $text === '[' || $text === '{') {+ $depth++;+ if ($depth === 1) {+ continue;+ }+ } elseif ($text === ')' || $text === ']' || $text === '}') {+ $depth--;+ } elseif ($text === ',' && $depth === 1) {+ $count++;+ $hasContent = false;+ continue;+ }+ if ($depth >= 1) {+ $hasContent = true;+ }+ }++ // a trailing comma does not start another argument+ return $count + ($hasContent ? 1 : 0);+}++/**+ * Returns the docblock in front of a class declaration (before its modifiers and attributes), null if there is none.+ *+ * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens+ */+function classDocComment(array $tokens, int $index): ?string+{+ $depth = 0;+ for ($i = $index - 1; $i >= 0; $i--) {+ $token = $tokens[$i];+ $text = \is_array($token) ? $token[1] : $token;++ // skip attributes: #[ ... ]+ if ($text === ']') {+ $depth++;+ continue;+ }+ if ($depth > 0) {+ if ($text === '[' || (\is_array($token) && $token[0] === \T_ATTRIBUTE)) {+ $depth--;+ }+ continue;+ }++ if (\is_array($token) && $token[0] === \T_DOC_COMMENT) {+ return $token[1];+ }+ if (\is_array($token) && \in_array($token[0], [\T_WHITESPACE, \T_COMMENT, \T_FINAL, \T_ABSTRACT, \T_READONLY], true)) {+ continue;+ }++ return null;+ }++ return null;+}
-
tests/lint/check-templates.php<pre>@@ -0,0 +1,142 @@+<?php++/**+ * YoureCom Standard: rules for templates and the frontend. Managed by _dev/standard (apply.php), do not edit here.+ * - no jQuery in templates and TypeScript+ * - no legacy AJAX actions (`Ajax.dboAction`, `WCF.Action.Proxy`) in TypeScript, new server calls are RPC endpoints+ * - no compiled .js files next to the TypeScript sources+ * - no `{@...}` output (WoltLab 6.1+: `{unsafe:...}` makes unescaped output explicit)+ * - strict comparisons in `{if}`/`{elseif}`: `===`/`!==` instead of `==`/`!=`, no `|empty`/`empty()`+ * (compare with `=== []`, `=== ''`, `=== 0` or `=== null`)+ * - no debug output (`|wcfDebug`) and no legacy phrases (`Language.addObject(`, phrases come from `{jsphrase}`)+ * - HTML: no duplicate attributes in a tag, no duplicate static ids in a template, `<button>` with a `type`,+ * form fields with a label (`<label for>`, an enclosing `<label>`, `aria-label`, `aria-labelledby` or `title`;+ * fields with a dynamic id are labelled by the including template)+ *+ * Usage: php tests/lint/check-templates.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$errors = [];++foreach (files(templateDirectories(), ['.tpl']) as $relative => $path) {+ // comments do not count, their line breaks stay for the line numbers+ $source = (string)\preg_replace_callback('~\{\*.*?\*\}~s', static fn (array $match): string => \str_repeat("\n", \substr_count($match[0], "\n")), (string)\file_get_contents($path));+ $line = static fn (int $offset): int => \substr_count($source, "\n", 0, $offset) + 1;++ foreach (\explode("\n", $source) as $index => $text) {+ $number = $index + 1;+ if (\preg_match('~\bjQuery\b|\$\(\s*[\'"#.]~', $text)) {+ $errors[] = "{$relative}:{$number}: jQuery";+ }+ if (\preg_match('~\{@~', $text)) {+ $errors[] = "{$relative}:{$number}: {@...} is deprecated, use {unsafe:...}";+ }+ if (\preg_match('~\|wcfDebug\b~', $text)) {+ $errors[] = "{$relative}:{$number}: debug output |wcfDebug";+ }+ if (\str_contains($text, 'Language.addObject(')) {+ $errors[] = "{$relative}:{$number}: Language.addObject(), preload the phrases with {jsphrase}";+ }+ }++ foreach (looseConditions($source) as [$offset, $problem]) {+ $errors[] = "{$relative}:{$line($offset)}: {$problem}";+ }++ // the HTML tags, without Smarty tags that contain a ">" (conditions)+ \preg_match_all('~<([a-zA-Z][\w-]*)\b((?:[^<>{]|\{[^}]*\})*)>~', $source, $tags, \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE);+ $ids = [];+ $labelFor = [];+ \preg_match_all('~\bfor="([^"]+)"~', $source, $labels);+ foreach ($labels[1] as $for) {+ $labelFor[$for] = true;+ }++ foreach ($tags as $tag) {+ $name = \strtolower($tag[1][0]);+ $attributes = $tag[2][0];+ $offset = $tag[0][1];+ $number = $line($offset);+ // attributes that depend on a condition can exist twice ({if}a="1"{else}a="2"{/if})+ $conditional = \preg_match('~\{(?:if|else|elseif)\b~', $attributes) === 1;+ $static = (string)\preg_replace('~\{[^}]*\}~', '', $attributes);++ \preg_match_all('~(?:^|\s)([a-zA-Z_:][-\w:.]*)(?=\s*=)~', $static, $names);+ if (!$conditional) {+ $duplicates = \array_unique(\array_diff_assoc(\array_map('strtolower', $names[1]), \array_unique(\array_map('strtolower', $names[1]))));+ if ($duplicates !== []) {+ $errors[] = "{$relative}:{$number}: <{$name}> has the attribute " . \implode(', ', $duplicates) . ' twice';+ }+ }++ if (\preg_match('~(?:^|\s)id="([A-Za-z][\w:.-]*)"~', $attributes, $id)) {+ $ids[$id[1]][] = $offset;+ }++ if ($name === 'button' && !\preg_match('~(?:^|\s)type\s*=~', $attributes)) {+ $errors[] = "{$relative}:{$number}: <button> without type (the default is submit)";+ }++ if (\in_array($name, ['input', 'select', 'textarea'], true) && !hasLabel($name, $attributes, $source, $offset, $labelFor)) {+ $errors[] = "{$relative}:{$number}: <{$name}> without a label (<label for>, enclosing <label>, aria-label, aria-labelledby or title)";+ }+ }++ foreach ($ids as $id => $offsets) {+ for ($i = 1, $count = \count($offsets); $i < $count; $i++) {+ // the same id in the branches of a condition is fine+ $between = \substr($source, $offsets[$i - 1], $offsets[$i] - $offsets[$i - 1]);+ if (!\preg_match('~\{(?:else|elseif)\b~', $between)) {+ $errors[] = "{$relative}:{$line($offsets[$i])}: the id \"{$id}\" exists twice in the template";+ }+ }+ }+}++foreach (files(['ts'], ['.ts', '.js']) as $relative => $path) {+ if (\str_ends_with($relative, '.js')) {+ $errors[] = "{$relative}: compiled JavaScript next to the TypeScript sources";+ continue;+ }++ foreach (\file($path) ?: [] as $index => $text) {+ $number = $index + 1;+ if (\preg_match('~\bjQuery\b|from "jquery"~', $text)) {+ $errors[] = "{$relative}:{$number}: jQuery";+ }+ if (\preg_match('~\bdboAction\(|WCF\.Action\.Proxy~', $text)) {+ $errors[] = "{$relative}:{$number}: legacy AJAX action, use an RPC endpoint";+ }+ }+}++finish($errors);++/**+ * Tells whether a form field has an accessible label.+ *+ * @param array<string, true> $labelFor the values of all for="..." attributes of the template+ */+function hasLabel(string $name, string $attributes, string $source, int $offset, array $labelFor): bool+{+ if ($name === 'input' && \preg_match('~(?:^|\s)type="(?:hidden|submit|button|reset|image|\{[^"]*)"~i', $attributes)) {+ return true;+ }+ if (\preg_match('~(?:^|\s)(?:aria-label|aria-labelledby|title)\s*=~', $attributes)) {+ return true;+ }+ // a dynamic id is labelled by the including template (e.g. option types inside the <label for> of the core)+ if (\preg_match('~(?:^|\s)id="([^"]+)"~', $attributes, $id) && (isset($labelFor[$id[1]]) || \str_contains($id[1], '{'))) {+ return true;+ }++ // inside a <label> that is not closed before the field+ $before = \substr($source, 0, $offset);+ $open = \strrpos($before, '<label');++ return $open !== false && !\str_contains(\substr($before, $open), '</label>');+}
-
tests/lint/check-xsd.php<pre>@@ -0,0 +1,107 @@+<?php++/**+ * YoureCom Standard: validates the PIP and language files against the XSDs of WoltLab Suite 6.2.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * Every file has to reference https://www.woltlab.com/XSD/6.0/<pip>.xsd; own PIPs of the YoureCom plugins reference+ * https://www.yourecom.de/XSD/6.2/<pip>.xsd (files/XSD/6.2 of de.yourecom.website, environment variable YOURECOM_XSD).+ * The XSDs of WoltLab come from the WCF repository+ * (directory `XSD`): environment variable WCF_XSD, locally default `../var/wcf/XSD` (cloned by _dev/standard/check.sh).+ * Gaps of the XSDs (elements the core accepts but the XSD does not know) are listed in tests/lint/config.php+ * (`xsdKnown`); entries that no longer match make the check fail, so the list can only shrink.+ *+ * Usage: php tests/lint/check-xsd.php+ */++namespace yourecom\standard;++require __DIR__ . '/lib.php';++$xsdDirectory = \getenv('WCF_XSD');+if ($xsdDirectory === false || $xsdDirectory === '') {+ $xsdDirectory = ROOT . '/../var/wcf/XSD';+}+if (!\is_dir($xsdDirectory)) {+ fail(["The XSD directory does not exist: {$xsdDirectory} (set WCF_XSD)"]);+}++// XSDs of the own PIPs of the YoureCom plugins: environment variable YOURECOM_XSD, locally the folder of the website plugin+$yourecomXsdDirectory = \getenv('YOURECOM_XSD');+if ($yourecomXsdDirectory === false || $yourecomXsdDirectory === '') {+ $yourecomXsdDirectory = ROOT . '/../de.yourecom.website/files/XSD/6.2';+}++// gaps of the XSDs of the WCF repository: the core accepts and uses these elements and attributes in its own packages+const CORE_GAPS = [+ 'objectType.xml' => ['defaultpermission', '}page'],+ 'package.xml' => ["'languagecode'"],+ 'templateListener.xml' => ['templatecode'],+ 'userGroupOption.xml' => ["is not a valid value of the atomic type 'xs:unsignedInt'"],+];++$known = config()['xsdKnown'];+$matched = [];+$errors = [];+$checked = 0;++\libxml_use_internal_errors(true);+foreach (\array_merge(\glob(ROOT . '/*.xml') ?: [], \glob(ROOT . '/language/*.xml') ?: []) as $file) {+ $name = \basename($file);+ if (\str_starts_with($name, 'phpunit')) {+ continue;+ }++ $document = new \DOMDocument();+ if (!$document->load($file)) {+ $errors[] = "{$name}: not well-formed";+ continue;+ }++ $schemaLocation = $document->documentElement?->getAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'schemaLocation') ?? '';+ if (\preg_match('~^http://www\.woltlab\.com\s+https://www\.yourecom\.de/XSD/6\.2/(\w+\.xsd)$~', \trim($schemaLocation), $match)) {+ // PIPs of the YoureCom plugins have no WoltLab XSD, theirs live in de.yourecom.website (files/XSD/6.2)+ $schema = $yourecomXsdDirectory . '/' . $match[1];+ } elseif (\preg_match('~^http://www\.woltlab\.com\s+https://www\.woltlab\.com/XSD/6\.0/(\w+\.xsd)$~', \trim($schemaLocation), $match)) {+ $schema = $xsdDirectory . '/' . $match[1];+ } else {+ $errors[] = "{$name}: xsi:schemaLocation must be \"http://www.woltlab.com https://www.woltlab.com/XSD/6.0/<pip>.xsd\" (own PIPs: \"http://www.woltlab.com https://www.yourecom.de/XSD/6.2/<pip>.xsd\")";+ continue;+ }+ if (!\is_file($schema)) {+ $errors[] = "{$name}: schema {$match[1]} not found";+ continue;+ }++ $checked++;+ \libxml_clear_errors();+ if ($document->schemaValidate($schema)) {+ continue;+ }++ foreach (\libxml_get_errors() as $error) {+ $message = \trim($error->message);+ foreach (CORE_GAPS[$name] ?? [] as $part) {+ if (\str_contains($message, $part)) {+ continue 2;+ }+ }+ foreach ($known[$name] ?? [] as $part) {+ if (\str_contains($message, $part)) {+ $matched[$name][$part] = true;+ continue 2;+ }+ }+ $errors[] = \sprintf('%s:%d: %s', $name, $error->line, $message);+ }+}++foreach ($known as $name => $parts) {+ foreach ($parts as $part) {+ if (!isset($matched[$name][$part])) {+ $errors[] = "{$name}: the known finding '{$part}' does not occur any more, remove it from tests/lint/config.php";+ }+ }+}++finish($errors, "{$checked} files");
-
tests/lint/config.php<pre>@@ -0,0 +1,17 @@+<?php++/**+ * Plugin specific settings of the YoureCom Standard checks (tests/lint/*).+ */+return [+ // statically used language variables with these prefixes must exist in language/*.xml+ 'languagePrefixes' => [],+ // variables with these prefixes that are defined elsewhere (keep this list short)+ 'languageIgnore' => [],+ // gaps of the WoltLab XSDs: file name => parts of the validation message (the list can only shrink)+ 'xsdKnown' => [],+ // legacy patterns that cannot be changed yet: relative file => rules of check-architecture.php (the list can only shrink)+ 'architectureKnown' => [],+ // committed build of ts/ (the outDir of tsconfig.test.json), null without TypeScript+ 'jsBuildDir' => null,+];
-
tests/lint/lib.php<pre>@@ -0,0 +1,407 @@+<?php++/**+ * YoureCom Standard: shared helpers of the lint scripts. Managed by _dev/standard (apply.php), do not edit here.+ *+ * Reads the package name from package.xml and the plugin specific settings from tests/lint/config.php.+ */++namespace yourecom\standard;++const ROOT = __DIR__ . '/../..';++/**+ * Returns the plugin specific settings merged with the defaults.+ *+ * @return array{+ * languagePrefixes: list<string>,+ * languageIgnore: list<string>,+ * xsdKnown: array<string, list<string>>,+ * architectureKnown: array<string, list<string>>,+ * jsBuildDir: string|null,+ * jsPhrasesDynamic: bool,+ * }+ */+function config(): array+{+ $file = ROOT . '/tests/lint/config.php';+ $config = \is_file($file) ? require $file : [];++ return [+ 'languagePrefixes' => $config['languagePrefixes'] ?? [],+ 'languageIgnore' => $config['languageIgnore'] ?? [],+ 'xsdKnown' => $config['xsdKnown'] ?? [],+ 'architectureKnown' => $config['architectureKnown'] ?? [],+ 'jsBuildDir' => $config['jsBuildDir'] ?? null,+ 'jsPhrasesDynamic' => $config['jsPhrasesDynamic'] ?? false,+ ];+}++/**+ * Returns the package identifier from package.xml.+ */+function packageName(): string+{+ $source = (string)\file_get_contents(ROOT . '/package.xml');+ if (!\preg_match('~<package name="([^"]+)"~', $source, $match)) {+ fail(['package.xml has no package name']);+ }++ return $match[1];+}++/**+ * Returns the source of the bootstrap file of the package (empty if there is none).+ */+function bootstrap(): string+{+ $name = packageName();+ foreach (['files', 'files_wcf'] as $directory) {+ foreach (\glob(ROOT . "/{$directory}/lib/bootstrap/*.php") ?: [] as $file) {+ if (\strcasecmp(\basename($file, '.php'), $name) === 0) {+ return (string)\file_get_contents($file);+ }+ }+ }++ return '';+}++/**+ * Returns the item names of a language file.+ *+ * @return list<string>+ */+function languageKeys(string $languageCode): array+{+ $file = ROOT . "/language/{$languageCode}.xml";+ if (!\is_file($file)) {+ return [];+ }++ $source = (string)\file_get_contents($file);+ \preg_match_all('~<item name="([^"]+)"~', $source, $matches);++ return $matches[1];+}++/**+ * Returns the directories with templates: `templates/` and `acptemplates/` (template PIPs) and templates an application+ * ships as files (`files/templates/`, `files/acp/templates/`).+ *+ * @return list<string>+ */+function templateDirectories(): array+{+ return ['templates', 'acptemplates', 'files/templates', 'files/acp/templates'];+}++/**+ * Yields the files below the given directories of the plugin whose name ends with one of the suffixes. Bundled+ * third-party code (`vendor/` directories) is skipped, like PHP-CS-Fixer does.+ *+ * @param list<string> $directories+ * @param list<string> $suffixes+ * @return \Generator<string, string> relative path => absolute path+ */+function files(array $directories, array $suffixes): \Generator+{+ $root = \str_replace('\\', '/', (string)\realpath(ROOT));+ foreach ($directories as $directory) {+ if (!\is_dir(ROOT . '/' . $directory)) {+ continue;+ }++ $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(ROOT . '/' . $directory, \FilesystemIterator::SKIP_DOTS));+ foreach ($iterator as $file) {+ $path = \str_replace('\\', '/', (string)\realpath($file->getPathname()));+ if (\str_contains(\substr($path, \strlen($root)), '/vendor/')) {+ continue;+ }+ foreach ($suffixes as $suffix) {+ if (\str_ends_with($path, $suffix)) {+ yield \substr($path, \strlen($root) + 1) => $path;+ break;+ }+ }+ }+ }+}++/**+ * Returns the calls of the given global functions in a PHP source (not methods, not declarations).+ *+ * @param list<string> $names lower case function names+ * @return list<array{name: string, line: int, arguments: string}>+ */+function functionCalls(string $source, array $names): array+{+ $tokens = \token_get_all($source);+ $calls = [];+ foreach ($tokens as $index => $token) {+ if (!\is_array($token) || !\in_array($token[0], [\T_STRING, \T_NAME_FULLY_QUALIFIED], true)) {+ continue;+ }+ $name = \strtolower(\ltrim($token[1], '\\'));+ if (!\in_array($name, $names, true)) {+ continue;+ }++ $next = nextToken($tokens, $index, 1);+ $previous = nextToken($tokens, $index, -1);+ if ($next === null || $tokens[$next] !== '(') {+ continue;+ }+ if ($previous !== null && \is_array($tokens[$previous])+ && \in_array($tokens[$previous][0], [\T_OBJECT_OPERATOR, \T_NULLSAFE_OBJECT_OPERATOR, \T_DOUBLE_COLON, \T_FUNCTION, \T_NEW, \T_CONST], true)) {+ continue;+ }++ // the source of the arguments up to the matching parenthesis+ $depth = 0;+ $arguments = '';+ for ($i = $next, $count = \count($tokens); $i < $count; $i++) {+ $text = \is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i];+ if ($text === '(' || $text === '[') {+ $depth++;+ } elseif ($text === ')' || $text === ']') {+ $depth--;+ }+ $arguments .= $text;+ if ($depth === 0) {+ break;+ }+ }++ $calls[] = ['name' => $name, 'line' => $token[2], 'arguments' => $arguments];+ }++ return $calls;+}++/**+ * Returns the index of the next (direction 1) or previous (-1) token that is no whitespace or comment.+ *+ * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens+ */+function nextToken(array $tokens, int $index, int $direction): ?int+{+ for ($i = $index + $direction; isset($tokens[$i]); $i += $direction) {+ if (!\is_array($tokens[$i]) || !\in_array($tokens[$i][0], [\T_WHITESPACE, \T_COMMENT, \T_DOC_COMMENT], true)) {+ return $i;+ }+ }++ return null;+}++/**+ * Loads an XML file of the plugin, null if it does not exist or is not well-formed.+ */+function xml(string $file): ?\DOMXPath+{+ if (!\is_file($file)) {+ return null;+ }++ $document = new \DOMDocument();+ $previous = \libxml_use_internal_errors(true);+ $loaded = $document->load($file);+ \libxml_clear_errors();+ \libxml_use_internal_errors($previous);+ if (!$loaded) {+ return null;+ }++ $xpath = new \DOMXPath($document);+ $xpath->registerNamespace('w', 'http://www.woltlab.com');++ return $xpath;+}++/**+ * Returns the application each source directory of the plugin is installed into (files, files_wcf, templates,+ * acptemplates): the application attribute of the install instruction, else the package itself for applications,+ * else wcf.+ *+ * @return array<string, string>+ */+function applications(string $pluginDir = ROOT): array+{+ $xpath = xml($pluginDir . '/package.xml');+ $own = 'wcf';+ if ($xpath !== null && \trim((string)$xpath->evaluate('string(//w:packageinformation/w:isapplication)')) === '1') {+ $name = (string)$xpath->evaluate('string(/w:package/@name)');+ $own = \strtolower(\substr($name, (int)\strrpos($name, '.') + 1));+ }++ $applications = ['files' => $own, 'files_wcf' => 'wcf', 'templates' => $own, 'acptemplates' => $own];+ $types = ['file' => 'files', 'template' => 'templates', 'acpTemplate' => 'acptemplates'];+ foreach ($xpath?->query('//w:instructions[@type="install"]/w:instruction') ?: [] as $instruction) {+ if (!$instruction instanceof \DOMElement || !isset($types[$instruction->getAttribute('type')])) {+ continue;+ }+ $archive = \trim($instruction->textContent);+ $directory = $archive !== '' ? \basename($archive, '.tar') : $types[$instruction->getAttribute('type')];+ if ($instruction->getAttribute('application') !== '' && isset($applications[$directory])) {+ $applications[$directory] = $instruction->getAttribute('application');+ }+ }++ return $applications;+}++/**+ * Builds constants.php of a plugin: the constants of its options (option.xml), of the application and of the+ * framework, so PHPStan and the IDE know them. Written by apply.php, checked by check-package.php.+ * The option constants have the types of the core's options.inc.php (OptionEditor::rebuild()): boolean and integer+ * options are int, all others string.+ */+function constantsSource(string $pluginDir): string+{+ $pluginDir = \str_replace('\\', '/', (string)\realpath($pluginDir));+ $groups = ['0' => [], "''" => []];+ $options = xml($pluginDir . '/option.xml');+ foreach ($options?->query('//w:import/w:options/w:option') ?: [] as $option) {+ if (!$option instanceof \DOMElement || $option->getAttribute('name') === '') {+ continue;+ }+ $type = \strtolower(\trim((string)$options->evaluate('string(w:optiontype)', $option)));+ $value = \in_array($type, ['boolean', 'integer'], true) ? '0' : "''";+ $groups[$value][\strtoupper($option->getAttribute('name'))] = $value;+ }++ $lines = [+ '<?php',+ '/**',+ ' * Defines constants for autocompletion in IDEs. This file is not meant to be actively used anywhere!',+ ' *',+ ' * @author Marcel Beckers',+ ' * @license YoureCom License - Commercial (YCLC) <https://www.yourecom.de/shop/licence-commercial/>',+ ' * @package ' . \basename($pluginDir),+ ' */',+ '',+ '// option constants',+ '',+ '// application constants',+ ];+ $package = xml($pluginDir . '/package.xml');+ if ($package !== null && \trim((string)$package->evaluate('string(//w:packageinformation/w:isapplication)')) === '1') {+ $name = (string)$package->evaluate('string(/w:package/@name)');+ if (\preg_match('~^[^.]+\.[^.]+\.(.+)$~', $name, $match)) {+ $key = \strtoupper($match[1]);+ $lines[] = "define('{$key}_DIR', '');";+ $lines[] = "define('RELATIVE_{$key}_DIR', '');";+ }+ }+ \array_push(+ $lines,+ '',+ '// hardcoded framework constants',+ "define('WCF_DIR', '');",+ "define('LEGAL_NOTICE_EMAIL_ADDRESS', '');",+ "define('DOMPDF_LOG_FILE', WCF_DIR . 'tmp/dompdf.log');",+ );+ foreach ($groups as $constants) {+ \ksort($constants, \SORT_STRING);+ foreach ($constants as $constant => $value) {+ $lines[] = "define('{$constant}', {$value});";+ }+ }++ return \implode("\n", $lines) . "\n";+}++/**+ * Returns the define() lines of a constants.php source, sorted, to compare two versions independent of the order.+ *+ * @return list<string>+ */+function constantDefinitions(string $source): array+{+ \preg_match_all('~^define\(.*$~m', \str_replace("\r", '', $source), $matches);+ $definitions = \array_map('trim', $matches[0]);+ \sort($definitions);++ return $definitions;+}++/**+ * Returns the loose comparisons in the conditions ({if}, {elseif}) of a template or language item:+ * list of [offset, problem].+ *+ * @return list<array{int, string}>+ */+function looseConditions(string $source): array+{+ $problems = [];+ \preg_match_all('~\{(?:if|elseif)\s[^}]*\}~', $source, $conditions, \PREG_OFFSET_CAPTURE);+ foreach ($conditions[0] as [$condition, $offset]) {+ // strings in the condition do not count+ $code = (string)\preg_replace('~\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*"~', "''", $condition);+ if (\preg_match('~(?<![=!<>])(==|!=|<>)(?!=)~', $code, $operator)) {+ $strict = $operator[1] === '==' ? '===' : '!==';+ $problems[] = [$offset, "loose comparison {$operator[1]} in {$condition}, use {$strict}"];+ }+ if (\preg_match('~\|empty\b|\bempty\s*\(~', $code)) {+ $problems[] = [$offset, "empty in {$condition}, compare strictly (=== [], === '', === 0, === null)"];+ }+ }++ return $problems;+}++/**+ * Runs git in the plugin directory, null if git fails (no repository).+ *+ * @param list<string> $arguments+ */+function git(array $arguments): ?string+{+ // without a shell: escapeshellarg() on Windows replaces "%" (e.g. in --format=%H); the answer goes through a+ // file, pipes are slow on Windows+ $answer = (string)\tempnam(\sys_get_temp_dir(), 'yourecom-git-');+ $process = \proc_open(+ ['git', '-C', (string)\realpath(ROOT), ...$arguments],+ [1 => ['file', $answer, 'w'], 2 => ['file', \PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w']],+ $pipes+ );+ if (!\is_resource($process)) {+ \unlink($answer);++ return null;+ }+ $status = \proc_close($process);+ $output = (string)\file_get_contents($answer);+ \unlink($answer);++ return $status === 0 ? \rtrim(\str_replace("\r\n", "\n", $output), "\n") : null;+}++/**+ * Prints the errors and exits with status 1, prints "OK" otherwise.+ *+ * @param list<string> $errors+ */+function finish(array $errors, string $summary = ''): never+{+ if ($errors !== []) {+ fail($errors);+ }++ echo 'OK' . ($summary !== '' ? " ({$summary})" : '') . "\n";++ exit(0);+}++/**+ * Prints the errors and exits with status 1.+ *+ * @param list<string> $errors+ */+function fail(array $errors): never+{+ \fwrite(\STDERR, \implode("\n", $errors) . "\n");++ exit(1);+}