From 82a7e2f59e89e68edb8bbffb96424101f3d18719 Mon Sep 17 00:00:00 2001 From: Frankie-Xu <92643488+Frankie-Xu@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:16:45 +0800 Subject: [PATCH] fix(php): keep inferred references to vendor attribute classes (#144) Co-authored-by: Cursor --- src/graph/invariants.ts | 12 ++++++------ src/graph/resolve.ts | 14 ++++++++++++-- test/graph-php.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/graph/invariants.ts b/src/graph/invariants.ts index 4ebdc80e..2b319858 100644 --- a/src/graph/invariants.ts +++ b/src/graph/invariants.ts @@ -9,8 +9,9 @@ * - every edge source is a real node * - every edge target is a real node, EXCEPT the relations that deliberately keep * an unresolved external string (an import specifier, a bare heritage name for - * an out-of-repo supertype, or a Java annotation type with no in-repo - * `@interface`) — those are a feature of "drop rather than guess", + * an out-of-repo supertype, a Java annotation type with no in-repo + * `@interface`, or a PHP 8 attribute class that exists only via `use` / + * vendor) — those are a feature of "drop rather than guess", * not a dangling edge. * * Self-loop `calls` are NOT a violation: direct recursion is a real edge a function @@ -35,10 +36,9 @@ const CONFIDENCE = new Set([ ]); // Relations whose target may be a deliberately-unresolved external string rather // than an in-repo node id: an import's module specifier, a heritage clause naming -// a supertype defined outside the repo (or a generic type parameter), or a Java -// annotation whose type is not declared in-repo. The set is language-agnostic — -// no other producer currently leaves an unresolved `references` target, so a -// future bug elsewhere would be masked here. +// a supertype defined outside the repo (or a generic type parameter), a Java +// annotation whose type is not declared in-repo, or a PHP 8 attribute whose +// class is only imported from vendor (#144). The set is language-agnostic. const TARGET_MAY_BE_EXTERNAL = new Set(["imports", "extends", "implements", "references"]); export interface InvariantResult { diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index c78a61e6..a9e77d40 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -233,7 +233,17 @@ export function resolveEdges( const targetFile = e.file.endsWith(".php") ? resolvePhpUse(e.specifier, phpFilesBySuffix) : resolveImport(e.specifier, e.file, byId); - if (!byId.has(targetFile)) continue; // external or unresolved module + if (!byId.has(targetFile)) { + // PHP: a `use` that does not map to an in-repo file (vendor + // `#[Route]`, `#[Deprecated]`, …) still keeps an inferred references + // edge, matching Java annotations whose `@interface` is not in the + // graph (#144). Other languages keep dropping — an unresolved TS + // import is not a type use. + if (e.file.endsWith(".php") && byId.get(e.source)?.origin === "ast") { + add(e.source, e.name, "references", "inferred"); + } + continue; + } const candidates = perFileName.get(targetFile)?.get(e.name) ?? []; if (candidates.length === 1) add(e.source, candidates[0].id, "references", "extracted"); } else if (e.file.endsWith(".php") && byId.get(e.source)?.origin === "ast") { @@ -250,7 +260,7 @@ export function resolveEdges( // contains the literal `@interface` (`includes`, not `startsWith`: a // meta-annotated type is `@Documented @Retention(...) public @interface // JsonAdapter`). Unresolved targets keep the bare name, matching - // heritage, rather than dropping the way PHP attributes do. + // heritage. PHP vendor attributes now take the same inferred path. const refKinds: Kind[] = ["interface"]; const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName); const anno = hit ? byId.get(hit.id) : undefined; diff --git a/test/graph-php.test.ts b/test/graph-php.test.ts index 7253651f..c9d8a7b6 100644 --- a/test/graph-php.test.ts +++ b/test/graph-php.test.ts @@ -479,6 +479,49 @@ test("PHP extraction: attribute usage resolves to references edges (#144)", asyn } }); +// #144 remainder: a `use`d attribute class that is not defined in the repo +// (Symfony `#[Route]`, `#[Deprecated]`, …) was dropped in resolve.ts because +// `if (!byId.has(targetFile)) continue`. Java annotations in the same situation +// keep an inferred references edge to the bare name. Do not mint a vendor +// class node. +const VENDOR_ROUTE_PHP = ` { + const dir = mkdtempSync(join(tmpdir(), "graft-php-vendor-attr-")); + try { + writeFileSync(join(dir, "composer.json"), `{"name": "poc/vendor-attr"}\n`); + writeFileSync(join(dir, "Controller.php"), VENDOR_ROUTE_PHP); + await buildGraph(dir); + const graph = readGraph(wiringPath(join(dir, "graft")))!; + + assert.ok( + !graph.nodes.some((n) => n.name === "Route" && n.kind === "class"), + "must not mint a Route class node for a vendor attribute", + ); + + const refs = graph.edges.filter( + (e) => e.relation === "references" && e.source === "Controller.php#Controller.index", + ); + assert.ok( + refs.some( + (e) => + e.confidence === "inferred" && + (e.target === "Route" || e.target === "Symfony\\Component\\Routing\\Annotation\\Route"), + ), + `index should keep an inferred references edge to vendor Route, got: ${JSON.stringify(refs)}`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + // Issue #144: an anonymous class (`new class implements I {…}`) previously // produced no node and no heritage edge — its methods were mis-attributed to // the enclosing function (`…#make.run`), so the type and its interface