From c899a4675ca90507a420d901ef7fa26d8b285f23 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 21 Jul 2026 08:08:15 +0900 Subject: refactor(lint): rewrite structural linters from Ruby to PHP Fold scripts/lint and scripts/linters/*.rb into a standalone Composer project under scripts/linters/, matching the scripts/plugin-class-classifier/ convention. Uses no external packages, only PHP + Composer autoloading. Verified byte-for-byte identical output against the original Ruby implementation, both on the current repo (all linters pass) and on a synthetic fixture exercising every violation type. Entry point moves from `scripts/lint` to `scripts/linters/lint`. --- .../src/Linters/CargoWorkspaceDependencies.php | 83 ++++++++++ scripts/linters/src/Linters/ContiguousUseBlock.php | 124 +++++++++++++++ scripts/linters/src/Linters/NoBannedUse.php | 170 +++++++++++++++++++++ .../src/Linters/NoDecorativeSectionComment.php | 65 ++++++++ .../linters/src/Linters/NoFormatTrailingComma.php | 48 ++++++ scripts/linters/src/Linters/NoModRs.php | 38 +++++ .../linters/src/Linters/NoStdCollectionsMaps.php | 80 ++++++++++ scripts/linters/src/Linters/NoUseAsAlias.php | 85 +++++++++++ scripts/linters/src/Linters/SortedDependencies.php | 100 ++++++++++++ 9 files changed, 793 insertions(+) create mode 100644 scripts/linters/src/Linters/CargoWorkspaceDependencies.php create mode 100644 scripts/linters/src/Linters/ContiguousUseBlock.php create mode 100644 scripts/linters/src/Linters/NoBannedUse.php create mode 100644 scripts/linters/src/Linters/NoDecorativeSectionComment.php create mode 100644 scripts/linters/src/Linters/NoFormatTrailingComma.php create mode 100644 scripts/linters/src/Linters/NoModRs.php create mode 100644 scripts/linters/src/Linters/NoStdCollectionsMaps.php create mode 100644 scripts/linters/src/Linters/NoUseAsAlias.php create mode 100644 scripts/linters/src/Linters/SortedDependencies.php (limited to 'scripts/linters/src/Linters') diff --git a/scripts/linters/src/Linters/CargoWorkspaceDependencies.php b/scripts/linters/src/Linters/CargoWorkspaceDependencies.php new file mode 100644 index 00000000..3ad86b6f --- /dev/null +++ b/scripts/linters/src/Linters/CargoWorkspaceDependencies.php @@ -0,0 +1,83 @@ +findNonWorkspaceDeps($path, $relative)); + } + + return $errors; + } + + /** @return list */ + private function findNonWorkspaceDeps(string $path, string $relative): array + { + $errors = []; + $currentSection = null; + + foreach (file($path) as $idx => $rawLine) { + $stripped = trim($rawLine); + + if (preg_match('/\A\[([^\]]+)\]\z/', $stripped, $m)) { + $currentSection = $m[1]; + continue; + } + + if ($currentSection === null || !in_array($currentSection, self::SECTION_NAMES, true)) { + continue; + } + if ($stripped === '' || str_starts_with($stripped, '#')) { + continue; + } + + if (preg_match('/\A([A-Za-z0-9_-]+)\.workspace\s*=\s*true\b/', $stripped)) { + continue; + } + + if (preg_match('/\A([A-Za-z0-9_-]+)\s*=\s*\{(.+)\}\s*\z/', $stripped, $m)) { + [, $name, $inner] = $m; + if (preg_match('/\bworkspace\s*=\s*true\b/', $inner)) { + continue; + } + $errors[] = "{$relative}:" . ($idx + 1) . ": `{$name}` does not use `workspace = true`"; + continue; + } + + if (preg_match('/\A([A-Za-z0-9_-]+)\s*=/', $stripped, $m)) { + $errors[] = "{$relative}:" . ($idx + 1) . ": `{$m[1]}` does not use `workspace = true`"; + } + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/ContiguousUseBlock.php b/scripts/linters/src/Linters/ContiguousUseBlock.php new file mode 100644 index 00000000..c73ea6f8 --- /dev/null +++ b/scripts/linters/src/Linters/ContiguousUseBlock.php @@ -0,0 +1,124 @@ +findSplitUseBlock($path, $relative)); + } + + return $errors; + } + + /** @return list */ + private function findSplitUseBlock(string $path, string $relative): array + { + $lines = file($path); + $errors = []; + $count = count($lines); + + $i = $this->skipPreamble($lines); + if ($i === null) { + return []; + } + + while (true) { + $i = $this->consumeUseStatement($lines, $i); + if ($i >= $count) { + break; + } + + $blanks = []; + $j = $i; + while ($j < $count) { + $stripped = trim($lines[$j]); + if ($stripped === '') { + $blanks[] = $j; + $j++; + } elseif (str_starts_with($stripped, '//') || str_starts_with($stripped, '#[')) { + $j++; + } else { + break; + } + } + + if ($j < $count && preg_match(self::USE_START_RE, trim($lines[$j]))) { + foreach ($blanks as $bi) { + $errors[] = "{$relative}:" . ($bi + 1) . ': blank line splits the leading `use` block'; + } + $i = $j; + } else { + break; + } + } + + return $errors; + } + + /** @param list $lines */ + private function skipPreamble(array $lines): ?int + { + foreach ($lines as $idx => $raw) { + $stripped = trim($raw); + if (preg_match(self::USE_START_RE, $stripped)) { + return $idx; + } + if ($stripped === '' || str_starts_with($stripped, '//') || str_starts_with($stripped, '#![') || str_starts_with($stripped, '#[')) { + continue; + } + + return null; + } + + return null; + } + + /** @param list $lines */ + private function consumeUseStatement(array $lines, int $startIdx): int + { + $braceDepth = 0; + $i = $startIdx; + $count = count($lines); + + while ($i < $count) { + $line = $lines[$i]; + $braceDepth += substr_count($line, '{') - substr_count($line, '}'); + $done = $braceDepth <= 0 && str_ends_with(rtrim($line), ';'); + $i++; + if ($done) { + return $i; + } + } + + return $i; + } +} diff --git a/scripts/linters/src/Linters/NoBannedUse.php b/scripts/linters/src/Linters/NoBannedUse.php new file mode 100644 index 00000000..a6bda77e --- /dev/null +++ b/scripts/linters/src/Linters/NoBannedUse.php @@ -0,0 +1,170 @@ +findBannedUses($path, $relative)); + } + + return $errors; + } + + /** @return list */ + private function findBannedUses(string $path, string $relative): array + { + $errors = []; + $buffer = null; + $startIdx = null; + + foreach (file($path) as $idx => $raw) { + $code = explode('//', $raw, 2)[0]; + $stripped = trim($code); + + if ($buffer === null) { + if (!preg_match(self::USE_START_RE, $stripped)) { + continue; + } + $buffer = ''; + $startIdx = $idx; + } + + $buffer .= ' ' . $stripped; + if (!str_contains($buffer, ';')) { + continue; + } + + preg_match('/\buse\s+(.*?);/s', $buffer, $m); + $tree = $m[1] ?? null; + + foreach ($this->expandUseTree($tree) as $full) { + if (!in_array($full, self::BANNED_USE_PATHS, true)) { + continue; + } + $errors[] = "{$relative}:" . ($startIdx + 1) . ": `use {$full}` is banned (fully qualify as `{$full}` instead)"; + } + + $buffer = null; + } + + return array_values(array_unique($errors)); + } + + /** @return list */ + private function expandUseTree(?string $tree): array + { + if ($tree === null) { + return []; + } + + $tree = trim($tree); + $brace = strpos($tree, '{'); + + if ($brace === false) { + $stripped = $this->stripUseAlias($tree); + + return $stripped === null || $stripped === '' ? [] : [$stripped]; + } + + $prefix = trim(preg_replace('/::\s*\z/', '', substr($tree, 0, $brace))); + $inner = preg_replace('/\}\s*\z/', '', substr($tree, $brace + 1)); + + $result = []; + foreach ($this->splitTopLevel($inner) as $child) { + foreach ($this->expandUseTree($child) as $sub) { + if ($sub === '' || $sub === 'self') { + $result[] = $prefix; + } elseif ($prefix === '') { + $result[] = $sub; + } else { + $result[] = "{$prefix}::{$sub}"; + } + } + } + + return $result; + } + + private function stripUseAlias(string $segment): ?string + { + if (preg_match('/\s+as\s+_\s*\z/', $segment)) { + return null; + } + + return trim(preg_replace('/\s+as\s+\S+\s*\z/', '', $segment)); + } + + /** @return list */ + private function splitTopLevel(string $str): array + { + $parts = []; + $current = ''; + $depth = 0; + + foreach (str_split($str) as $ch) { + switch ($ch) { + case '{': + $depth++; + $current .= $ch; + break; + case '}': + $depth--; + $current .= $ch; + break; + case ',': + if ($depth === 0) { + $parts[] = $current; + $current = ''; + } else { + $current .= $ch; + } + break; + default: + $current .= $ch; + } + } + $parts[] = $current; + + return array_values(array_filter(array_map('trim', $parts), static fn (string $p): bool => $p !== '')); + } +} diff --git a/scripts/linters/src/Linters/NoDecorativeSectionComment.php b/scripts/linters/src/Linters/NoDecorativeSectionComment.php new file mode 100644 index 00000000..dba24063 --- /dev/null +++ b/scripts/linters/src/Linters/NoDecorativeSectionComment.php @@ -0,0 +1,65 @@ +findDecorativeComments($path, $relative)); + } + + return $errors; + } + + /** @return list */ + private function findDecorativeComments(string $path, string $relative): array + { + $errors = []; + + foreach (file($path) as $idx => $raw) { + $stripped = ltrim($raw); + if (!str_starts_with($stripped, '//')) { + continue; + } + if (str_starts_with($stripped, '///') || str_starts_with($stripped, '//!')) { + continue; + } + if (!preg_match(self::DECORATIVE_RUN_RE, $stripped)) { + continue; + } + + $errors[] = "{$relative}:" . ($idx + 1) . ': decorative section comment'; + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/NoFormatTrailingComma.php b/scripts/linters/src/Linters/NoFormatTrailingComma.php new file mode 100644 index 00000000..dc364846 --- /dev/null +++ b/scripts/linters/src/Linters/NoFormatTrailingComma.php @@ -0,0 +1,48 @@ + $raw) { + if (!str_contains($raw, ',)')) { + continue; + } + // `(,)` is a macro repetition fragment (e.g. `$(,)?`). + if (str_contains($raw, '(,)')) { + continue; + } + + $errors[] = "{$relative}:" . ($idx + 1) . ': trailing `,)` before a closing paren'; + } + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/NoModRs.php b/scripts/linters/src/Linters/NoModRs.php new file mode 100644 index 00000000..248958e1 --- /dev/null +++ b/scripts/linters/src/Linters/NoModRs.php @@ -0,0 +1,38 @@ +.rs` instead of `/mod.rs`:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::modRsFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + $errors[] = $relative; + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/NoStdCollectionsMaps.php b/scripts/linters/src/Linters/NoStdCollectionsMaps.php new file mode 100644 index 00000000..c2f9c32e --- /dev/null +++ b/scripts/linters/src/Linters/NoStdCollectionsMaps.php @@ -0,0 +1,80 @@ +findStdMapUsages($path, $relative)); + } + + return $errors; + } + + /** @return list */ + private function findStdMapUsages(string $path, string $relative): array + { + $errors = []; + + foreach (file($path) as $idx => $raw) { + $code = explode('//', $raw, 2)[0]; + + if (preg_match_all('/\bstd::collections::(HashMap|HashSet|BTreeMap|BTreeSet)\b/', $code, $m)) { + foreach ($m[1] as $name) { + $errors[] = "{$relative}:" . ($idx + 1) . ": use of `std::collections::{$name}` (use `indexmap::" . self::indexmapReplacement($name) . '` instead)'; + } + } + + if (preg_match_all('/\bstd::collections::\{([^}]*)\}/', $code, $m)) { + foreach ($m[1] as $group) { + foreach (explode(',', $group) as $entry) { + $name = preg_split('/\s+as\s+/', trim($entry))[0]; + if (!in_array($name, self::BANNED_MAP_NAMES, true)) { + continue; + } + $errors[] = "{$relative}:" . ($idx + 1) . ": import of `std::collections::{$name}` (use `indexmap::" . self::indexmapReplacement($name) . '` instead)'; + } + } + } + } + + return array_values(array_unique($errors)); + } + + private static function indexmapReplacement(string $name): string + { + return match ($name) { + 'HashMap', 'BTreeMap' => 'IndexMap', + 'HashSet', 'BTreeSet' => 'IndexSet', + default => throw new \LogicException("unexpected map name: {$name}"), + }; + } +} diff --git a/scripts/linters/src/Linters/NoUseAsAlias.php b/scripts/linters/src/Linters/NoUseAsAlias.php new file mode 100644 index 00000000..c725630c --- /dev/null +++ b/scripts/linters/src/Linters/NoUseAsAlias.php @@ -0,0 +1,85 @@ +findUseAliases($path, $relative)); + } + + return $errors; + } + + /** @return list */ + private function findUseAliases(string $path, string $relative): array + { + $errors = []; + $inUse = false; + $braceDepth = 0; + + foreach (file($path) as $idx => $raw) { + $code = explode('//', $raw, 2)[0]; + $stripped = trim($code); + + if (!$inUse) { + if (!preg_match(self::USE_ALIAS_START_RE, $stripped)) { + continue; + } + $inUse = true; + $braceDepth = 0; + } + + if (preg_match_all('/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/', $code, $m)) { + foreach ($m[1] as $name) { + if ($name === '_') { + continue; + } + if (preg_match(self::PASCAL_CASE_RE, $name)) { + continue; + } + $errors[] = "{$relative}:" . ($idx + 1) . ": `as {$name}` aliasing in `use` statement"; + } + } + + $braceDepth += substr_count($code, '{') - substr_count($code, '}'); + if ($braceDepth <= 0 && str_ends_with(rtrim($code), ';')) { + $inUse = false; + $braceDepth = 0; + } + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/SortedDependencies.php b/scripts/linters/src/Linters/SortedDependencies.php new file mode 100644 index 00000000..78ea0eb2 --- /dev/null +++ b/scripts/linters/src/Linters/SortedDependencies.php @@ -0,0 +1,100 @@ +parseDepSections(file_get_contents($path)); + + foreach (['dependencies', 'dev-dependencies'] as $section) { + $deps = $sections[$section] ?? []; + if ($deps === []) { + continue; + } + + $expected = $this->sortDepNames($deps); + if ($deps === $expected) { + continue; + } + + $errors[] = "{$relative} [{$section}]\n" + . ' actual: ' . implode(', ', $deps) . "\n" + . ' expected: ' . implode(', ', $expected); + } + } + + return $errors; + } + + /** @return array> */ + private function parseDepSections(string $content): array + { + $sections = []; + $current = null; + + foreach (explode("\n", $content) as $line) { + $stripped = rtrim($line, "\r\n"); + + if (preg_match('/\A\s*\[([^\]]+)\]\s*\z/', $stripped, $m)) { + $current = $m[1]; + $sections[$current] ??= []; + continue; + } + + if ($current !== null && preg_match('/\A([A-Za-z0-9_-]+)\s*[.=]/', $stripped, $m)) { + $sections[$current][] = $m[1]; + } + } + + return $sections; + } + + /** + * @param list $deps + * @return list + */ + private function sortDepNames(array $deps): array + { + $shirabe = []; + $other = []; + + foreach ($deps as $dep) { + if (str_starts_with($dep, 'shirabe-')) { + $shirabe[] = $dep; + } else { + $other[] = $dep; + } + } + sort($shirabe); + sort($other); + + return array_merge($shirabe, $other); + } +} -- cgit v1.3.1