Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/graph/invariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,10 +36,9 @@ const CONFIDENCE = new Set<string>([
]);
// 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<string>(["imports", "extends", "implements", "references"]);

export interface InvariantResult {
Expand Down
14 changes: 12 additions & 2 deletions src/graph/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions test/graph-php.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?php
use Symfony\\Component\\Routing\\Annotation\\Route;

class Controller {
#[Route('/')]
public function index(): void {}
}
`;

test("PHP extraction: vendor attribute keeps an inferred references edge (#144)", async () => {
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
Expand Down
Loading