From cec38036d6b3d5a4bd009f2eef3bd88791ab5828 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 21 Jul 2026 03:13:24 +0900 Subject: feat(plugin-class-classifier): add query helper for single-class lookups Answers how a given class is treated at the plugin boundary, accepting a PHP source file, a Rust source file, or a (short or fully qualified) class name. Reads report.json and generates it first when missing. Rust paths resolve by normalized segment matching because the snake_case mapping is not reversible for acronyms (io_interface.rs -> IOInterface). Co-Authored-By: Claude Fable 5 --- docs/dev/plugin-class-classification.md | 20 ++++ scripts/plugin-class-classifier/query | 187 ++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100755 scripts/plugin-class-classifier/query diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md index db48880f..263161cb 100644 --- a/docs/dev/plugin-class-classification.md +++ b/docs/dev/plugin-class-classification.md @@ -445,6 +445,26 @@ workflow on a Composer upgrade: run the classifier before updating the diff the two files — review only the changed rows. A brand-new class lands in a category (or in the violation list) without any human re-derivation. +### Querying a single class + + scripts/plugin-class-classifier/query ... + +answers "how is this class treated?" for one or more targets, from +`report.json` (generated on the fly when missing; staleness is not +checked). A target may be a PHP source file, a Rust source file, or a class +name — fully qualified or just the short name, case-insensitive: + + query composer/src/Composer/Util/Git.php + query crates/shirabe/src/io/io_interface.rs + query Locker + +Rust paths are resolved by normalized segment matching against the report's +FQCNs rather than textual case conversion, because the snake_case mapping +is not reversible for acronyms (`io_interface.rs` → `IOInterface`). Vendor +class names resolve at package granularity. The output shows category +(including an overridden category's computed value), direction, +reachability, reasons, attributes, and the mutator methods. + ### Analysis limits All conservative: diff --git a/scripts/plugin-class-classifier/query b/scripts/plugin-class-classifier/query new file mode 100755 index 00000000..906c1022 --- /dev/null +++ b/scripts/plugin-class-classifier/query @@ -0,0 +1,187 @@ +#!/usr/bin/env php +... + + is any of: + a PHP source file composer/src/Composer/Util/Git.php + a Rust source file crates/shirabe/src/util/git.rs + a class name Composer\\Util\\Git, Git, git + + Prints the plugin-boundary classification from report.json, + generating the report first if it does not exist. + + USAGE); + exit(2); +} + +$reportPath = __DIR__ . '/report.json'; +if (!is_file($reportPath)) { + fwrite(STDERR, "report.json not found; running classify...\n"); + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/classify') . ' > /dev/null', $_, $status); + if (!is_file($reportPath)) { + fwrite(STDERR, "classify failed to produce report.json (exit $status)\n"); + exit(1); + } + if ($status !== 0) { + fwrite(STDERR, "note: classify exited with $status (violations present); report was still written\n"); + } +} + +$report = json_decode(file_get_contents($reportPath), true); +if (!is_array($report)) { + fwrite(STDERR, "cannot parse $reportPath\n"); + exit(1); +} + +/** @var array> */ +$classes = []; +foreach ($report['classes'] as $row) { + $classes[$row['fqcn']] = $row; +} +/** @var array> */ +$vendorTypes = []; +foreach ($report['vendorTypes'] as $row) { + $vendorTypes[$row['fqcn']] = $row; +} + +/** Path-segment form used to match Rust paths: no underscores, lowercase. */ +function normalizeSegment(string $segment): string +{ + return strtolower(str_replace('_', '', $segment)); +} + +/** @return list matched FQCNs */ +function resolveTarget(string $target, array $classes): array +{ + // Rust source file: match normalized path segments against FQCNs, + // because the snake_case mapping is not textually reversible + // (io_interface.rs -> IOInterface). + if (str_ends_with($target, '.rs')) { + $path = preg_replace('#^.*?crates/shirabe/src/#', '', str_replace('\\', '/', $target)); + $segments = explode('/', substr($path, 0, -strlen('.rs'))); + $wanted = array_map(normalizeSegment(...), $segments); + $matches = []; + foreach ($classes as $fqcn => $_) { + $fqcnSegments = explode('\\', $fqcn); + array_shift($fqcnSegments); // Composer\ + if (array_map(normalizeSegment(...), $fqcnSegments) === $wanted) { + $matches[] = $fqcn; + } + } + + return $matches; + } + + // PHP source file: the path under src/Composer/ is the FQCN. + if (str_ends_with($target, '.php')) { + $path = str_replace('\\', '/', $target); + $pos = strpos($path, 'src/Composer/'); + if ($pos !== false) { + $fqcn = 'Composer\\' . str_replace('/', '\\', substr($path, $pos + strlen('src/Composer/'), -strlen('.php'))); + if (isset($classes[$fqcn])) { + return [$fqcn]; + } + } + // Fall back to the basename as a short class name (also covers + // vendor files and paths given without src/Composer/). + $target = basename($path, '.php'); + } + + // Class name: exact FQCN first, else case-insensitive, else by short + // name (last segment). + $name = ltrim($target, '\\'); + if (isset($classes[$name])) { + return [$name]; + } + $matches = []; + foreach ($classes as $fqcn => $_) { + if (strcasecmp($fqcn, $name) === 0) { + $matches[] = $fqcn; + } + } + if ($matches !== []) { + return $matches; + } + foreach ($classes as $fqcn => $_) { + $parts = explode('\\', $fqcn); + if (strcasecmp(end($parts), $name) === 0) { + $matches[] = $fqcn; + } + } + + return $matches; +} + +function printRow(array $row): void +{ + echo $row['fqcn'], "\n"; + echo ' category: ', $row['category']; + if (isset($row['computedCategory'])) { + echo ' (computed: ', $row['computedCategory'], ', overridden)'; + } + echo "\n"; + echo ' direction: ', $row['direction'] ?? '-', "\n"; + echo ' reachable: ', $row['reachable'] ? 'yes' : 'no', "\n"; + foreach ($row['reasons'] as $reason) { + echo ' reason: ', $reason, "\n"; + } + foreach ($row['attributes'] as $key => $value) { + echo ' ', $key, ': ', is_array($value) ? implode(', ', $value) : var_export($value, true), "\n"; + } + if (isset($row['methods'])) { + $mutators = array_values(array_filter($row['methods'], static fn (array $m) => $m['purity'] === 'mutator')); + echo ' methods: ', count($row['methods']), ' public/protected, ', count($mutators), " mutator\n"; + foreach ($mutators as $m) { + echo ' mutator: ', $m['name'], "\n"; + } + } +} + +$exit = 0; +foreach (array_slice($argv, 1) as $i => $target) { + if ($i > 0) { + echo "\n"; + } + + $matches = resolveTarget($target, $classes); + if ($matches !== []) { + foreach ($matches as $j => $fqcn) { + if ($j > 0) { + echo "\n"; + } + printRow($classes[$fqcn]); + } + continue; + } + + // Vendor types: reached ones carry a direction; any other name under a + // known vendor prefix classifies at package granularity. + $name = ltrim($target, '\\'); + if (isset($vendorTypes[$name])) { + $row = $vendorTypes[$name]; + echo $row['fqcn'], "\n"; + echo ' category: ', $row['category'], ' (vendor package ', $row['package'], ")\n"; + echo ' direction: ', $row['direction'], "\n"; + continue; + } + $vendor = VendorPackages::lookup($name); + if ($vendor !== null) { + echo $name, "\n"; + echo ' category: ', $vendor['category'], ' (vendor package ', $vendor['package'], ", wholesale)\n"; + continue; + } + + fwrite(STDERR, "not found: $target\n"); + $exit = 1; +} + +exit($exit); -- cgit v1.3.1