aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-07-21 03:13:24 +0900
committernsfisis <nsfisis@gmail.com>2026-07-23 22:07:16 +0900
commitcec38036d6b3d5a4bd009f2eef3bd88791ab5828 (patch)
treef752079278ff48b5a0acef3caff1b1daa1424124
parent765b7749d31675d3cbf541b146ead1aafd6d6ae9 (diff)
downloadphp-shirabe-cec38036d6b3d5a4bd009f2eef3bd88791ab5828.tar.gz
php-shirabe-cec38036d6b3d5a4bd009f2eef3bd88791ab5828.tar.zst
php-shirabe-cec38036d6b3d5a4bd009f2eef3bd88791ab5828.zip
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 <noreply@anthropic.com>
-rw-r--r--docs/dev/plugin-class-classification.md20
-rwxr-xr-xscripts/plugin-class-classifier/query187
2 files changed, 207 insertions, 0 deletions
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 <target>...
+
+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
+<?php
+
+declare(strict_types=1);
+
+require __DIR__ . '/vendor/autoload.php';
+
+use Shirabe\PluginClassifier\VendorPackages;
+
+if ($argc < 2) {
+ fwrite(STDERR, <<<USAGE
+ usage: query <target>...
+
+ <target> 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<string, array<string, mixed>> */
+$classes = [];
+foreach ($report['classes'] as $row) {
+ $classes[$row['fqcn']] = $row;
+}
+/** @var array<string, array<string, mixed>> */
+$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<string> 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);