diff --git a/CHANGELOG.md b/CHANGELOG.md index eedcfdc4a..8c308bcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/Graphify-Labs/graphify/releases) +## 0.9.92 (unreleased) + +Consolidated PHP-support and resolution work from the [`lawnstarter/graphify`](https://github.com/lawnstarter/graphify) fork — #1682 and everything found while fixing it. The full defect report is #2615. The groups below are ordered as the work landed on the fork, so a later group can lift a refusal an earlier group states; those spots are marked. + +**PHP member-call resolution, cross-language isolation, and import-edge repair:** + +- Feature: PHP instance-method calls on a typed receiver now resolve to the method they really reach, instead of a bare same-name match (#1682). `$this->prop->method()` binds to the type declared on the property — including a constructor-promoted param — and the same typing covers nullsafe receivers (`$obj?->method()`), natively typed parameters, and `$var = new T()` locals. `(new Service())->method()` resolves too and is the one form tagged EXTRACTED (1.0), since the class is named right there in the source; the promotion applies only when the written namespace corroborates the class node found for it, compared against the namespace the defining file DECLARES rather than against its PSR-4 path (a file at `app/Services/Client.php` may well declare `namespace App\Vendor;`). Every other typed receiver is INFERRED (0.85). The motivating case is a Laravel corpus: `$this->leadHunter->search(...)` used to reach nothing, and now reaches `App\Services\LeadHunterService::search`. +- Refuse-don't-guess is the policy wherever the receiver's type is not provably one concrete in-corpus class, so all of these deliberately emit NO edge: untyped, union-typed (`A|B`) and intersection-typed (`A&B`) receivers; receivers typed by an `interface`, `enum` or `trait` — none of which mints a definition node, so binding one would pick a same-short-named stranger (the `App\Contracts\Notifier` vs `App\Support\Notifier` collision, or `App\Enums\Status` beside an Eloquent `App\Models\Status`), and that refusal now survives an incremental rebuild by persisting those names on the declaring file's node (this refusal is lifted by the resolution-gaps group below, once interface/trait/enum declarations mint their own nodes); a short type name that does not match exactly one class in the corpus; a method the receiver's own class does not declare, so `__call` magic dispatch fabricates nothing; chained (`$this->factory()->method()`) and array-element (`$bag['k']->method()`) receivers; a local rebound to anything but a matching `new`, or rebound to other storage by `global`/`static`; a name shadowed by a closure or arrow-function parameter, a `foreach` target, or list destructuring; anonymous classes (`new class { ... }`); and `self`/`static`/`parent` in type position, which need inheritance context the raw-call facts do not carry. +- Behavior change: a same-file call through a typed receiver moves from EXTRACTED to INFERRED (0.85). Those calls used to be minted by the in-file bare-name matcher, which cannot tell the property's declared type apart from any other class in the file; they are now routed through the receiver-typed resolver, which is right more often but no longer claims to be certain. Untyped receivers keep their existing in-file behavior, so this is a confidence change on typed receivers only, not a drop in edge count. One asymmetry is visible in the output and worth knowing about: a fully qualified `(new \App\Services\Client())->method()` is EXTRACTED, while the same name written as a local (`$c = new \App\Services\Client(); $c->method();`) stays INFERRED — the inline form is corroborated against the declared namespace, the local form is typed through the method-scoped table and is not. +- Fix: the PHP and Objective-C member-call resolvers no longer match a receiver's type against class definitions written in ANY language; each index is scoped to its own source suffixes. This cut both ways in a polyglot corpus, so it is two fixes: a Python `class Lead` could be bound as a PHP or ObjC receiver's type and mint a cross-language edge, and a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore also GAIN PHP and ObjC edges that a name collision previously deleted. The ObjC half is a pre-existing defect of the same shape that rides along with the PHP work; the Java and C# halves are fixed by the next bullet, and the C++, Swift, TypeScript and Python halves by the one after it. +- Fix: a member-call resolver no longer mints edges out of another language's data (`lawnstarter/graphify#10`), which is again two fixes. First, the Swift, Python and TypeScript resolvers claimed a raw call by skipping anything carrying a `lang` tag — but only the cpp, csharp, java, objc and php extractors stamp one, and those three resolvers are themselves untagged, so they consumed each other's raw calls: a TypeScript `Lead.search({})` reached the Python resolver's capitalized-receiver class arm and minted an EXTRACTED edge into a Python method with no TypeScript `Lead` anywhere in the corpus. Each now consumes only raw calls written in the source files it owns, a positive suffix filter that is closed by construction rather than a hardcoded list of languages to exclude; the tagged languages keep matching on `lang`, because C++ and ObjC share `.h` and a suffix cannot tell their raw calls apart. Second, the Java and C# receiver-type indexes are now scoped to their own sources, the last copies of the shape the previous bullet fixed for PHP and ObjC: a Java `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED. Polyglot corpora therefore LOSE cross-language member-call edges that were always wrong — including some labelled EXTRACTED, the strongest confidence — and GAIN Java and C# edges that a foreign class merely sharing a short name previously deleted. Single-language corpora are unaffected: every filter added here admits everything such a corpus contains. Both defects predate the receiver-typed PHP work; the same index exposure in the C++, Swift, TypeScript and Python resolvers is closed by the next bullet. +- Fix: the last four receiver-type indexes — C++, Swift, TypeScript and Python — are now scoped to their own sources too, so no member-call resolver matches a receiver's declared type against class definitions written in another language any more (`lawnstarter/graphify#24`). This is the same two-way defect the PHP/ObjC and Java/C# bullets above describe, in its remaining copies: a C++ `Lead lead; lead.search()` bound to a Python `class Lead` at INFERRED, a Swift `let lead: Lead` and a TypeScript `private lead: Lead` did the same, and — in the other direction — a foreign class merely SHARING a short name pushed the single-definition guard to 2 and silently suppressed the correct same-language edge. Polyglot corpora therefore LOSE these cross-language edges, which were always wrong, and GAIN C++, Swift, TypeScript and Python edges that a foreign short-name collision previously deleted. Single-language corpora are unaffected: the filter admits everything such a corpus contains. Two notes on the edges of this change. `.h` is scoped into BOTH the C++ and the Objective-C index, because it routes to either extractor by content and a C++ class and an ObjC `@interface` both live in a header — the two languages are therefore isolated from every other language but not from each other, which no suffix can fix, and raw-call ownership for them stays on the extractor-stamped `lang` for exactly that reason. And Python is scoped on both of its arms, not just the class index: its `module.func()` arm matched any corpus file whose stem equalled the receiver, so `import lead` beside a `lead.ts` bound the call to a TypeScript function at EXTRACTED. All ten resolvers now share one `_is_owned_definition` predicate keyed off the same per-resolver suffix tuple that registers the resolver, so the registration and the scoping cannot drift. Pre-existing, not a regression. +- Fix: an `imports` edge no longer vanishes when any same-stem file sits beside its target, in Python, Rust, Zig, Elixir, PowerShell, Pascal and Bash (`lawnstarter/graphify#33`). Each of these names an import's target by the imported file's bare stem id (`import lead` -> `lead`), which resolves only while that id is unique: add a `lead.md` and the two file nodes collide, so id-disambiguation salts them into `lead_py_lead` and `lead_md_lead` while the edge — keyed by the importer's own file rather than the target's — was left pointing at an id that no longer named anything, and was dropped along with everything downstream of it (Python's `module.func()` call resolution among them). These edges now stamp the `target_file` hint the disambiguator already accepts for this (#1814), so the salt lands on the right file; an import written in one language can only mean a file of that language, so the choice stays unambiguous whatever the collider is. The hint is stamped centrally rather than in each extractor, because an extractor sees one file and cannot know which of the corpus's same-stem candidates the id will end up naming; it is transient and popped by its only reader, so it never reaches `graph.json`. An id claimed by more than one importable file of the same language is left dangling, as before, and a corpus with no collision is bit-identical — the hint only ever selects among the salted variants of an id the edge already named, so it cannot change WHICH node an edge resolves to. Bash additionally emitted the edge twice under a collision — once correct, once dangling — because its second producer in `resolve_bash_source_edges` derives ids from the path after disambiguation has already renamed them; that pass now reads the ids as they actually stand, which also repairs the source-backed `calls` edges it resolves. TypeScript/JavaScript and C/C++/Objective-C already had equivalent protection; Julia, Fortran and Verilog target an importer-scoped node and were never exposed; Dart mints its own stub nodes and Ruby/PHP emit no file-targeting import edges. Pre-existing, not a regression. +- Known recall gaps in PHP member-call resolution, all consequences of refusing rather than guessing: a method reached through a `trait` the receiver's class `use`s gets no edge (traits mint no definition node, so the class carries no `method` edge for it); a method inherited from a cross-file parent class gets no edge (the `inherits` chain is not walked — C# is currently the only resolver that does); an `enum`'s methods are unreachable as call targets for the same reason the enum-typed receiver is refused (lifted by the later groups below, once enums mint declaration nodes and enum-typed receivers bind); and typed parameters are read only inside class methods, so a top-level `function helper(Service $s) { $s->method(); }` resolves nothing. One residual false-positive risk was named here — a property typed through a `use` alias that points OUTSIDE the corpus, while exactly one unrelated class of that short name exists INSIDE it, satisfying the single-definition guard and minting a wrong INFERRED edge — and is closed by the `use`-map fix below. Java still has the identical exposure. +- Fix: a union- or intersection-typed PHP receiver no longer mints a bare-name `calls` edge when the candidate methods live in the SAME file as the call (`lawnstarter/graphify#9`). The refusal above already held across files, but the legacy in-file matcher derived its decision from whether a type had been STAMPED, which made "annotation refused" indistinguishable from "no annotation" — so `private Alpha|Beta $svc; $this->svc->run();` bound to whichever `run()` the file's label index saw last, by file order, at EXTRACTED confidence. The receiver table now tells the two apart, and a refused multi-class annotation defers to the receiver-typed resolver, which emits nothing for an unstamped receiver. **Deletion scope**, stated deliberately because deferring removes edges that exist today: the ONLY edges removed are same-file bare-name edges whose receiver is declared as a union (`A|B`) or an intersection (`A&B`) — including `A|null`, which is semantically `?A` but is a union node, and so loses its same-file edge rather than resolving as one concrete type. Everything else the concrete-type policy also refuses is deliberately left on the in-file arm, because none of it declares MULTIPLE candidate classes: `self`/`static`/`parent` (which name the calling class, whose methods usually ARE the in-file match), primitives, and `mixed`/`object`/`iterable`/`callable`. Genuinely untyped receivers and `$this->method()` are untouched. +- Fix: a PHP 8.2 disjunctive-normal-form property type (`private (A&B)|C $x;`) is no longer skipped outright (`lawnstarter/graphify#9`). DNF parses as its own AST node, which the property and promoted-param scanners did not name among the type shapes they accept, so such a property was invisible twice over: it minted the same-file bare-name `calls` edge the fix above removes (a DNF type is a union at top level, so it has no single receiver class either), and its classes got no `references` edge at all. It now refuses like a union, and references A, B and C like one. +- Behavior change: a PHP 8.1 first-class callable (`$obj->method(...)`, plus the nullsafe `$obj?->method(...)` and `$this->method(...)` forms) now emits `indirect_call` instead of `calls` (`lawnstarter/graphify#15`). The syntax creates a `Closure` — it names the method without invoking it — so the edge moves to the relation this repo already uses for a callback passed by name, and `calls` keeps meaning "control flow transfers here". Target resolution is untouched: the same receiver typing, the same refusals, the same confidence (INFERRED 0.85 through a typed receiver, EXTRACTED 1.0 for `$this`), only the relation differs. Ordinary invocations are unaffected, including the spread form `$obj->method(...$args)`, which is a real call; when a caller both invokes and references the same method, the `calls` edge wins the pair. Detection is keyed on the argument list being exactly the `...` placeholder (`variadic_placeholder`) on the pinned grammar, tree-sitter-php 0.24.1. Consumers that query `relation == "calls"` will no longer see these edges; blast-radius/`affected` output is unchanged, since it already includes `indirect_call`. +- Fix: a group-form `use function A\{f, g};` or `use const A\{K};` no longer claims `f`, `g` or `K` as an imported CLASS name (`lawnstarter/graphify#26`). tree-sitter-php puts the `function`/`const` keyword on the *declaration* for the braced form but on the *clause* for the unbraced one, and the PHP type-reference pass only ever inspected the clause — so a group-imported function or constant whose short name was also used in a class position in the SAME file re-pointed that `inherits`/`implements`/`mixes_in`/`imports`/`references` edge onto an external stub labeled with an FQN that names a function or a constant, not a class. Both spellings now agree: the reference falls back to the namespace-relative FQN or to the legacy unique-label rewire, exactly as the unbraced form always did. Strictly subtractive — it can only REMOVE a class-name claim, never add one — and it needs the same short name used both ways in one file, which is why it is rare in practice. Pre-existing; not a regression from the `use`-metadata capture. +- Fix: a PHP member call through a receiver whose type name the calling file CLAIMED no longer falls back to a same-short-named stranger elsewhere in the corpus (`lawnstarter/graphify#16`). A file that writes `use Vendor\Sdk\Client;` has already said which `Client` it means, but the resolver never read `use` statements — so `private Client $c; $this->c->send();` bound the lone unrelated `App\Local\Client`, which satisfied the single-definition guard, and minted an `INFERRED 0.8` edge into a class the file never imported. A new `PhpNameResolver` (the PHP twin of the C# one) resolves the claimed name against the corpus and is consulted in FRONT of the corpus-wide short-name index, so a claim that lands on no in-corpus class refuses instead of guessing. Annotations written out qualified are read the same way, including the namespace-relative form: inside `namespace App\Http`, `private Local\Client $c;` means `App\Http\Local\Client` and not `App\Local\Client`. **Deletion scope**, stated deliberately because this change only ever removes edges: the ONLY edges removed are `calls`/`indirect_call` edges through a receiver whose short type name the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — where that claim does not name a class in the corpus. Everything else is untouched. An unclaimed short name still resolves through exactly the same fallback as before; `use function` / `use const` claim no class name in either spelling; and a claim is only ever contradicted by hard evidence — the namespace the defining file DECLARES, or that file's PSR-4 path when it was not dispatched this run, where a path SHORTER than the written name is read as a stripped composer prefix rather than as a contradiction, so incremental rebuilds agree with full ones instead of quietly deleting more. Binding an alias to the right one of several same-short-named classes is a recall ADDITION and deliberately not part of this change. +- Feature: a PHP member call through a receiver whose type name the calling file claims now BINDS to the class whose defining file declares exactly that fully qualified name (`lawnstarter/graphify#22`), completing the `use`-map work above with its recall half. Two things resolve that used to refuse: `use App\Alpha\X;` with `private X $c;` selects `App\Alpha\X` among several same-short-named in-corpus classes (the single-definition guard previously refused the pair), and a renaming alias `use App\Alpha\X as Y;` with `private Y $c;` follows the alias to its target, which no short-name census could find because nothing in the corpus is named `Y`. The match is against the namespace each defining file DECLARES (the same #14 facts the inline-`new` corroboration reads), folded case-insensitively like every other PHP name; an FQN declared identically by two files poisons that entry rather than guessing between them. **Addition scope**, stated deliberately as the mirror of the deletion scope above: this change only ever adds or re-points `calls`/`indirect_call` edges whose receiver type the calling file claims — through `use`, `use ... as`, a group `use`, or a qualified annotation — and whose claimed FQN matches a declared one exactly. An unclaimed short name still resolves through the same unique-short-name fallback as before, and every #16-style refusal stands where no declared FQN matches. New edges are INFERRED (0.85), like every receiver-typed binding. +- To make those bindings survive `graphify update`, each PHP file node now persists the FQNs its classes are declared under as a `_php_class_fqns` marker in `graph.json` (`lawnstarter/graphify#23`), riding the same channel as `_callable` (#2438) and `_php_non_class_types` (#11/#12): the watcher and `graphify update` hand the marker back on the resolution-context nodes, and the resolver rebuilds its declared-FQN index from it for every file the incremental pass left untouched. Without this, every edge #22 adds would exist on full builds only and silently vanish on the first incremental rebuild — the exact failure mode #11 closed for the interface refusal. A `graph.json` written before the marker fails closed, matching the established precedent: the binding is simply absent (never redirected to a namesake, and the #16 refusals still stand on path evidence) until the defining file is re-extracted or `graphify update` runs on it once. + +**Natural-language query seeding (#2507 follow-ups):** + +- Change: the relational-verb demotion that 0.9.35 shipped for #2507 is restructured and widened (`lawnstarter/graphify#37`). A relational word in a natural-language `graphify query` — "who **calls** X", "**callers** of X", "what **uses** X" — names the *relation* the question asks about, not a symbol to start from, so it forfeits the unconditional per-term seed slot (#1445) — **and nothing else**: relational words are NOT stopworded (upstream deliberately pinned "calls" as a kept content term in #1597), they still score, and they still win seeds through the gap window on merit, so a corpus symbol literally named `calls` or `uses` keeps its exact-match dominance and stays seedable. The vocabulary is now derived from the two tables that already encode relational intent (`_CONTEXT_HINTS` words plus `_CONTEXT_FILTER_ALIASES` keys), extended with the caller/use/listen families, and unioned with the twelve words the previous hardcoded list carried that neither table yields — a strict superset of the 0.9.35 set (62 words vs 29). Demotion runs through a `_demote_relational_intent_terms` helper that keeps the map-keyed all-relational fallback, so a query made of nothing *but* relational words (`graphify query "calls"`) keeps its guarantee unfiltered, mirroring the existing all-stopword fallback in query-term extraction. The five #2507 tests pass against the restructured implementation unchanged. +- Fix: a query term that an already-picked seed's label plainly matches no longer claims a second, redundant guaranteed seed (`lawnstarter/graphify#37`). The #1445 guarantee exists to stop one term's incidental exact match from starving the other terms, and that protection is unchanged for a genuinely starved term; what changes is the invariant it enforces, from *every term's own singleton winner gets a slot* to *every term with any match is matched by at least one seed*. A term that is a substring of a picked seed's normalized label — the same predicate as the scorer's weakest match tier, so the seed provably would have matched the term in scoring — is by definition not starved. Without the skip, each generic noun in a question dragged in its own hub: "what code uses ChargeCustomerService to charge a customer" seeded the `Customer` model and a `.charge()` method on top of `ChargeCustomerService`, whose label already matches both terms, and the hub's member fan-out then flooded the traversal (a measured 2,307-node traversal on a 48.5k-node corpus). Coverage is judged on the seed's normalized **label** alone and never on its node id: a labelless node is real (an alias-only node enters the graph with no label) and is still seedable through the source-file scoring tier, so reading its id's path fragments would declare unrelated terms covered on a match the scorer never made — and starve the term's real winner, breaking the very invariant this refines. The check runs after the existing homonymous-label dedup gate (#1766) and in the same sorted-term order, so a generic label still contributes at most one seed. +- Fix: a *heuristically inferred* context filter that reaches nothing beyond the seeds now relaxes to an unfiltered traversal instead of returning a confident near-empty answer (`lawnstarter/graphify#37`). "Who calls ChargeCustomerService?" infers a `call` filter, but a class node owns no call edges — calls attach to its methods, and the class-to-member edge carries no context — so the filtered traversal could not leave the seed even with perfect seeding. The threshold is deliberately zero expansion (the traversal discovered nothing at all, which needs no tuning constant; a filter that found *something* is doing its job), it applies identically in breadth-first and depth-first mode, and the header says so — `Context: call (heuristic; relaxed — no matches beyond seeds)` — so a relaxed answer never reads as a filtered one. An **explicitly** requested `--context` filter is an instruction, not a guess, and is honored even when it strands the seeds. +- Serve-side only: these three changes are opt-ins wired exclusively from the natural-language query pipeline, so the scorer stays byte-identical (its legacy-equality property tests and the query-scoring benchmark's equality gate hold without edits) and every other caller of the seed picker is untouched by construction. Nothing in extraction, merge or the graph format changes, so no rebuild or re-extraction is needed to pick this up — though the package version bump does roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`) as every bump does, which for this release only means the next file dispatched for extraction is re-parsed rather than served from cache. +- Together these three take the motivating impact questions from wrong-but-confident to correct on a real 48.5k-node Laravel corpus, where the queried service has exactly 3 grep-verified production callers: "Who calls ChargeCustomerService?" rendered 0 of the 3 (a budget's worth of the decoy's test neighborhood instead), "callers of ChargeCustomerService" found all 3 but buried them in an unrelated seed's haystack, and "what code uses ChargeCustomerService to charge a customer" rendered 0 of 3 out of a multi-thousand-node traversal explosion; all three now render all 3 callers within the default token budget. One residual is documented rather than chased: a generic noun like "code" can still guarantee a seed for a doc file that merely prefix-matches it (a `coder.md`), contributing a small minority of the traversal — below every acceptance bar, and complementary upstream work on test-path seed exclusion (#2381) and bounded per-term recovery (#2113) would shrink it further. +- Fix: only the **top-ranked** seed can now declare a query term covered, refining the covered-term guarantee skip above (`lawnstarter/graphify#37` C2; adopted from the #2516 review). The initial skip let *any* already-picked seed absorb a term whose letters its label happened to contain, which reintroduced starvation one layer down: `ReportService` contains "port", so a corpus symbol literally named `port` — outscoring it ~7,300x as the term's own singleton winner — lost the guaranteed seat that was its only way into the seed list, because an unrelated runner-up had claimed the term by coincidence. Coverage asserts "the query's dominant match already answers this term", and that is a claim only the query's dominant match is entitled to make. The invariant is restated accordingly, from *every term with any match is matched by at least one seed* to **every term with any match is matched by the top-ranked seed or by a seed of its own**. Everything else about the skip is unchanged: the same normalized-**label** predicate (never the node id), a seed with no label still covers nothing, the generic-noun hub suppression that motivated #37/C2 still fires because the covering seed there is the top-ranked one, and every `_pick_seeds` caller outside the natural-language query pipeline still keeps the legacy guarantee untouched. The three measured impact phrasings on the 48.5k-node Laravel corpus are byte-identical before and after. + +**PHP node identity and lookup:** + +- Fix: a PHP `interface`, `trait` or `enum` now mints a declaration node, exactly as a `class` does (`lawnstarter/graphify#47`, RC1 of `#46`). `_PHP_CONFIG.class_types` held `class_declaration` alone, so **no node was ever created for any of the three** — 142 interfaces, 30 traits and 119 enums (291 declarations) on the pinned 46,406-node api.lawnstarter.com corpus. Every resolution pass that could canonicalize an edge therefore had nothing to land on, and the fan-in scattered three ways: the `implements`/`extends`/trait-`use` base minted a bare *sourceless* stub (`balanceitemrepository`) which, having an empty source key, kept the un-salted id and so shadowed the real name — `graphify explain "BalanceitemRepository"` answered from that degree-1 stub while `graphify affected` on the same name refused with "No unique node match"; `Foo::CONST` fan-in fragmented across one salted per-file stub per referencing file (17 of them, holding 20 `references_constant` edges, for that one interface); and `imports`/parameter-type `references` parked on the *file* node by the PSR-4 id-collision accident that makes `_make_id(FQN)` equal the file id — or, when the filename differs from the type name (a trait in `Extras.php`), on a sourceless FQN-labeled stub instead. The three kinds join `class_declaration` in `class_types`, mirroring Java and Groovy, which have always had `interface_declaration`. Two grammar details ride along: an enum's body is an `enum_declaration_list` rather than the `declaration_list` every other PHP declaration uses, so `body_fallback_child_types` learns it; and the `_resolve_php_type_references` raw-scan — which reads the written extends/implements/`use` text so a qualified name is resolved as written instead of guessed — scanned `class_declaration` bodies only, so `interface Reader extends Sub\Repo` and `enum Status { use Sub\Describes; }` recorded nothing and fell through to the same-namespace fallback, silently binding the rival `App\Contracts\Repo` / `App\Enums\Describes`. It now scans all four declaration kinds and both body shapes. +- Behavior change riding on the above: **methods of a PHP interface, trait or enum move from file-scoped ids to type-scoped ids**, and their labels gain the leading dot that a class member has always carried — `app_repo_foorepository_find` / `find()` becomes `app_repo_foorepository_foorepository_find` / `.find()`, hanging off the new declaration node rather than directly off the file. `graphify update` re-extracts the whole code corpus in one pass, so it lands consistently; a *hook-driven* incremental rebuild against a graph built before this release will drop edges from files it did not re-extract to the old ids (the merge requires both endpoints live, so a stale-id edge is dropped, never repointed — loss, never misdirection), and the next full update restores them. A PHP corpus must therefore be re-extracted (`graphify update .`) to pick this up, and re-extraction is genuinely required rather than merely advisable, because AST cache entries are keyed by content hash within the version namespace — a same-version rebuild replays the pre-fix nodes untouched. +- Fix: a PHP `use` import whose name is only ever written as `Foo::class` no longer dangles on a bare short-name target (`lawnstarter/graphify#48`, RC2 of `#46`). `_import_php` emits the `imports` edge keyed on the imported short name and mints no node for it, so when the importing file never also writes that name as a base type or a parameter type, no stub node exists — and `_resolve_php_type_references` skipped the edge outright, because it read the name to resolve off a stub node's label. The bare target then survived into `graph.json` with no node behind it and `serve` materialized an attributeless degree-1 node for it at load. The resolver now reads the FQN off the edge's own `target_fqn` metadata, which `_import_php` has stamped since `#19`, so an import resolves from what the `use` statement literally said rather than from whether some *other* construct in the same file happened to leave a stub behind. An import that resolves to an in-corpus type lands on that type's node; one that resolves to nothing is proven external by its own FQN and parks on an FQN-labeled sourceless stub the bare-name rewire cannot collapse — it never bare-matches an unrelated class that merely shares the short name. Aliased imports (`use A\B\C as D;`) are fixed by the same read: the alias never matched the edge's short-name target, so the old path could only guess `\`. `use function` / `use const` imports are left alone — they name callables, not types, so they have no business in the type map. Independent of `#47`: this resolves from edge metadata, not from node existence, though imports of interfaces, traits and enums additionally need `#47`'s declaration nodes to exist before they can land on anything. +- Fix: a **sourceless stub can no longer shadow a real declaration**, and N stubs no longer count as one rival (`lawnstarter/graphify#49`, RC3 of `#46`). `find_node_ambiguity` grouped the winning match tier by `source_file` to decide whether a tie was worth reporting, but every stub the extractor mints for a reference it could not resolve carries `source_file == ""` — so the 18 stubs sharing the `BalanceitemRepository` label on the pinned 46.4k-node Laravel corpus collapsed into a single `""` bucket, no ambiguity was reported, and `explain` answered with `matches[0]`: whichever stub `G.nodes()` happened to yield first. Reorder the graph and the same query answered with a different stub, equally confidently, while `graphify affected` on that same name refused with "No unique node match" — the whole `explain`-vs-`affected` divergence in #46's symptom. Two changes fix it, and neither depends on stub counts staying high (a post-#47/#48 graph has fewer stubs; the residual ones are still protected). First, `_find_node_tiers` drops sourceless nodes from the exact tier when that tier also holds a sourced one: a stub is a broken duplicate of the real declaration, never the better answer, and never something the caller could disambiguate anyway since it has no path to retry with. Second, sourceless nodes are keyed individually in the ambiguity grouping rather than by their shared empty source, so a tier made *only* of stubs — where there is no real declaration to prefer — reports rivals instead of picking one silently. `affected`'s `resolve_seed` learns the same sourced-beats-sourceless rule in both its exact-label and bare-name passes, so the two commands now agree on every shape: both resolve to the sourced declaration when one exists, and both refuse when every rival is a stub. Sourced-vs-sourced ties (the monorepo `MetricsPort` case) are untouched, and a lone stub with no sourced rival still resolves as before. +- Sync with upstream #3872: the `lawnstarter/graphify#39` relative-scope refusal is superseded. Upstream now captures every `scoped_call_expression` as a member call (callee = the method, receiver = the scope) and defers it from the bare-name pass, which removes the fabricated `parent`-named callee #39 refused (1,698 inbound `calls` edges on `ServiceCategory::parent()` in the pinned 46,406-node api.lawnstarter.com corpus). Upstream also added its own `_resolve_php_member_calls` for those calls under this PR's resolver name and registration. Rather than keep two definitions with one silently shadowing the other, this PR's resolver absorbs upstream's static tier. `Foo::m()` binds EXTRACTED to the called method through the same `use`-aware `PhpNameResolver` refusals a typed receiver gets (#16/#22), `self::`/`static::` bind to the enclosing class, `parent::` binds to a single base, and `$var::m()` is left unresolved. A receiver whose type no PHP file declares is parked for cross-repo linking (#3152), under its short name. Upstream's flat per-file `php_type_table` instance tier is dropped: this PR's method-scoped typing passes every upstream instance-receiver test, and the flat table would bypass the #16 refusal. +- Fix: a PHP property assigned in the **constructor body** from a typed constructor parameter now types the `$this->prop->method()` receiver, so the call resolves to that parameter's class (`lawnstarter/graphify#38`). PHP receiver typing read the class's typed properties and constructor-promoted params only, which misses the pre-promotion idiom that dominates a Laravel corpus: the property is declared untyped with its type in a `@var` docblock, and the collaborator arrives as a typed `__construct` parameter that the body assigns across (`$this->chargeCustomerService = $chargeCustomerService;`). Measured on the 48.5k-node Laravel corpus, `BalanceCustomerAccountService::handle()` minted no `calls` edge to `ChargeCustomerService::handle()` while its two sibling callers — one using a promoted param, one a typed method param — resolved fine. The **parameter's signature is the evidence and `@var` docblocks are still not read**: a docblock-only property with no constructor assignment types nothing, and where docblock and signature disagree the signature wins. The refusal discipline is the local-variable one: the property is poisoned rather than guessed at when its binding is not provably single-typed — a second assignment of a different or untypable type (a container resolve, an inline `new`, a local, a literal), an augmented or by-reference assignment, a list-destructuring or `foreach` target, or a parameter rebound anywhere in the body, including by a shadowing closure parameter. Because a property is not a local, that refusal is judged over the whole **class**, not just the constructor: a write to the property from any other method — a setter, a `boot()`, a closure captured anywhere — refuses the constructor's binding, since it is a deferred write that may run never, later, or repeatedly, and an *untyped* property (unlike a natively typed one, which PHP fatals on an incompatible write) carries no runtime guarantee that would make it safe. The refusal is unconditional on the write and not conditional on the written type, so even a same-typed setter costs the edge — deliberate, and the trade this discipline makes everywhere else too. A real annotation always outranks the assignment, so a natively typed or promoted property keeps its declared type and a union-typed one keeps its multi-class refusal (#9); every downstream refusal — interface/enum/trait receivers (#5, #12), the `use`-claimed out-of-corpus name (#16), the single-definition guard — applies unchanged, since this only fills in the receiver type the resolver was already asking for. Extraction-side, so a PHP corpus needs re-extracting (`graphify update .`) to pick the new edges up. + +**Resolution gaps closed on top of the above:** + +- Fix: a PHP **function call never binds to a method** across files (`lawnstarter/graphify#52`). In PHP a bare `name(...)` — a `function_call_expression` — can only invoke a global or namespaced *function*; reaching a method requires `$obj->`, `Class::` or first-class-callable syntax, each of which takes a different path through the extractor. The shared cross-file pass matched by normalized label, and that normalization (`raw.strip("()").lstrip(".")`) erases the member marker the engine writes — a method labeled `.event()` and a function labeled `event()` both key as `event` — so Laravel's `event(...)` helper bound to whichever class happened to declare an `event()` method. On the pinned 46,406-node api.lawnstarter.com corpus that is **848 fabricated inbound `calls` edges on `GetProviderBillingEventsTest::event()`**, a test method credited as the callee of 368 distinct source files, 308 of them not tests. It was not even an ambiguity the god-node tie-breakers could catch: the three `Event` *classes* key as `Event`, so the method was the **sole** exact-case candidate and bound through the single-candidate path at INFERRED 0.8, before any tie-break could apply. The refusal is language-semantic rather than heuristic — a method is simply not a candidate at a function-call site — so it is applied to the candidate **list**, not by re-keying the indexes, which covers every consumer at once: the single-candidate bind, the symbol/module import-evidence disambiguation, and the god-node tie-break. The same language-semantic argument extends past methods: a **class-like declaration is not invocable either** — `report($e)` where only `class Report` exists is a "Call to undefined function" fatal, not a constructor call — so class, interface, enum and trait nodes are refused at a function-call site alongside methods, leaving only plausible function targets. That half also fixes a pre-existing bind this release would otherwise have inherited: `foo(...)` matching a cross-file `class Foo` through the case-insensitive fold has always produced a `calls` edge into the class. Both index paths are covered, and the folded lookup is *retried* after the method/class refusal rather than skipped: the case-insensitive fallback only fires on an empty exact-case list, so a method shadowing the exact-case key would otherwise have hidden a real `function Event()` reachable solely under the folded key, and the site would have resolved to nothing at all. The retry has to carry the refusal with it, or it hands the folded index a candidate the exact-case path never offered — a capitalized class under the shadowed lowercase key, which is exactly the **7 `report(...)` → `class Checkr\Resources\Report` edges** measured on the rebuilt 46.6k-node corpus. The class discriminator is the `_callable_class` marker the `indirect_call` guard already reads, chosen for the same reason as the label one: it is replayed onto `resolution_context_nodes` by both `graphify update` and `watch`, so it still classifies unchanged-corpus candidates on an incremental rebuild. The call site is identified by a marker the PHP extractor stamps on the raw call, which is load-bearing rather than convenience: a `scoped_call_expression` is also not a member call, so refusing on "PHP and not a member call" alone would have silently widened the policy to `Class::method()`. The method-vs-function discriminator is the engine's own label convention (`.name()` for a member, `name()` for a top-level function), chosen because a label persists into `graph.json` and so still classifies the unchanged-corpus candidates an incremental rebuild hands back as resolution context (#2406) — an edge-derived test would not, since the shared pass never receives those edges. PHP-only by construction: a Ruby or Python bare call reaches a method through implicit self, so their candidate filtering is deliberately untouched, as is the member-call resolver (`_resolve_php_member_calls`). One residual is documented rather than chased: a `event(...)` call in the *same file* as a class declaring an `event()` method still binds to it, because the extractor resolves that raw call in-file and it never reaches the cross-file pass — unchanged behavior, and the reason the measured fan-in shrinks to near-zero rather than provably zero. Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to shed these edges, and re-extraction genuinely requires the release's version bump to roll the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`): entries are keyed by content hash *within* that namespace, so a same-version rebuild replays the pre-fix raw calls untouched. +- Fix: `graphify query` and `graphify path` / `shortest_path` no longer resolve an endpoint to a **sourceless stub** when a real declaration carries the same label (`lawnstarter/graphify#54`, closing the gap `#49` left behind). `#49` taught `_find_node_tiers` and `affected`'s `resolve_seed` to drop stubs from a mixed exact tier, so `explain` and `affected` agree on the sourced declaration — but the scored path those two commands do not share, the `_score_nodes` ranking that feeds `_pick_scored_endpoint` (path) and `_pick_seeds` (query), never learned the rule. On the two-node repro — `FooRepository` at `source_file: ""` beside `FooRepository` at `app/bindings.php` — both nodes score **5619.081861425897**, identically, so the ranking fell through to its lexicographic node-id tie-break and answered with whichever id sorted first: `explain` named the declaration while `query` traversed the stub, reinstating for `explain`-vs-`query` exactly the divergence `#49` closed for `explain`-vs-`affected`, and violating the design intent the scorer's own comment states — that `path` and `query` resolve the same node `explain` does. The stub is rarely a merely cosmetic mismatch, because it is usually the *disconnected* node: `shortest_path` answered a reachable query with a false "No directed path found", and `query` spent its traversal on the stub's neighborhood instead of the declaration's. The fix is a **tie-break, not a scoring penalty**, and the distinction is load-bearing: a sourceless node can never *out*-score an otherwise-identical sourced one, since the only source-derived scoring term is additive and source hits do not count toward term coverage, so a shadowing stub always arrives at the sort as an exact tie and a tie-break is the complete and minimal intervention. The sourced preference is inserted directly under the score in both tie-break keys — the combined ranking sort and the per-term singleton-winner key that feeds the seed guarantee — above the label-length and node-id keys, which are arbitrary with respect to which node is real. No live score changes, so every score-sensitive baseline holds unedited. `#49`'s carve-out survives by construction rather than by a special case: an all-sourceless field has nothing to prefer, so a lone stub still wins its query and nothing that answered before now returns nothing. One asymmetry with `_find_node` is deliberate and left in place, since `_find_node` is unchanged here: it demotes stubs in its *exact* tier only, while a score tie can also arise from the prefix and substring tiers — on those shapes the scored path now prefers the sourced node where `_find_node` still answers by graph-iteration order, which is the more deterministic of the two answers, and closing the remaining gap would mean changing `_find_node` itself. Serve-side only: nothing in extraction, merge or the graph format changes, so an existing graph picks this up with **no rebuild and no re-extraction**. +- Fix: a PHP receiver typed with an `interface`, `enum` or `trait` name now binds to that declaration's own method instead of being refused outright (`lawnstarter/graphify#53`). `_resolve_php_member_calls` consulted a corpus-wide pre-scan of the non-class declaration names (`_php_non_class_types`) and skipped every such receiver — correct while the three minted no definition node, because the only thing such a receiver could land on was a same-short-named CLASS: Laravel's `App\Contracts\Notifier` interface beside an unrelated `App\Support\Notifier`, or `App\Enums\Status` beside an Eloquent `App\Models\Status`, each leaving exactly ONE definition under the short name and so satisfying the single-definition guard with the wrong answer. `#47` (in the node-identity group above) gave all three kinds canonical sourced nodes that their own methods attach to, which makes the refusal redundant: the collision now presents TWO definitions and is refused by the guard unaided, while a `use`-imported name is decided by the declared-FQN match. What the refusal was still costing is the rest — an interface or enum named by exactly one declaration, and a `use`-claimed one whose import says which of the namesakes it means — so `$this->notifier->send()` on a `private Notifier $notifier` now lands on `Notifier::send()` itself. Implementations are still never guessed: a `MailNotifier implements Notifier` gets nothing from an interface-typed receiver, and the same-short-named stranger class gets nothing on any path. +- The lift required extending the declared-FQN pre-scan to the three kinds, and that half is a fix in its own right: `_php_pre_scan_class_namespaces` recorded `class_declaration` only, so an interface/enum/trait node NEVER carried a declared FQN — not on a full build, not through the `_php_class_fqns` marker (`#23`). `PhpNameResolver`'s `use`-claim guard then fell back to comparing the node's PSR-4 PATH, and `_php_fqn_names_another_class` treats a path with FEWER segments than the written name as *no evidence* and keeps the edge. On a full build the paths are still absolute at resolver time, so the comparison ran and refused; on an incremental rebuild the replayed context node carries the relativized `app/Contracts/Notifier.php` (3 segments), so `use Illuminate\Contracts\Notifications\Notifier;` (4) tripped the bail-out and bound the in-corpus interface that the vendor import provably does not name — a wrong edge of the `#16` class, appearing ONLY under `graphify update`/`watch`, which is the normal operating mode. `Illuminate\Contracts\\` is exactly 4 segments and Laravel apps routinely declare `App\Contracts\{Repository,Factory,Guard,Mailer,…}` beside imports of the framework contracts with those same short names, so the shape is ordinary rather than exotic. All four declaration kinds are now read into `php_class_fqns` / `_php_class_fqns` / `fqn_def_nid`, which makes the guard whole-name and decisive on both build paths and closes the full-vs-incremental parity hole; the `tests/test_php_member_calls.py` cases assert the two builds' verdicts TOGETHER, since a full-build-only assertion is green on the broken code. +- The `_php_non_class_types` / `_php_interfaces` channel is kept intact — pre-scan, per-file payload, file-node stamp, `graphify update`/`watch` replay, both spellings read back — so graphs written by earlier versions still round-trip. Nothing consumes it for resolution any more; it remains the only record of declaration KIND that survives into a graph. +- Extraction-side, so a PHP corpus must be re-extracted (`graphify update .`) to pick this up: the new edges come from the receiver-typing pass and the declared FQNs come from a pre-scan, and AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild replays the pre-fix payloads untouched. +- The package version bump to 0.9.92 rolls the version-namespaced AST cache (`graphify-out/cache/ast/v{version}/`). Most of the changes above are extraction-side, so a PHP corpus — and any polyglot corpus — must be re-extracted (`graphify update .`) to pick them up: AST cache entries are keyed by content hash *within* the version namespace, so a same-version rebuild would replay the pre-fix facts untouched. The serve-side changes (the seeding group and the sourced-endpoint tie-breaks) need no rebuild. + ## 0.9.91 (2026-10-14) Eight community pull requests: a Windows MCP-stdio hang fix, Rust/JS/TS/extract call-resolution and id-collision fixes, an Ollama context-size fix, and a clustering label-retention fix. The AST cache schema is bumped, so the first build after upgrading re-extracts. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index e0e6e5275..efba412bd 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -48,6 +48,12 @@ EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric - **0.65** — weak (naming similarity only) - **0.55** — speculative +That rubric describes edges Claude inferred. The per-language member-call resolvers are a separate, deterministic source of INFERRED edges: they read the receiver's declared type out of the AST and bind at a fixed **0.85** (a step of the same rubric), reserving EXTRACTED (1.0) for a receiver whose type is named in the source at the call site. + +**PHP member calls refuse rather than guess.** `$this->prop->method()`, `$obj?->method()`, a typed parameter and a `$var = new T()` local all bind to the receiver's declared type as INFERRED 0.85; `(new Service())->method()` is EXTRACTED 1.0, but only when the namespace written at the call site corroborates the class that was found. When the type is not provably one concrete in-corpus class, no edge is emitted at all — untyped, union- and intersection-typed receivers, a short name that matches two classes, a method the receiver's class does not declare, chained and array-element receivers, a local rebound or shadowed anywhere in the method, and anonymous classes. A scoped call is EXTRACTED 1.0 because its class is written at the call: `Foo::m()` binds to `Foo`'s method through the same `use`-import refusals, `self::`/`static::` to the enclosing class's, `parent::` to its single base's, and `$var::m()` to nothing. A receiver typed by an `interface`, `enum` or `trait` binds to that declaration's own method only; implementations are never guessed — `MailNotifier implements Notifier` gets nothing from a `Notifier`-typed receiver. A Laravel corpus has many identically named `search()`/`log()`/`handle()` methods, so an absent edge is worth more than a guessed one. + +A PHP 8.1 first-class callable — `$obj->method(...)`, including the nullsafe and `$this` forms — resolves by exactly those rules but is emitted as **`indirect_call`**, not `calls`: the syntax creates a `Closure` and names the method without invoking it, the same shape the resolver already labels `indirect_call` for a callback passed by name. `calls` therefore keeps meaning "control flow transfers here". The spread form `$obj->method(...$args)` is a real invocation and stays `calls`. + --- ## Token benchmark diff --git a/graphify/affected.py b/graphify/affected.py index b011f2756..58a9ae9f4 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -148,6 +148,22 @@ def _prefer_file_node( return None +def _is_sourced(graph: nx.Graph, node_id: str) -> bool: + return bool(str(graph.nodes[node_id].get("source_file") or "")) + + +def _prefer_sourced_node(graph: nx.Graph, node_ids: list[str]) -> str | None: + """Return the one node with a source file among label rivals, else None. + + Mirrors serve's `_find_node_tiers` exact-tier rule (#49): a sourceless node is + an unresolved-reference placeholder, so a real declaration sharing its label is + the answer rather than a tie. Without this, `explain` resolved the sourced node + while `affected` refused with "No unique node match" — #46's symptom. + """ + sourced = [node_id for node_id in node_ids if _is_sourced(graph, node_id)] + return sourced[0] if len(sourced) == 1 else None + + def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | None: # A trailing path separator must not change a source-file match — serve's # _find_node tokenizes the path (which drops it), so strip it here for parity @@ -161,8 +177,17 @@ def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | for node_id, data in graph.nodes(data=True) if _normalize_label(str(data.get("label", ""))) == query_lower ] - if len(exact_label_matches) == 1: + if len(exact_label_matches) == 1 and _is_sourced(graph, exact_label_matches[0]): return exact_label_matches[0] + if exact_label_matches: + sourced = _prefer_sourced_node(graph, exact_label_matches) + if sourced is not None: + return sourced + # A lone match that is a sourceless stub is NOT yet an answer: the real + # declaration may carry a decorated label ("handle()") that only the + # bare-name pass below reaches, and serve's exact tier — which matches + # both forms — would prefer it (#49). That pass is a superset of this + # one, so a stub with no sourced rival still resolves there. # Callable labels are decorated ("name()"), so a bare "name" query falls # through exact matching and then ties with any "name*" sibling in the # contains pass. Match on the undecorated name before giving up. @@ -174,6 +199,10 @@ def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | ] if len(bare_name_matches) == 1: return bare_name_matches[0] + if bare_name_matches: + sourced = _prefer_sourced_node(graph, bare_name_matches) + if sourced is not None: + return sourced # Compare paths in repo-relative form. Only this branch is path-shaped; the # label branches above keep the query verbatim. query_path = _normalize_label(_as_repo_relative(query, root)) diff --git a/graphify/cli.py b/graphify/cli.py index 6a20e3dad..d0ad31a89 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -4295,9 +4295,22 @@ def _ctx_identity(source_file) -> str | None: } # Keep bounded resolver identity for unchanged nodes; # these markers cannot be reconstructed from labels. + # `_php_non_class_types` (#11, #12) rides the same + # marker channel as the callability flags: it records + # which of an unchanged PHP file's declarations are + # interfaces, enums or traits rather than classes. It + # drove a receiver refusal until #53 lifted it, and is + # still carried; `_php_interfaces` is the pre-#12 + # spelling, kept for older graphs. `_php_class_fqns` + # (#23) is what resolution reads today: the declared + # FQNs that let a claimed `use` import bind into an + # unchanged defining file (#22), and let the guard + # refuse a vendor import that only shares a short name. for _marker in ( "_callable", "_callable_class", "_elixir_module", "_rust_impl_key", "_rust_declaration_count", + "_php_non_class_types", "_php_interfaces", + "_php_class_fqns", "_ts_public_twin", "_py_property", "_java_ret", "_ts_fields", "_ts_static_fields", ): diff --git a/graphify/extract.py b/graphify/extract.py index 327ee2ec9..2876a1271 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -66,6 +66,10 @@ from graphify.extractors.markdown import extract_markdown, _MD_LINK_INDEX_CACHE # noqa: F401 from graphify.extractors.ocaml import extract_ocaml # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 +from graphify.extractors.php import ( # noqa: F401 + PhpNameResolver, + _php_qualified_corroborates, +) from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.r import extract_r, resolve_r_sourced_calls # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 @@ -140,6 +144,8 @@ _pascal_resolve_class, _pascal_resolve_unit, _pascal_unit_cache, + _php_use_clause_context, + _php_use_clause_fact, _pnpm_workspace_globs, _python_call_identifier, _python_import_from_module, @@ -507,6 +513,132 @@ def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None: e["target"] = alias_map[tgt] +# Languages whose import edges name their target by the imported file's bare +# stem id, as (importer suffixes, importable target suffixes). Each pair is +# closed within one language: an import written in one of the first set can only +# ever mean a file from the second, which is what makes the hint below +# unambiguous even when the colliding sibling belongs to another language (#33). +# +# Python's target set omits `.pyi` on purpose — it has no extractor, so it mints +# no file node to point a hint at, and listing it could only mask a real `.py` +# target behind a phantom ambiguity. +_IMPORT_STEM_LANGUAGES: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = ( + ((".py", ".pyi"), (".py",)), + ((".rs",), (".rs",)), + ((".zig",), (".zig",)), + ((".ex", ".exs"), (".ex", ".exs")), + ((".ps1", ".psm1", ".psd1"), (".ps1", ".psm1", ".psd1")), + ((".sh", ".bash"), (".sh", ".bash")), + ( + (".pas", ".pp", ".dpr", ".dpk", ".inc"), + (".pas", ".pp", ".dpr", ".dpk", ".inc"), + ), +) + + +def _file_nids_by_path(all_nodes, all_edges, root) -> dict: + """Resolved source path -> the id its file node carries RIGHT NOW. + + For passes that run after ``_disambiguate_colliding_node_ids`` and therefore + cannot derive a file's id from its path: a same-stem sibling salts the id + away from whatever the path formula would produce. A file node is the one + that ``contains`` its file's other nodes; an empty file contains nothing, so + fall back to the node whose label is the file's own basename. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + contains_sources = { + e.get("source") for e in all_edges if e.get("relation") == "contains" + } + by_path: dict[Path, str] = {} + fallback: dict[Path, str] = {} + for n in all_nodes: + source_file, nid = n.get("source_file"), n.get("id") + if not source_file or not nid: + continue + candidate = Path(str(source_file)) + try: + resolved = ( + candidate if candidate.is_absolute() else root / candidate + ).resolve() + except OSError: + continue + if nid in contains_sources: + by_path.setdefault(resolved, nid) + elif n.get("label") == resolved.name: + fallback.setdefault(resolved, nid) + for resolved, nid in fallback.items(): + by_path.setdefault(resolved, nid) + return by_path + + +def _hint_import_targets(paths, all_edges, root) -> None: + """Tell id-disambiguation which file each stem-named import edge targets. + + The extractors in ``_IMPORT_STEM_LANGUAGES`` name an import's target by the + bare file-node id of the imported file (``import lead`` -> ``lead``), which + resolves only while that id is unique. Add ANY same-stem file — a ``lead.md`` + will do — and the two file nodes collide, so + ``_disambiguate_colliding_node_ids`` salts them apart into ``lead_py_lead`` + and ``lead_md_lead``. The edge's target salt is keyed by the IMPORTER's + source_file, which matches neither, so the edge is left pointing at an id + that no longer names anything: silently dropped, along with everything + downstream of it (Python's ``module.func()`` call resolution among them). + + The disambiguator already accepts a ``target_file`` hint for exactly this + (#1814), keying the target salt by that file instead. Stamp it here rather + than in each extractor, because an extractor sees one file and cannot know + which of the corpus's same-stem candidates the id will end up naming. + + This cannot change WHICH node an edge resolves to — the hint only selects + among the salted variants of an id the edge already named — so a corpus with + no collision is bit-identical. Guards: never overwrite a hint an emitter + already stamped, and skip an id claimed by more than one importable file of + the same language (leave it dangling, as before). + + Must run BEFORE ``_disambiguate_colliding_node_ids``, the hint's only reader. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + hints: list[tuple[tuple[str, ...], dict[str, str]]] = [] + for importer_suffixes, target_suffixes in _IMPORT_STEM_LANGUAGES: + file_id_to_paths: dict[str, set[str]] = {} + for p in paths: + if p.suffix.lower() not in target_suffixes: + continue + try: + rel = Path(p).resolve().relative_to(root) + except (ValueError, OSError): + continue + file_id_to_paths.setdefault(_file_node_id(rel), set()).add(str(p)) + hint_map = { + fid: next(iter(ps)) for fid, ps in file_id_to_paths.items() if len(ps) == 1 + } + if hint_map: + hints.append((importer_suffixes, hint_map)) + if not hints: + return + for e in all_edges: + if not ( + isinstance(e, dict) + and e.get("relation") in ("imports", "imports_from") + and not e.get("target_file") + ): + continue + source_file = str(e.get("source_file", "")).lower() + for importer_suffixes, hint_map in hints: + if not source_file.endswith(importer_suffixes): + continue + target_path = hint_map.get(e.get("target")) + if target_path: + e["target_file"] = target_path + break + + def _repoint_python_sibling_imports(paths, all_nodes, all_edges, root) -> None: """Repoint Python sibling-import edges to the real file node in directories without a Python package initializer (#3430). @@ -1242,46 +1374,32 @@ def _import_scala(node, source: bytes, file_nid: str, stem: str, edges: list, st def _import_php(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: - # `node` is a namespace_use_clause: `Client` or `Client as HttpClient`, - # whose children are the qualified/bare name, then (if present) `as` - # and the alias name, in that order. An aliased clause used to always - # target the bare imported name ("Client"), ignoring the alias - # entirely -- so two files importing the SAME external class under - # DIFFERENT local aliases (a real pattern: disambiguating two - # same-named classes from different namespaces) produced two - # DIFFERENT stub targets for one class, and _resolve_php_type_references - # (which resolves a stub via the file's own alias -> FQN map, keyed by - # the alias when the import has one) could never find this one under - # its bare-name-derived key, leaving the edge stuck on an unresolved, - # never-repointed stub (#3421). The alias, when present, is also what - # the rest of the file actually references (a parameter typed - # `HttpClient`, not `Client`), so preferring it here keeps this edge's - # target consistent with those reference edges too. - saw_as = False - module_name = None - for child in node.children: - if child.type == "as": - saw_as = True - continue - if child.type in ("qualified_name", "name", "identifier"): - bare = _read_text(child, source).split("\\")[-1].strip() - if saw_as: - module_name = bare - break # the alias name always comes last; nothing more to see - if module_name is None: - module_name = bare - if module_name: - tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "context": "import", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) + # `node` is a single `namespace_use_clause`; the group-use prefix and the + # `function`/`const` keyword of a group use live on the parent declaration, + # so the clause alone cannot spell its own FQN (#19). Shared parser with + # `_resolve_php_type_references` — see resolution.py. + fact = _php_use_clause_fact(node, source, *_php_use_clause_context(node, source)) + if fact is None: + return + target_fqn, alias, use_kind = fact + # The edge target stays keyed on the imported short name: re-pointing it is + # the resolvers' job (`_resolve_php_type_references`), not the capture's. + module_name = target_fqn.rsplit("\\", 1)[-1].strip() + if not module_name: + return + edges.append({ + "source": file_nid, + "target": _make_id(module_name), + "relation": "imports", + "context": "import", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + "metadata": sanitize_metadata({k: v for k, v in + {"use_kind": use_kind, "alias": alias, "target_fqn": target_fqn}.items() + if v is not None}), + }) # ── C/C++ function name helpers ─────────────────────────────────────────────── @@ -1596,11 +1714,14 @@ def _ruby_sanitize_method_name(name: str) -> str: }), function_types=frozenset({"function_definition", "method_declaration", "anonymous_function", "arrow_function"}), import_types=frozenset({"namespace_use_clause"}), + # `$obj?->method()` parses as a distinct node type with the same + # object/name/arguments fields, so it flows through the member-call branch + # unchanged once it is recognized as a call at all (#1682). # object_creation_expression joins the dispatch set so `new Foo(...)` links # the constructing method to Foo (engine has a dedicated PHP branch: the # class sits in a bare name/qualified_name child, not a field) - the PHP # twin of Java #1373 / C# #2997 (#3115). - call_types=frozenset({"function_call_expression", "member_call_expression", "scoped_call_expression", "class_constant_access_expression", "object_creation_expression"}), + call_types=frozenset({"function_call_expression", "member_call_expression", "nullsafe_member_call_expression", "scoped_call_expression", "class_constant_access_expression", "object_creation_expression"}), static_prop_types=frozenset({"scoped_property_access_expression"}), helper_fn_names=frozenset({"config"}), container_bind_methods=frozenset({"bind", "singleton", "scoped", "instance"}), @@ -4396,6 +4517,61 @@ def _key_of(e: dict, src: str | None, tgt: str | None) -> tuple: rc["caller_nid"] = remap[cn] +# Source suffixes each member-call resolver owns. Used BOTH to register the +# resolver and to scope what it consumes (#8, #10) — one definition so the two +# cannot drift. TS/JS is the exception: upstream scopes it by interop family +# (`_lang_family(...) == "jsts"`, which also covers SFC script blocks) and +# registers it with `_JS_TS_SUFFIXES`, so it has no entry here. +_SWIFT_RESOLVER_SUFFIXES = (".swift",) +_PYTHON_RESOLVER_SUFFIXES = (".py",) +_CSHARP_RESOLVER_SUFFIXES = (".cs",) +_JAVA_RESOLVER_SUFFIXES = (".java",) +_PHP_RESOLVER_SUFFIXES = ( + ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", +) +# `.h` routes to extract_cpp or extract_objc by content, so it appears in both +# the C++ and ObjC sets. Raw calls are still claimed by the extractor-stamped +# `lang`, never by suffix; only the DEFINITION index is scoped by suffix, where +# including `.h` is correct — a C++ class and an ObjC @interface both live in +# one. The consequence is that C++ and ObjC are isolated from every other +# language but not from each other, which no suffix can fix (#24). +_CPP_RESOLVER_SUFFIXES = ( + ".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h", +) +_OBJC_RESOLVER_SUFFIXES = (".m", ".mm", ".h") + + +def _raw_call_is_owned(rc: dict, suffixes: tuple[str, ...]) -> bool: + """True when ``rc`` was written in a source file with one of ``suffixes``. + + How the untagged Swift and Python resolvers claim their raw calls (#10); + TypeScript claims by interop family (``_lang_family``) instead. The tagged languages (cpp, csharp, java, objc, php) match on the + extractor-stamped ``lang`` instead, because C++ and ObjC share `.h` and a + suffix alone cannot tell their raw calls apart. Prior art for the suffix + form: ``ruby_resolution._ruby_raw_calls``. The suffix is the effective one, + so a `.graphifyrc` language override (#2961) counts as that language. + """ + return _owned_suffix(rc.get("source_file")).endswith(suffixes) + + +def _is_owned_definition(node: "dict | None", suffixes: tuple[str, ...]) -> bool: + """True when ``node`` was declared in a source file with one of ``suffixes``. + + The definition-index twin of ``_raw_call_is_owned`` (#8, #10, #24). Every + member-call resolver scopes its receiver-type index through this, so a + receiver's declared type can only ever bind to a type written in the same + language. + """ + return _owned_suffix((node or {}).get("source_file")).endswith(suffixes) + + +def _owned_suffix(source_file: object) -> str: + """``source_file``'s effective suffix, lowercased; ``""`` when absent.""" + if not source_file: + return "" + return effective_suffix(str(source_file)).lower() + + def _drop_shadowed_csharp_partial_bases(all_nodes: list[dict], all_edges: list[dict]) -> None: """Drop a merged partial type's placeholder base when a real base of the same name sits next to it. @@ -4547,12 +4723,17 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} # Type name -> definition node ids (real, source-backed, type-like defs only). - # len != 1 is the god-node guard: an ambiguous type name bails. + # len != 1 is the god-node guard: an ambiguous type name bails. Scoped to + # Swift sources (#24): unscoped, a Python `class Lead` could type + # `let lead: Lead` and mint a cross-language edge, and a foreign class merely + # SHARING the short name pushed the guard to 2 and deleted the correct Swift + # edge. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _SWIFT_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, from `method` edges. @@ -4621,6 +4802,13 @@ def _key(label: str) -> str: existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} for rc in all_raw_calls: + # Consume only raw calls written in Swift sources (#10). The former + # `if rc.get("lang"): continue` excluded the TAGGED languages (cpp, + # csharp, java, objc, php) but not the untagged ones — Python and + # TypeScript raw calls carry no `lang` either, so they flowed straight + # into the arms below. A positive suffix filter is closed by construction. + if not _raw_call_is_owned(rc, _SWIFT_RESOLVER_SUFFIXES): + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") @@ -4710,7 +4898,11 @@ def _key(label: str) -> str: # A class owns methods: it is the source of one or more `method` edges. Index # class label -> owning class node ids (len != 1 is the god-node guard), and - # (class_node_id, method_key) -> method_node_id. + # (class_node_id, method_key) -> method_node_id. Only classes declared in + # Python sources are candidates (#24): unscoped, `Lead.search()` bound to a + # Java `class Lead` at EXTRACTED, and a foreign class merely SHARING the name + # pushed the guard to 2 and deleted the correct Python edge. `method_index` + # needs no scoping — it is only ever keyed by a class id already admitted here. class_def_nids: dict[str, list[str]] = {} method_index: dict[tuple[str, str], str] = {} owner_of: dict[str, str] = {} @@ -4721,7 +4913,7 @@ def _key(label: str) -> str: if src and tgt: owner_of[tgt] = src cnode = node_by_id.get(src) - if cnode is not None: + if cnode is not None and _is_owned_definition(cnode, _PYTHON_RESOLVER_SUFFIXES): class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(src) tnode = node_by_id.get(tgt) if tnode is not None: @@ -4891,6 +5083,12 @@ def _inherited_method(caller: str, callee: str, skip_own: bool) -> "str | None": return None for rc in all_raw_calls: + # Consume only raw calls written in Python sources (#10). The former + # `if rc.get("lang"): continue` let every untagged language through: a + # TypeScript `Lead.search({})` reached the class arm below and minted an + # EXTRACTED edge into a Python method with no TS `Lead` in the corpus. + if not _raw_call_is_owned(rc, _PYTHON_RESOLVER_SUFFIXES): + continue if rc.get("_ambiguous_python_import"): continue if not rc.get("is_member_call"): @@ -4963,11 +5161,16 @@ def _inherited_method(caller: str, callee: str, skip_own: bool) -> "str | None": # never match), then to the single callable that module contains. A # receiver also matches the local alias bound on that import edge # (#2082), so an aliased import resolves the same as the bare name. + # A candidate module must itself be a Python file (#24): matching on + # the stem alone, a `lead.ts` answered `import lead` and bound the + # call to a TypeScript function, and a `lead.ts` sitting beside the + # real `lead.py` made the pair ambiguous and deleted the true edge. rkey = _key(receiver) caller_file = _file_node(caller) file_aliases = import_alias_by_filenode.get(caller_file, {}) mods = [t for t in imported_by_filenode.get(caller_file, ()) if t in contains_children + and _is_owned_definition(node_by_id.get(t), _PYTHON_RESOLVER_SUFFIXES) and t not in file_of_node and (_module_stem_key(t) == rkey or file_aliases.get(t) == rkey)] if len(mods) != 1: # not an imported module, or ambiguous -> bail @@ -5113,6 +5316,13 @@ def _field_type(class_nid: str, field: str, static: bool, depth: int = 0) -> tup existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} for rc in all_raw_calls: + # Consume only raw calls written in TS/JS-family sources (#10). The former + # `if rc.get("lang"): continue` excluded only the tagged languages, so a + # Python or Swift raw call still reached the arms below — and this + # resolver's `references` fallback minted a call-context edge onto the + # receiver's type even when no method matched. + if _lang_family(rc.get("source_file")) != "jsts": + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") @@ -5256,11 +5466,17 @@ def _key(label: str) -> str: # excluding non-contained nodes keeps them from making a real type ambiguous. contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to C++ sources (#24): unscoped, a Python `class Lead` could type + # `Lead lead;` and mint a cross-language edge, and a foreign class merely + # SHARING the short name pushed the single-definition guard to 2 and deleted + # the correct C++ edge. `.h` is in the set (and in ObjC's), so the two stay + # distinguishable from every other language but not from each other. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _CPP_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type @@ -5427,11 +5643,17 @@ def _key(label: str) -> str: contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Scoped to C# sources (#10), the same fix #8 made for PHP/ObjC. An unscoped + # index matched a C# receiver type against classes written in ANY language, + # which cut both ways: a Python `class Lead` could type the receiver, and a + # Python class merely SHARING the name pushed the single-definition guard to + # 2 and silently suppressed the correct C# edge. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_is_owned_definition(n, _CSHARP_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) # Namespace/using/alias-aware simple-name resolution, shared with the C# @@ -5748,158 +5970,6 @@ def _bind_member_field_tables( return bound -def _resolve_php_member_calls( - per_file: list[dict], - all_nodes: list[dict], - all_edges: list[dict], -) -> None: - r"""Resolve PHP ``ClassName::method()`` / ``self::``/``static::``/``parent::`` - static calls against the receiver's class (#3872). - - The shared engine's PHP branch used to treat ``scoped_call_expression`` as a - bare function call named after the *scope* (``Helper::format()`` bound the - callee name to ``"Helper"``), so the cross-file bare-name pass silently - resolved every static call to whichever node happened to share the class's - label — almost always the class definition itself — instead of the actual - method, and did so with the generic import-evidence confidence gate even - though the receiver class is named explicitly in source. The engine now - captures the real method name as the callee and the scope text as the - receiver and marks it a member call; this resolver binds it to the class's - actual method node, mirroring ``_resolve_java_member_calls``. - - ``self::``/``static::`` resolve to the enclosing class (exact — the keyword - names it as unambiguously as a literal class name would). ``parent::`` - resolves through a single `inherits` edge from the enclosing class. A - ``$var::method()`` dynamic scope is left unresolved — PHP has no - declared-type table for it to resolve against, and guessing would risk the - same over-connection the #543/#1219 guard exists to prevent. Every other - scope is treated as a class reference regardless of case (PHP class and - method names are case-insensitive) or namespace qualification (a - fully-qualified ``\App\Models\User::method()`` or ``App\Models\User::`` - matches the class's own unqualified label by its last segment). - """ - def key(label: str) -> str: - return str(label).strip().removeprefix(".").removesuffix("()").lower() - - def class_key(name: str) -> str: - return str(name).strip().lstrip("\\").rsplit("\\", 1)[-1].lower() - - contained = {edge.get("target") for edge in all_edges - if edge.get("relation") == "contains"} - node_by_id = {node.get("id"): node for node in all_nodes} - - type_def_nids: dict[str, list[str]] = {} - for node in all_nodes: - if ( - _lang_family(node.get("source_file")) == "php" - and node.get("id") in contained - and _is_type_like_definition(node) - ): - type_def_nids.setdefault(class_key(node.get("label", "")), []).append(node["id"]) - - method_index: dict[tuple[str, str], set[str]] = {} - enclosing_type: dict[str, str] = {} - for edge in all_edges: - if edge.get("relation") != "method": - continue - owner, method = edge.get("source"), edge.get("target") - method_node = node_by_id.get(method) - if method_node is None: - continue - enclosing_type.setdefault(method, owner) - method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method) - - inherits_bases: dict[str, list[str]] = {} - for edge in all_edges: - if edge.get("relation") == "inherits": - inherits_bases.setdefault(edge["source"], []).append(edge["target"]) - - type_table_by_file: dict[str, dict[str, str]] = {} - for result in per_file: - tt = result.get("php_type_table") - if tt and tt.get("path"): - type_table_by_file[str(tt["path"])] = tt.get("table", {}) - - existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} - for result in per_file: - for raw_call in result.get("raw_calls", []): - if not raw_call.get("is_member_call"): - continue - if not str(raw_call.get("source_file", "")).endswith(".php"): - continue - receiver = raw_call.get("receiver") - callee = raw_call.get("callee") - caller = raw_call.get("caller_nid") - if not receiver or not callee or not caller: - continue - - inferred = False - receiver_lower = receiver.lower() - if receiver_lower in ("self", "static"): - type_nid = enclosing_type.get(caller) - if not type_nid: - continue - elif receiver_lower == "parent": - bases = inherits_bases.get(enclosing_type.get(caller), []) - if len(bases) != 1: - continue - type_nid = bases[0] - elif not raw_call.get("php_scoped") and not receiver.startswith("$"): - # Instance receiver (`$g->m()`, `$this->prop->m()`): the type comes - # from the annotation table, never the call site, so this rung is - # INFERRED. An untyped, union or longer-chain receiver is absent from - # the table and stays unresolved rather than guessing a class name. - typed = type_table_by_file.get( - str(raw_call.get("source_file", "")), {}).get(receiver) - if typed is None: - continue - inferred = True - type_defs = type_def_nids.get(class_key(typed), []) - if not type_defs: - _park_unresolved_member_call( - node_by_id.get(caller), callee, typed, "php", raw_call, - ) - continue - if len(type_defs) != 1: - continue - type_nid = type_defs[0] - elif not receiver.startswith("$"): - type_defs = type_def_nids.get(class_key(receiver), []) - if not type_defs: - _park_unresolved_member_call( - node_by_id.get(caller), callee, receiver, "php", raw_call, - ) - continue - if len(type_defs) != 1: - continue - type_nid = type_defs[0] - else: - # Dynamic scope ($var::method()) — no declared-type table to - # resolve against; leave unresolved rather than guess. - continue - - method_nids = method_index.get((type_nid, key(callee)), set()) - if len(method_nids) != 1: - continue - method_nid = next(iter(method_nids)) - if method_nid == caller or (caller, method_nid) in existing_pairs: - continue - existing_pairs.add((caller, method_nid)) - all_edges.append({ - "source": caller, - "target": method_nid, - "relation": "calls", - "context": "call", - "confidence": "INFERRED" if inferred else "EXTRACTED", - # Table-typed receiver -> the discrete INFERRED rung (0.85); - # a source-named class scope stays EXTRACTED (1.0). - "confidence_score": 0.85 if inferred else 1.0, - "source_file": raw_call.get("source_file", ""), - "source_location": raw_call.get("source_location"), - "weight": 1.0, - }) - - def _resolve_java_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -5927,10 +5997,14 @@ def key(label: str) -> str: if edge.get("relation") == "contains"} node_by_id = {node.get("id"): node for node in all_nodes} + # Scoped to Java sources (#10), the same fix #8 made for PHP/ObjC: an + # unscoped index let a Python `class Lead` type the receiver of + # `Lead lead; lead.search()` at INFERRED, and let a foreign class merely + # sharing the name suppress the correct Java edge. type_def_nids: dict[str, list[str]] = {} for node in all_nodes: if ( - node.get("source_file") + _is_owned_definition(node, _JAVA_RESOLVER_SUFFIXES) and node.get("id") in contained and _is_type_like_definition(node) ): @@ -6164,6 +6238,299 @@ def _chain_type(chain: list, caller: str, depth: int = 0) -> str | None: _emit(caller, method_nid, raw_call, exact) +_PHP_NON_CLASS_TYPE_MARKERS = ("_php_non_class_types", "_php_interfaces") + + +def _php_context_interface_entry(context_nodes: list[dict] | None) -> dict | None: + """Recover the unchanged corpus's PHP interface/enum/trait names (#11, #12). + + The extractor stamps the names a file declared on that file's own node as + ``_php_non_class_types`` — a persisted marker, like ``_callable`` (#2438) — + and ``watch``/``graphify update`` hand it back on the resolution-context + nodes. Returns a synthetic ``per_file``-shaped entry carrying just those + names (or None when there are none), which extends the resolver's existing + single channel instead of adding a second one. + + ``_php_interfaces`` is the pre-#12 spelling of the same marker, carrying + interfaces alone; it is still read so a graph.json written before enums and + traits joined the set keeps handing back the names it does carry. + + The MEMBER-CALL RESOLVER no longer consults these names: #5/#12 used them to + refuse an interface/enum/trait-typed receiver outright, and #53 lifted that + refusal once #47 gave the three declarations canonical nodes of their own + (`_resolve_php_member_calls`). The channel itself — pre-scan, stamp, replay — + is kept whole: it is what a graph written by an older version still carries, + and it is the only record of which corpus names are non-class declarations + that survives a rebuild in which the declaring file is never dispatched. + + Read from the RAW context list rather than off the merged resolution nodes: a + changed caller that does `use App\\Contracts\\Notifier;` mints a sourceless + import stub whose id IS the interface file node's id, and the merge drops the + colliding context node (fresh wins) — taking the marker with it. + """ + names = sorted({ + str(name) + for node in (context_nodes or []) + for marker in _PHP_NON_CLASS_TYPE_MARKERS + for name in (node.get(marker) or ()) + }) + return {"php_non_class_types": names} if names else None + + +def _php_context_class_fqn_entries(context_nodes: list[dict] | None) -> list[dict]: + """Recover the unchanged corpus's declared PHP class FQNs (#23). + + The ``php_class_fqns`` payload aligns 1:1 with the files dispatched this + run, so on an incremental rebuild the declared-FQN binding (#22) went blind + to every class whose defining file was unchanged, and the edges it adds on + a full build silently vanished. The extractor stamps each PHP file node + with its ``_php_class_fqns`` map — the same persisted-marker channel as + ``_php_non_class_types`` — and this hands the stamps back as synthetic + ``per_file``-shaped entries, one per file since the payload is keyed by + the DEFINING file's path. The path is the context node's own + ``source_file``: that is the exact form the resolver's type nodes carry, + because both come from the same persisted graph. A graph written before + the marker existed yields no entries and the binding fails closed — no + edge rather than a guessed one — until the defining file is re-extracted. + + Read from the RAW context list for the reason `_php_context_interface_entry` + is: an import stub minted by a changed caller can collide with the file + node's id and take the marker down with it in the merge. + """ + entries = [] + for node in context_nodes or []: + classes = node.get("_php_class_fqns") + source_file = node.get("source_file") + if isinstance(classes, dict) and classes and source_file: + entries.append({ + "php_class_fqns": {"path": str(source_file), "classes": classes} + }) + return entries + + +def _resolve_php_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve PHP member calls against the receiver's declared type (#1682). + + A scoped call (``Foo::m()``, #3872) binds to the method on the class its + scope names, through the same ``use``-aware name resolution and refusals as + a typed receiver, and is exact because the class is written at the call. + ``self::``/``static::`` name the caller's enclosing class and ``parent::`` + its single base; a ``$var::m()`` scope is left unresolved. A receiver whose + type no PHP file in this corpus declares is parked on the caller for a + merged graph to finish (#3152). + + Receiver ``this`` binds to the caller's enclosing class (exact). Receiver + ``this.`` carries the type stamped by the extractor from the class's + typed properties and constructor-promoted params (inferred). A missing or + ambiguous receiver type is skipped rather than falling back to a bare + method-name match — a Laravel corpus has many identically named service + methods, and guessing between them is worse than an absent edge. + + A raw call marked ``fcc`` is a PHP 8.1 first-class callable + (``$obj->method(...)``): the method is referenced, not invoked, so it + resolves by exactly the rules above but is emitted as ``indirect_call`` + (#15). + + A receiver typed with an ``interface``, ``enum`` or ``trait`` name resolves + by those same rules (#53). Until #47 the three minted no definition node, so + such a receiver could only ever land on a same-short-named CLASS — the + Laravel Contracts collision (``App\\Contracts\\Notifier`` vs + ``App\\Support\\Notifier``) or the enum-beside-model one — and #5/#12 + refused every one of them outright, off a corpus-wide pre-scan of the + declared names. Now that each declaration mints a canonical sourced node + that its own methods attach to, the same two rules that keep a CLASS-typed + receiver honest cover them: a calling file that names one through a ``use`` + import gets ``PhpNameResolver``'s verdict — the declared-FQN match when the + import names an in-corpus declaration, a refusal when it names a vendor type + that merely shares the short name — and a file that claims nothing falls to + the single-definition guard, which sees the collision as the two definitions + it now is. What the blanket refusal was costing is the rest: an interface or + enum named by exactly one declaration binds to that declaration's method. + """ + def key(label: str) -> str: + # PHP class and method names are case-insensitive. + return str(label).strip().removeprefix(".").removesuffix("()").casefold() + + contained = {edge.get("target") for edge in all_edges + if edge.get("relation") == "contains"} + node_by_id = {node.get("id"): node for node in all_nodes} + + # Scoped to PHP sources (#8). An unscoped index matched a PHP receiver type + # against classes written in ANY language, which cut both ways: a Python + # `class Lead` could be bound as the receiver's type, and a Python class + # merely SHARING the name pushed the single-definition guard to 2 and + # silently suppressed the correct PHP edge. + type_def_nids: dict[str, list[str]] = {} + for node in all_nodes: + if ( + _is_owned_definition(node, _PHP_RESOLVER_SUFFIXES) + and node.get("id") in contained + and _is_type_like_definition(node) + ): + type_def_nids.setdefault(key(node.get("label", "")), []).append(node["id"]) + + method_index: dict[tuple[str, str], set[str]] = {} + enclosing_type: dict[str, str] = {} + for edge in all_edges: + if edge.get("relation") != "method": + continue + owner, method = edge.get("source"), edge.get("target") + method_node = node_by_id.get(method) + if method_node is None: + continue + enclosing_type.setdefault(method, owner) + method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method) + + inherits_bases: dict[str, list[str]] = {} + for edge in all_edges: + if edge.get("relation") == "inherits": + inherits_bases.setdefault(edge["source"], []).append(edge["target"]) + + # Fully qualified type names as each defining file DECLARES them (#14), so + # the inline-`new` corroboration below compares the written name against the + # real one instead of against the file's path, which PSR-4 only conventionally + # agrees with. Keyed by defining file, then by short name. Covers every + # namespaced declaration, `interface`/`enum`/`trait` included since they + # became bindable receiver types (#53) — a declaration without a declared + # FQN is one the guards can only judge by its path, and a replayed context + # node's path is short enough to make that judgement abstain. + class_fqn_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + declared = result.get("php_class_fqns") + if declared and declared.get("path"): + class_fqn_by_file[declared["path"]] = declared.get("classes", {}) + + # `use`-import/namespace-aware receiver typing (#21, #22), consulted IN + # FRONT of the corpus-wide short-name index below — the shape of the C# + # call site in `_resolve_csharp_member_calls`. A name the calling file + # CLAIMS through a `use` import or writes out qualified is decided here and + # never falls back: the refusal is the whole fix for #16, and the claimed + # FQN binding to the type whose file declares exactly that name is the + # recall counterpart (#22) — see PhpNameResolver. + resolver = PhpNameResolver(all_nodes, all_edges, type_def_nids, class_fqn_by_file) + + def declared_fqn(type_node: dict | None) -> str | None: + """The namespace-qualified name of ``type_node``'s class, if its file + declared one. Absent for a global-namespace class, and for a node + replayed from a prior graph on an incremental run.""" + if not type_node: + return None + by_name = class_fqn_by_file.get(str(type_node.get("source_file") or "")) + if not by_name: + return None + return by_name.get(key(type_node.get("label", ""))) + + existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges} + # First-class callables (`fcc`, #15) go last: the sort is stable, so ordinary + # invocations keep source order and claim the (caller, method) pair first. A + # real call outranks a mere reference to the same method, which is also the + # precedence the in-file and cross-file indirect-dispatch passes use. + php_raw_calls = [ + raw_call + for result in per_file + for raw_call in result.get("raw_calls", []) + if raw_call.get("lang") == "php" and raw_call.get("is_member_call") + ] + php_raw_calls.sort(key=lambda raw_call: bool(raw_call.get("fcc"))) + for raw_call in php_raw_calls: + receiver = raw_call.get("receiver") + callee = raw_call.get("callee") + caller = raw_call.get("caller_nid") + if not receiver or not callee or not caller: + continue + + # A scoped call (`Foo::m()`, `self::m()`, #3872) names its class in the + # source, so it binds EXTRACTED; an instance receiver is typed through + # the extractor's annotation tables and binds INFERRED. + scoped = bool(raw_call.get("php_scoped")) + receiver_lower = receiver.lower() + if receiver == "this" and not scoped: + exact = True + type_nid = enclosing_type.get(caller) + if not type_nid: + continue + elif scoped and receiver_lower in ("self", "static"): + exact = True + type_nid = enclosing_type.get(caller) + if not type_nid: + continue + elif scoped and receiver_lower == "parent": + bases = inherits_bases.get(enclosing_type.get(caller), []) + if len(bases) != 1: + continue + exact = True + type_nid = bases[0] + else: + exact = scoped + if scoped: + if receiver.startswith("$"): + continue # `$var::m()`: no declared type to resolve against + type_name = receiver.lstrip("\\").rsplit("\\", 1)[-1] + qualified = receiver if "\\" in receiver else None + else: + type_name = raw_call.get("receiver_type") + qualified = raw_call.get("receiver_type_qualified") + if not type_name: + continue # untyped / union-typed / unknown receiver: refuse + resolved, decisive = resolver.resolve_type_name( + type_name, qualified, raw_call.get("source_file", ""), + ) + type_defs = type_def_nids.get(key(type_name), []) + if resolved: + type_nid = resolved + elif not type_defs: + # No PHP file here declares the type: park the call on the + # caller so a merged graph can finish it (#3152). The short name + # is parked, the form cross_repo_calls matches labels against. + _park_unresolved_member_call( + node_by_id.get(caller), callee, + str(type_name).lstrip("\\").rsplit("\\", 1)[-1], "php", raw_call, + ) + continue + elif decisive: + continue # the name is claimed and names no in-corpus class (#16) + elif len(type_defs) != 1: + continue # short name collides across the corpus: refuse + else: + type_nid = type_defs[0] + if receiver == "(new)": + # The class is named in the source; promote to EXTRACTED + # only when the written namespace backs the node we found. + type_node = node_by_id.get(type_nid) + exact = _php_qualified_corroborates( + raw_call.get("receiver_qualified"), + type_node, + declared_fqn(type_node), + ) + + method_nids = method_index.get((type_nid, key(callee)), set()) + if len(method_nids) != 1: + continue # the typed receiver's class has no such method: refuse + method_nid = next(iter(method_nids)) + if method_nid == caller or (caller, method_nid) in existing_pairs: + continue + existing_pairs.add((caller, method_nid)) + all_edges.append({ + "source": caller, + "target": method_nid, + # A first-class callable NAMES the method without invoking it, so it + # is the repo's `indirect_call`, not `calls` (#15). Everything else + # above — receiver typing, the claimed-name and single-definition + # refusals, the confidence ladder — is deliberately shared. + "relation": "indirect_call" if raw_call.get("fcc") else "calls", + "context": "call", + "confidence": "EXTRACTED" if exact else "INFERRED", + "confidence_score": 1.0 if exact else 0.85, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + }) + + def _resolve_objc_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -6246,8 +6613,17 @@ def _is_protocol_declaration(n: dict) -> bool: node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if (n.get("source_file") and n.get("id") in contained - and _is_type_like_definition(n) and not _is_protocol_declaration(n)): + # Two independent refusals compose here, both narrowing the same index. + # Scoped to ObjC sources (#8), same defect and fix as the PHP twin: an + # unscoped index let a foreign class type an ObjC receiver, and let a + # foreign class merely sharing the name suppress the correct ObjC edge. + # `_is_owned_definition` subsumes upstream's `source_file` truthiness + # test — a node with no source file ends with no suffix — so the ObjC + # scoping is kept as the stricter of the two, and upstream's protocol + # exclusion (#2589) is ANDed on top rather than replacing it. + if (_is_owned_definition(n, _OBJC_RESOLVER_SUFFIXES) + and n.get("id") in contained and _is_type_like_definition(n) + and not _is_protocol_declaration(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) method_index: dict[tuple[str, str], str] = {} @@ -7276,10 +7652,18 @@ def _resolve_kotlin_member_calls( LanguageResolver("terraform_modules", frozenset({".tf"}), resolve_terraform_modules) ) register_language_resolver( - LanguageResolver("swift_member_calls", frozenset({".swift"}), _resolve_swift_member_calls) + LanguageResolver( + "swift_member_calls", + frozenset(_SWIFT_RESOLVER_SUFFIXES), + _resolve_swift_member_calls, + ) ) register_language_resolver( - LanguageResolver("python_member_calls", frozenset({".py"}), _resolve_python_member_calls) + LanguageResolver( + "python_member_calls", + frozenset(_PYTHON_RESOLVER_SUFFIXES), + _resolve_python_member_calls, + ) ) # Ruby type-aware member-call resolution (Class.new + typed var.method). Lives in # graphify.ruby_resolution; registered here as a second consumer of the framework. @@ -7296,27 +7680,41 @@ def _resolve_kotlin_member_calls( register_language_resolver( LanguageResolver( "cpp_member_calls", - frozenset({".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h"}), + frozenset(_CPP_RESOLVER_SUFFIXES), _resolve_cpp_member_calls, ) ) register_language_resolver( LanguageResolver( "objc_member_calls", - frozenset({".m", ".mm", ".h"}), + frozenset(_OBJC_RESOLVER_SUFFIXES), _resolve_objc_member_calls, ) ) # C# receiver-typed member-call resolution (#1609): `field/param/local.Method()` # bound to the receiver's declared type instead of a bare same-named match. register_language_resolver( - LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls) + LanguageResolver( + "csharp_member_calls", + frozenset(_CSHARP_RESOLVER_SUFFIXES), + _resolve_csharp_member_calls, + ) ) register_language_resolver( - LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls) + LanguageResolver( + "java_member_calls", + frozenset(_JAVA_RESOLVER_SUFFIXES), + _resolve_java_member_calls, + ) ) +# PHP receiver-typed member-call resolution (#1682): `$this->prop->method()` +# bound to the property's declared type instead of a bare same-named match. register_language_resolver( - LanguageResolver("php_member_calls", frozenset({".php"}), _resolve_php_member_calls) + LanguageResolver( + "php_member_calls", + frozenset(_PHP_RESOLVER_SUFFIXES), + _resolve_php_member_calls, + ) ) register_language_resolver( LanguageResolver("rust_self_member_calls", frozenset({".rs"}), _resolve_rust_self_member_calls) @@ -9067,9 +9465,13 @@ def extract( `_callable_class` markers, #2438), and the member-call resolvers run by `run_language_resolvers` (#2437) — so a changed caller can still bind `foo()`, `obj.method()`, or `submit(handler)` to an - unchanged callee. They are never parsed, mutated, or returned; - raw_calls come only from `paths`, so only edges sourced by the - re-extracted files are emitted. + unchanged callee. They also carry the unchanged corpus's PHP + interface, enum and trait names, stamped as `_php_non_class_types` + on each PHP file node (#11, #12) — a persisted channel kept whole + for older graphs, though the member-call resolver stopped consuming + it when #53 lifted the refusal it fed. They are never + parsed, mutated, or returned; raw_calls come only from `paths`, so + only edges sourced by the re-extracted files are emitted. resolution_context_edges: structural resolver edges from the same unchanged corpus (#2437). These map receiver types to their methods and preserve inheritance evidence for language-specific resolvers. @@ -9962,6 +10364,11 @@ def _learn(e: dict) -> None: # graph is identical regardless of scan root (#2072). _repoint_python_package_imports(paths, all_nodes, all_edges, root) _repoint_python_sibling_imports(paths, all_nodes, all_edges, root) + # Then hint the disambiguator at each stem-named import's real target file, so + # a same-stem sibling cannot strand the edge on a salted-away id (#33). Must + # be after the repoints above (they rewrite some targets) and before + # disambiguation (the hint's only reader). + _hint_import_targets(paths, all_edges, root) _merge_swift_extensions(per_file, all_nodes, all_edges) # Salt colliding ids apart BEFORE the C# partial merge. The merge rewires the # edges of every half onto the canonical half's id; if that id collides with @@ -10117,7 +10524,10 @@ def _looks_like_bash(result: object) -> bool: sh_paths = [p for _, p in sh_pairs] try: all_edges.extend( - resolve_bash_source_edges(sh_results, sh_paths, root, existing_edges=all_edges) + resolve_bash_source_edges( + sh_results, sh_paths, root, existing_edges=all_edges, + file_nids=_file_nids_by_path(all_nodes, all_edges, root), + ) ) except Exception as exc: import logging @@ -10155,11 +10565,22 @@ def _looks_like_bash(result: object) -> bool: n for n in resolution_context_nodes if n.get("id") and n["id"] not in _fresh_ids ] + # #52: ids of nodes whose label marks them as a MEMBER of a type — the + # engine labels a method `.name()` and a property `.name`, against a + # top-level function's `name()`. The normalization below erases that marker + # (`.event()` and `event()` both key as `event`), which is what let a PHP + # `event(...)` function call bind to a class's `event()` METHOD. The label + # is persisted in graph.json, so the discriminator holds just as well for + # candidates handed back as unchanged-corpus resolution context (#2406) — + # unlike an id-shape or edge-derived test. + member_labelled_nids: set[str] = set() for n in resolution_nodes: if n.get("file_type") == "rationale" or n.get("type") == "namespace": continue raw = n.get("label", "") normalised = raw.strip("()").lstrip(".") + if raw.strip("()").startswith("."): + member_labelled_nids.add(n["id"]) if normalised: # Case is semantic in most languages, so index (and match, below) by exact # case — folding collapses `Path` (class) into `PATH` (env var) and makes a @@ -10182,6 +10603,20 @@ def _looks_like_bash(result: object) -> bool: # them from the indirect_call guard below to avoid false edges (#2137). class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")} + def _is_php_function_target(nid: str) -> bool: + """Can a bare PHP ``name(...)`` reach this candidate? (#52) + + Only a global or namespaced FUNCTION. A METHOD needs ``$obj->`` / + ``Class::`` / callable syntax, and a CLASS-LIKE declaration (class, + interface, enum, trait) is not invocable at all — ``Report($e)`` where + only ``class Report`` exists is a "Call to undefined function" fatal, + not a constructor call. Both discriminators survive an incremental + rebuild: the member marker rides the persisted LABEL, and + ``_callable_class`` is one of the markers `graphify update` / `watch` + replay onto their `resolution_context_nodes` (cli.py, watch.py). + """ + return nid not in member_labelled_nids and nid not in class_nids + # Kotlin import targets (#2526): rewrite each `imports` edge from the bare # last-segment id to the node its written FQN names, via the per-file # package declarations. Runs HERE — after the id-remap/disambiguation passes @@ -10341,6 +10776,35 @@ def _looks_like_bash(result: object) -> bool: candidates = global_label_to_nids_ci.get(callee.lower(), []) if not candidates: continue + # #52: a PHP `function_call_expression` — a bare `name(...)` — can only + # invoke a global/namespaced FUNCTION, so only plausible function targets + # stay candidates (`_is_php_function_target`). Laravel's `event(...)` + # helper was binding to whichever class declared an `event()` METHOD — + # 848 incoming edges on one test method in the measured corpus — because + # the label index erases the `.` member marker and the method was then + # the sole candidate → single-candidate bind. Filtering the candidate + # list (rather than the index) covers every consumer below at once — + # single-candidate bind, import-evidence disambiguation and the god-node + # tie-break. PHP-only by design: Ruby and Python bare calls reach methods + # through implicit self, so their candidate filtering is unchanged. + if rc.get("php_function_call"): + candidates = [c for c in candidates if _is_php_function_target(c)] + # Refusing the exact-case candidates re-opens the folded fallback, + # which only ever fires on an EMPTY exact-case list above. A method + # shadowing the exact-case key (`.event()` → `event`) otherwise hid a + # real `function Event()` — reachable solely under the folded key — + # and the site resolved to nothing at all. Same folded index, same + # case-insensitive-caller gate, same refusal applied to it: without + # the refusal here the retry reaches a capitalized CLASS under the + # folded key (`report(...)` → `class Report`) that the exact-case + # path never offered — 7 such edges on the measured corpus. + if not candidates and _lang_is_case_insensitive(rc.get("source_file")): + candidates = [ + c for c in global_label_to_nids_ci.get(callee.lower(), []) + if _is_php_function_target(c) + ] + if not candidates: + continue if rc.get("csharp_new") or rc.get("lang") == "csharp": # An enum member or a field/property is never a valid call target # either — not just never what `new X()` constructs. A bare call @@ -10662,11 +11126,28 @@ def _has_import_evidence(candidate_id: str) -> bool: # results: raw_calls come solely from `paths`, so nothing sourced by an # unchanged file is ever emitted, and the ambiguity guards count the same # candidates a full build would (the context is the whole unchanged corpus). + # + # #11/#12: the unchanged corpus's INTERFACE, ENUM and TRAIT names ride in on + # the context nodes' `_php_non_class_types` marker and are handed over as one + # extra `per_file` entry (scratch list, the real `per_file` is untouched). + # The member-call resolver stopped consuming them in #53 — its refusal was + # made redundant by the declaration nodes #47 mints — but the channel is kept + # whole for the graphs still carrying it; see `_php_context_interface_entry`. if resolution_context_nodes or resolution_context_edges: _rl_nodes = list(resolution_nodes) _rl_edges = all_edges + list(resolution_context_edges or []) + _rl_per_file = per_file + _php_ctx_entry = _php_context_interface_entry(resolution_context_nodes) + # #23: the declared-FQN maps ride the same marker channel, per defining + # file — without them the #22 binding goes blind to every class whose + # file this rebuild left alone, and its edges exist only on full builds. + _php_ctx_fqns = _php_context_class_fqn_entries(resolution_context_nodes) + if _php_ctx_entry is not None or _php_ctx_fqns: + _rl_per_file = [*per_file, + *([_php_ctx_entry] if _php_ctx_entry else []), + *_php_ctx_fqns] _n0, _e0 = len(_rl_nodes), len(_rl_edges) - run_language_resolvers(paths, per_file, _rl_nodes, _rl_edges) + run_language_resolvers(paths, _rl_per_file, _rl_nodes, _rl_edges) all_nodes.extend(_rl_nodes[_n0:]) all_edges.extend(_rl_edges[_e0:]) else: @@ -10816,6 +11297,16 @@ def _canon(nid: str) -> str: # label (that would reintroduce the #1566/#2137 data-symbol false positives); # a graph written before the markers existed simply fails closed until its # files are re-extracted. + # `_php_non_class_types` (#11, #12) persists for the same reason: it is the + # only record of which corpus names are interface/enum/trait declarations + # that survives a rebuild leaving the declaring file undispatched. Both + # spellings are read back, the pre-#12 `_php_interfaces` included. Nothing + # consumes it for resolution since #53 lifted the receiver refusal it fed; + # the channel stays because the graphs written by earlier versions carry it. + # `_php_class_fqns` (#23) persists likewise, failing in the safe direction + # too: a pre-marker graph loses the declared-FQN BINDING (#22) on an + # incremental rebuild — an absent edge, never a guessed one — until the + # defining file is re-extracted. # local_alias is a transient import-resolution hint (#2082), same shape as # target_file (#1814): it exists only so the module arm of diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index b1e6959e1..b5a2d56c1 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -16,6 +16,7 @@ from graphify.extractors.resolution import _resolve_js_import_target from graphify.security import sanitize_metadata from pathlib import Path +from typing import NamedTuple def _csharp_namespace_id(dotted_name: str) -> str: @@ -168,6 +169,139 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: stack.extend(n.children) return out +# The PHP declaration kinds that are not `class`. All three ARE in +# `_PHP_CONFIG.class_types` and mint definition nodes as of #47, so this is no +# longer "the kinds outside class_types"; it was the member-call resolver's +# refusal policy (#1682) until #53 lifted that refusal, and what remains is a +# record of declaration KIND. The name and the marker it feeds +# (`_php_non_class_types`) are kept as spelled: the marker is persisted in +# graph.json and read back on incremental rebuilds (#11/#12). +_PHP_NON_CLASS_DECLARATIONS = frozenset({ + "interface_declaration", + "enum_declaration", + "trait_declaration", +}) + + +def _php_pre_scan_non_class_declarations(root_node, source: bytes) -> set[str]: + """Return names declared as `interface`, `enum` or `trait` in this PHP file (#1682). + + These names once drove a REFUSAL: the member-call resolver would not bind a + receiver typed with one of them. Laravel's conventions make the collision + that motivated it realistic — an `App\\Contracts\\Notifier` interface beside + an unrelated `App\\Support\\Notifier` class, or an `App\\Enums\\Status` enum + beside an Eloquent `App\\Models\\Status` — because while the three minted no + definition node, exactly ONE definition existed under the short name, which + satisfied the single-definition guard and bound the receiver to a stranger. + + #47 gave all three kinds definition nodes, so the collision now presents TWO + and the guard refuses on its own; #53 lifted the blanket refusal on that + basis, and nothing consumes these names for resolution any more (see + `_php_context_interface_entry` in extract.py). The pre-scan and its + `_php_non_class_types` marker are kept because they are persisted in + graph.json: they are the only record of declaration KIND that survives into + a graph, and older graphs carry them. + """ + out: set[str] = set() + stack = [root_node] + while stack: + n = stack.pop() + if n.type in _PHP_NON_CLASS_DECLARATIONS: + name_node = n.child_by_field_name("name") + if name_node is not None: + text = _read_text(name_node, source) + if text: + out.add(text) + stack.extend(n.children) + return out + + +# The declaration kinds `_php_pre_scan_class_namespaces` reads a declared FQN +# off. `class_declaration` alone until #53: once an interface/enum/trait-typed +# receiver became bindable, a declaration WITHOUT a declared FQN was one the +# name guard could only judge by its PSR-4 path — strictly weaker, and weakest +# on exactly the incremental path where paths arrive relativized. All four kinds +# declare a namespaced type, so all four are read. +_PHP_FQN_DECLARATIONS = frozenset({ + "class_declaration", + "interface_declaration", + "enum_declaration", + "trait_declaration", +}) + + +def _php_pre_scan_class_namespaces(root_node, source: bytes) -> dict[str, str]: + """Map every namespaced type in this PHP file to its fully qualified name (#14). + + PHP class NODES carry no namespace, so the inline-`new` corroboration in + ``_php_qualified_corroborates`` had only the file's path to compare a + written ``\\App\\Services\\Client`` against. PSR-4 is a convention, not an + invariant: a file at ``app/Services/Client.php`` may declare + ``namespace App\\Vendor;`` (PSR-0 leftovers, classmap autoloaders, moved + files, generated code), and the written name then corroborates a class that + exists nowhere. The declaration is right there in the source — read it. + + ``interface``, ``enum`` and ``trait`` declarations are read alongside + ``class`` (#53). They were left out while #5/#12 refused every receiver + typed with such a name, which made their FQNs unused; once the refusal was + lifted and those declarations became bindable, the omission left + ``PhpNameResolver`` judging them by PSR-4 path alone — and + ``_php_fqn_names_another_class`` treats a path with fewer segments than the + written name as NO EVIDENCE and keeps the edge. A rebuild replays context + nodes with RELATIVIZED paths, so ``use Illuminate\\Contracts\\…\\Notifier;`` + (4 segments) against a replayed ``app/Contracts/Notifier.php`` (3) bound the + in-corpus interface that the vendor import provably does not name — a wrong + edge of the #16 class, and only on the incremental path. A declared FQN + makes that comparison whole-name and decisive on both paths, and lets + ``fqn_def_nid`` (#22) pick the imported one of several namesakes. + + Both namespace forms are handled: ``namespace X;`` applies to the + declarations that follow it (a file may switch namespaces mid-way), and + ``namespace X { … }`` applies to its block. A type declared in NO namespace + is deliberately absent from the map: the file states nothing, so the + resolver falls back to the path check rather than refusing. A short name + declared twice under different namespaces in one file is dropped — the map + is keyed by short name, and a wrong answer is worse than no answer. + """ + out: dict[str, str] = {} + conflicting: set[str] = set() + + # Each entry carries the namespace in force where it was queued, so the + # scopes stay right without walking siblings in order. A declaration body is + # never descended into: PHP has no nested type declarations, and an + # anonymous class inside a method names nothing. + stack = [(root_node, "")] + while stack: + node, namespace = stack.pop() + current = namespace + for child in node.children: + if child.type == "namespace_definition": + name_node = child.child_by_field_name("name") + # A braced `namespace { … }` names nothing: the global namespace. + declared = (_read_text(name_node, source).strip("\\") + if name_node is not None else "") + body = child.child_by_field_name("body") + if body is not None: + stack.append((body, declared)) + else: + current = declared # applies to the declarations that follow + continue + if child.type in _PHP_FQN_DECLARATIONS: + name_node = child.child_by_field_name("name") + name = _read_text(name_node, source) if name_node is not None else "" + if name and current: + fqn = f"{current}\\{name}" + short = name.casefold() + if out.setdefault(short, fqn) != fqn: + conflicting.add(short) + continue + if child.is_named: + stack.append((child, current)) + for short in conflicting: + out.pop(short, None) + return out + + _CSHARP_TYPE_DECLARATIONS = frozenset({ "class_declaration", "interface_declaration", @@ -889,75 +1023,426 @@ def _php_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s if c.is_named: _php_collect_type_refs(c, source, generic, out) -def _php_declared_type_name(node, source: bytes) -> str | None: - """The single class name a PHP type annotation names, or None. +# PHP type names that never denote a resolvable class definition. `self`, +# `static` and `parent` are relative (they need inheritance context the raw-call +# facts do not carry), the rest are builtins with no user definition. +_PHP_NON_CONCRETE_TYPE_NAMES = frozenset({ + "self", "static", "parent", "object", "mixed", "iterable", "callable", + "void", "never", "null", "true", "false", "array", "string", "int", + "float", "bool", +}) + + +class _PhpReceiverType(NamedTuple): + """A receiver's declared type: the short name plus the WRITTEN form (#20). - Only `Greeter` and `?Greeter` qualify: a union, an intersection or a primitive names - no one class, and binding a receiver to the first arm of `A|B` would be a guess. + `short` is what the resolver has always matched on — namespace-stripped, so + `\\Vendor\\Sdk\\Client` and a bare `Client` are the same key. `qualified` is + the annotation exactly as written, and ONLY when it carries a namespace + separator: an unqualified annotation leaves it None, so its facts stay + identical to the pre-#20 ones. It is independent evidence about WHICH + same-short-named class was meant — the fact the use-import resolver (#21) + needs and the flattening in `_php_name_text` used to destroy. """ - if node is None: - return None - if node.type == "optional_type": - node = next((c for c in node.children if c.is_named), None) - if node is None: - return None - if node.type != "named_type": + short: str + qualified: str | None + + +def _php_written_type(name_node, source: bytes) -> _PhpReceiverType | None: + """Pair a PHP `name`/`qualified_name` node's short name with its written text.""" + short = _php_name_text(name_node, source) + if not short or short.lower() in _PHP_NON_CONCRETE_TYPE_NAMES: return None - return next((_php_name_text(c, source) for c in node.children - if c.type in ("name", "qualified_name")), None) + written = _read_text(name_node, source) + return _PhpReceiverType(short, written if "\\" in written else None) + +def _php_concrete_type(type_node, source: bytes) -> _PhpReceiverType | None: + """Single concrete class named by a PHP type expression, or None (= refuse). -def _php_variable_text(node, source: bytes) -> str | None: - """The bare name a PHP `variable_name` binds: `$greeter` -> `greeter`.""" - if node is None or node.type != "variable_name": + Deliberately NOT `_php_collect_type_refs`: that helper flattens a union into + several refs, whereas a receiver typed `A|B` has no single type and must be + refused. `named_type` yields its name (short + written, #20); a nullable + wrapper around exactly one type unwraps (`?Foo` is still concretely Foo); + union, intersection, primitive and missing types yield None (#1682). + """ + if type_node is None: + return None + if type_node.type == "named_type": + for c in type_node.children: + if c.type in ("name", "qualified_name"): + return _php_written_type(c, source) return None - return next((_read_text(c, source) for c in node.children if c.type == "name"), None) + if type_node.type in ("optional_type", "nullable_type"): + inner = [c for c in type_node.named_children if c.type != "comment"] + if len(inner) == 1: + return _php_concrete_type(inner[0], source) + return None -def _php_first_declared_type(node, source: bytes) -> str | None: - """The first single-class type annotation among ``node``'s direct children.""" - return next((name for name in (_php_declared_type_name(c, source) for c in node.children) - if name), None) +# Type expressions that declare MORE THAN ONE possible class for a receiver: +# union (`A|B`), intersection (`A&B`) and PHP 8.2 disjunctive-normal-form +# (`(A&B)|C`, a union at top level) — node names probe-verified against +# tree-sitter-php 0.24.1. +_PHP_MULTI_TYPE_NODES = frozenset({ + "union_type", "intersection_type", "disjunctive_normal_form_type", +}) -def _php_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None: - """Collect ``name -> TypeName`` for every PHP receiver whose type is written down. +def _php_multi_typed_annotation(type_node) -> bool: + """True when a PHP type annotation names more than one candidate class (#9). - Four sources, all needed: a typed property (`private Greeter $g;`), a constructor - promotion (`__construct(private Greeter $g)`), a typed parameter, and - `$g = new Greeter()`. File-scoped and flat, first binding wins — a parameter - shadowing a property in another method must not retype the property's own calls. - Children are pushed reversed so the walk yields document order and "first" means - first in the file. + `_php_concrete_type` refuses several shapes, and the two reasons for + refusal must be told apart at the call site (user story 11): a receiver whose + annotation is a UNION or INTERSECTION provably has several possible classes, + so an in-file bare-name match can only pick one of them by file order and + must be suppressed. The policy's other refusals — `self`/`static`/`parent`, + primitives, `mixed`/`object`/`iterable`/`callable`, a nullable wrapping more + than one type — declare no such multiplicity, so they deliberately keep + today's in-file edge, exactly like a genuinely untyped receiver (#2's + accepted deviation, user story 9). `self` especially: it names the calling + class, whose methods usually ARE the in-file match. """ - stack = [root] + return type_node is not None and type_node.type in _PHP_MULTI_TYPE_NODES + + +# Subtrees that are a DIFFERENT binding scope than the method being scanned: +# their assignments must not type the enclosing method's variables. Closures are +# deliberately absent — their calls are attributed to the enclosing method, so +# their locals belong to the same raw-call scope (their PARAMETERS are poisoned +# separately, since a shadowed name cannot be told apart from the outer one). +_PHP_FOREIGN_SCOPE_TYPES = frozenset({ + "anonymous_class", + "class_declaration", + "interface_declaration", + "trait_declaration", + "enum_declaration", + "function_definition", + "method_declaration", +}) + +_PHP_CLOSURE_TYPES = frozenset({"anonymous_function", "arrow_function"}) + + +def _php_method_receiver_types( + method_node, + source: bytes, + field_types: dict[str, _PhpReceiverType | None], +) -> dict[str, _PhpReceiverType | None]: + """Build the receiver type table visible to one PHP method (#1682). + + ``this.`` keys come from the declaring class's typed properties and + constructor-promoted params. PHP properties are reachable ONLY through + ``$this->``, so these keys can never collide with a local variable name. + + Bare keys come from natively typed parameters and ``$var = new T()`` locals. + Raw calls retain no lexical scope, so a name is POISONED — dropped from the + table entirely — whenever its binding is not provably single-typed: a rebind + to anything but a `new`, two conflicting `new` types, an augmented + assignment, a closure or arrow-function parameter shadowing it, a foreach + target, a list-destructuring element, or a `global`/`static` statement + rebinding it to other storage. Poisoning is order-independent, which is why + it can be decided from a single unordered walk. + + A key mapped to None is PRESENT but unresolved: its annotation named several + candidate classes (`A|B`, `A&B`), which the call site must tell apart from an + ABSENT key, meaning no annotation at all (#9). Precedence is concrete type > + multi-class refusal > absent, so a union-typed param later assigned a `new T()` + still resolves to T, while a poisoned union-typed one stays refused. + + A resolved value carries both the short name and the written qualified form + (#20); every decision below is taken on the SHORT name alone, so the table's + keys and their resolved/refused/absent status are exactly what they were. + """ + table: dict[str, _PhpReceiverType | None] = { + f"this.{name}": type_name for name, type_name in field_types.items() + } + method_types: dict[str, _PhpReceiverType] = {} + multi_typed_params: set[str] = set() + ambiguous: set[str] = set() + + def poison(name: str) -> None: + if name: + method_types.pop(name, None) + ambiguous.add(name) + + def bind(name: str, declared: _PhpReceiverType | None) -> None: + if not name or name in ambiguous: + return + previous = method_types.get(name) + if declared is None or (previous is not None and previous.short != declared.short): + poison(name) + elif previous is not None and previous.qualified != declared.qualified: + # Same short name written two ways (`new Client()` then + # `new \Vendor\Client()`): the SHORT binding is what it always was, + # so keep it. Only the qualified evidence conflicts — drop that + # rather than poison a name today's table still types (#20). + method_types[name] = _PhpReceiverType(previous.short, None) + else: + method_types[name] = declared + + def poison_bound_vars(node) -> None: + """Poison every ``$var`` named anywhere in a binding-site subtree. + + Covers `[$a, [$b]] = …`, `list($a, $b) = …`, `foreach … as $k => &$v` + and closure parameter lists in one sweep (shapes probe-verified). + """ + stack = [node] + while stack: + n = stack.pop() + if n is None: + continue + if n.type == "variable_name": + poison(_read_text(n, source).lstrip("$")) + continue + stack.extend(n.children) + + def new_type_name(node) -> _PhpReceiverType | None: + """Class named by an ``object_creation_expression``, or None. + + `new self()` / `new static()` need inheritance context the raw-call + facts do not carry, so the non-concrete set refuses them. + """ + if node is None or node.type != "object_creation_expression": + return None + cls = next((c for c in node.named_children + if c.type in ("name", "qualified_name")), None) + if cls is None: # `new class { … }` names nothing + return None + return _php_written_type(cls, source) + + # Natively typed parameters. `variadic_parameter` is excluded on purpose: + # `T ...$xs` binds an ARRAY of T, not a T. + params = method_node.child_by_field_name("parameters") + if params is not None: + for param in params.children: + if param.type not in ("simple_parameter", "property_promotion_parameter"): + continue + type_node = param.child_by_field_name("type") + declared = _php_concrete_type(type_node, source) + name_node = param.child_by_field_name("name") + if name_node is not None and declared: + # Untyped / primitive params simply stay unbound. + bind(_read_text(name_node, source).lstrip("$"), declared) + elif name_node is not None and _php_multi_typed_annotation(type_node): + # A union/intersection param is not BOUND — `bind(None)` would + # poison it, and poisoning is indistinguishable from untyped. + # Mark it instead, and let the merge below apply precedence. + multi_typed_params.add(_read_text(name_node, source).lstrip("$")) + + body = method_node.child_by_field_name("body") + stack = list(body.children) if body is not None else [] while stack: - n = stack.pop() - t = n.type - if t == "property_declaration": - type_name = _php_first_declared_type(n, source) - for element in n.children if type_name else (): - if element.type != "property_element": - continue - name = next((_php_variable_text(c, source) for c in element.children - if c.type == "variable_name"), None) - if name and name not in table: - table[name] = type_name - elif t in ("property_promotion_parameter", "simple_parameter"): - type_name = _php_first_declared_type(n, source) - name = next((_php_variable_text(c, source) for c in n.children - if c.type == "variable_name"), None) - if name and type_name and name not in table: - table[name] = type_name - elif t == "assignment_expression": - right = n.child_by_field_name("right") - if right is not None and right.type == "object_creation_expression": - name = _php_variable_text(n.child_by_field_name("left"), source) - type_name = next((_php_name_text(c, source) for c in right.children - if c.type in ("name", "qualified_name")), None) - if name and type_name and name not in table: - table[name] = type_name - stack.extend(reversed(n.children)) + node = stack.pop() + if node.type in _PHP_FOREIGN_SCOPE_TYPES: + continue + if node.type in _PHP_CLOSURE_TYPES: + poison_bound_vars(node.child_by_field_name("parameters")) + elif node.type == "foreach_statement": + # children are [iterated expression, target(s)…, body]; only the + # targets rebind names, and the element type is unknown. + body_node = node.child_by_field_name("body") + targets = [c for c in node.named_children if c is not body_node] + for target in targets[1:]: + poison_bound_vars(target) + elif node.type in ("global_declaration", "function_static_declaration"): + # `global $svc;` / `static $svc;` rebind the NAME to DIFFERENT + # storage — the global slot, or the function-static slot that starts + # out null — so any type learned from a `new` in this body is stale + # (#13). Name-targeted, not statement-targeted: `global $other;` + # must leave `$svc`'s binding intact. Both multi-name forms + # (`global $a, $svc;` and `static $x = 1, $svc;`) carry one + # `variable_name` per declared name, and a static initializer is a + # constant expression, so sweeping the whole statement names exactly + # the rebound variables (shapes probe-verified). + poison_bound_vars(node) + elif node.type == "augmented_assignment_expression": + left = node.child_by_field_name("left") + if left is not None and left.type == "variable_name": + poison(_read_text(left, source).lstrip("$")) + elif node.type == "assignment_expression": + left = node.child_by_field_name("left") + if left is not None and left.type == "list_literal": + poison_bound_vars(left) + elif left is not None and left.type == "variable_name": + bind( + _read_text(left, source).lstrip("$"), + new_type_name(node.child_by_field_name("right")), + ) + stack.extend(node.children) + + table.update(method_types) + for name in ambiguous: + table.pop(name, None) + for name in multi_typed_params: + # setdefault, so a concrete type learned from a `new` wins; a poisoned + # name (popped just above) falls back to the multi-class refusal. + table.setdefault(name, None) + return table + + +def _php_ctor_assigned_field_types( + ctor_node, + other_method_nodes, + source: bytes, +) -> dict[str, _PhpReceiverType]: + """Type the properties a CONSTRUCTOR BODY assigns from a typed param (#38). + + The pre-promotion idiom that dominates a Laravel corpus declares the + property untyped — its type living only in a ``@var`` docblock — and takes + the collaborator as a typed constructor parameter:: + + /** @var ChargeCustomerService */ + protected $chargeCustomerService; + public function __construct(ChargeCustomerService $chargeCustomerService) { + $this->chargeCustomerService = $chargeCustomerService; + } + + The parameter's SIGNATURE is the evidence; the docblock is deliberately not + read (docblocks lie more often than signatures do). The result feeds the + same ``this.`` receiver table as a native property type, merged UNDER + it — a real annotation, including a union's refusal (#9), always wins. + + Refusal discipline mirrors the local-variable one: a property is POISONED — + left out entirely — unless its binding is provably single-typed. Poisoned by + a second assignment of a different or untypable type, an augmented or + by-reference assignment, or a list-destructuring or ``foreach`` target. + Every poison rule is order-independent, so one unordered walk decides them + all. + + A property is not a local, so the poison scope is the whole CLASS, not the + constructor: ``other_method_nodes`` (every other method of the same class) + is walked for writes to ``$this->prop`` too, and any it finds refuses the + binding. Such a write is the same category as a write from inside a closure + — deferred, and possibly running never, later, or repeatedly — so both take + the identical ``deferred`` path below, which can only refuse and never bind. + The refusal is unconditional on the write rather than conditional on the + written type: a same-typed setter is provably harmless and loses its edge + anyway, which is the trade this discipline makes everywhere else too. + The DECLARED path is untouched by all of this and needs no such sweep — PHP + enforces a native property type on every write, so a setter cannot retype a + typed or promoted property, while an untyped one carries no such guarantee. + + The right-hand side must name a parameter of THIS constructor whose own + binding survived ``_php_method_receiver_types``, which is what rules out an + untyped or union-typed parameter and one rebound anywhere in the body + (including by a shadowing closure parameter). A local built in the body is + not a parameter: ``$this->prop = $svc`` after ``$svc = new T()`` poisons the + property rather than binding it, keeping this to the one form #38 scopes. + """ + # Bare keys only (no field types are passed in): the constructor's own + # parameters and locals, already stripped of every poisoned name. + local_types = _php_method_receiver_types(ctor_node, source, {}) + + param_names: set[str] = set() + params = ctor_node.child_by_field_name("parameters") + for param in params.children if params is not None else (): + if param.type not in ("simple_parameter", "property_promotion_parameter"): + continue + name_node = param.child_by_field_name("name") + if name_node is not None: + param_names.add(_read_text(name_node, source).lstrip("$")) + + assigned: dict[str, _PhpReceiverType] = {} + refused: set[str] = set() + + def refuse(prop: str) -> None: + if prop: + assigned.pop(prop, None) + refused.add(prop) + + def bind(prop: str, declared: _PhpReceiverType | None) -> None: + if not prop or prop in refused: + return + previous = assigned.get(prop) + if declared is None or (previous is not None and previous.short != declared.short): + refuse(prop) + elif previous is not None and previous.qualified != declared.qualified: + # Same short name reached through two differently written params: + # keep the short binding, drop the conflicting qualified evidence + # (the local-table rule, #20). + assigned[prop] = _PhpReceiverType(previous.short, None) + else: + assigned[prop] = declared + + def this_prop(node) -> str: + """Property name in ``$this->prop``, or "" for any other target. + + Rejects ``$other->prop`` (a different object), ``$this->{$name}`` (the + name is computed) and ``$this->a->b`` (whose target is a property of + the property, leaving ``a``'s own binding untouched). + """ + if node is None or node.type != "member_access_expression": + return "" + obj = node.child_by_field_name("object") + name = node.child_by_field_name("name") + if obj is None or name is None or name.type != "name": + return "" + if obj.type != "variable_name" or _read_text(obj, source) != "$this": + return "" + return _read_text(name, source) + + def refuse_props_in(node) -> None: + """Refuse every ``$this->prop`` named anywhere in a binding-site subtree + (``[$this->a, $b] = …``, ``foreach … as $this->a``).""" + stack = [node] + while stack: + n = stack.pop() + if n is None: + continue + refuse(this_prop(n)) + stack.extend(n.children) + + def param_type(node) -> _PhpReceiverType | None: + """The type of the parameter ``node`` names, or None (which refuses).""" + if node is None or node.type != "variable_name": + return None + name = _read_text(node, source).lstrip("$") + return local_types.get(name) if name in param_names else None + + def body_children(method_node): + body = method_node.child_by_field_name("body") + return body.children if body is not None else () + + # `deferred` rides along with each node and marks a write whose timing is + # not the constructor's own: one made from inside a closure, or one made by + # another method of the class. The walk descends into both (to poison what + # they write) without ever letting them BIND. An abstract or interface + # method has no body and contributes nothing. + stack = [(child, False) for child in body_children(ctor_node)] + for method_node in other_method_nodes: + stack.extend((child, True) for child in body_children(method_node)) + while stack: + node, deferred = stack.pop() + if node.type in _PHP_FOREIGN_SCOPE_TYPES: + continue # a nested class/function has a different (or no) `$this` + if node.type == "assignment_expression": + left = node.child_by_field_name("left") + if left is not None and left.type == "list_literal": + refuse_props_in(left) + elif deferred: + refuse(this_prop(left)) + else: + prop = this_prop(left) + if prop: + bind(prop, param_type(node.child_by_field_name("right"))) + elif node.type in ("augmented_assignment_expression", + "reference_assignment_expression"): + # `$this->prop .= …` composes an unknown value; `$this->prop = &$svc` + # aliases the variable's storage, so rebinding it retypes the + # property. Neither is a single-typed binding. + refuse_props_in(node.child_by_field_name("left")) + elif node.type == "foreach_statement": + body_node = node.child_by_field_name("body") + targets = [c for c in node.named_children if c is not body_node] + for target in targets[1:]: # children are [iterated, target(s)…, body] + refuse_props_in(target) + nested = deferred or node.type in _PHP_CLOSURE_TYPES + stack.extend((child, nested) for child in node.children) + + for prop in refused: + assigned.pop(prop, None) + return assigned def _php_method_return_type_node(method_node): @@ -5659,6 +6144,13 @@ def _extract_generic( # same-named, explicitly typed receiver in a different method. csharp_field_types: dict[str, dict[str, str]] = {} csharp_method_scopes: dict[int, tuple[object, str]] = {} + # PHP receiver typing (#1682): typed properties and constructor-promoted + # params of the declaring class, keyed `this.` per method scope. + # `prop -> declared type` per class. A value of None means the annotation was + # PRESENT but named several candidate classes (`A|B`, `A&B`), which the call + # site must tell apart from an ABSENT key = no annotation at all (#9). + php_field_types: dict[str, dict[str, _PhpReceiverType | None]] = {} + php_method_scopes: dict[int, tuple[object, str]] = {} # class nid -> method name -> one [return type, type-param count] per # overload, for typing `var v = R.M()` cross-file (#4266). csharp_method_returns: dict[str, dict[str, list]] = {} @@ -5672,6 +6164,12 @@ def _extract_generic( root, source, stem ) + php_non_class_type_names: set[str] = set() + php_class_fqns: dict[str, str] = {} + if config.ts_module == "tree_sitter_php": + php_non_class_type_names = _php_pre_scan_non_class_declarations(root, source) + php_class_fqns = _php_pre_scan_class_namespaces(root, source) + swift_protocol_names: set[str] = set() swift_class_names: set[str] = set() if config.ts_module == "tree_sitter_swift": @@ -6954,9 +7452,30 @@ def scala_base_name(type_node) -> str | None: and parent_class_nid): for c in node.children: if c.type not in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + # PHP 8.2 `(A&B)|C`, absent from this list until + # #9 and so invisible to both the receiver table + # and the type-reference walk below. + "disjunctive_normal_form_type"): continue line = node.start_point[0] + 1 + # #1682: remember the property's declared type so a later + # `$this->prop->method()` resolves against it. Only a single + # concrete class name counts — unions/primitives are refused. + # A multi-class annotation is recorded as None so the call site + # can defer rather than bare-name match it (#9); every other + # refusal leaves the property out of the table entirely. A + # resolved type keeps the written qualified form too (#20). + declared = _php_concrete_type(c, source) + multi_typed = _php_multi_typed_annotation(c) + if declared or multi_typed: + fields = php_field_types.setdefault(parent_class_nid, {}) + for pe in node.children: + if pe.type != "property_element": + continue + v = pe.child_by_field_name("name") + if v is not None: + fields[_read_text(v, source).lstrip("$")] = declared refs: list[tuple[str, str]] = [] _php_collect_type_refs(c, source, False, refs) for ref_name, role in refs: @@ -7508,9 +8027,22 @@ def scala_base_name(type_node) -> str | None: type_node = None for sub in p.children: if sub.type in ("named_type", "primitive_type", "nullable_type", - "union_type", "intersection_type", "optional_type"): + "union_type", "intersection_type", "optional_type", + "disjunctive_normal_form_type"): type_node = sub break + # #1682: a promoted param IS a typed class property — + # record it in the same `this.` receiver table, + # multi-class annotations included as None (#9). + if is_promoted and parent_class_nid: + promoted_type = _php_concrete_type(type_node, source) + v = p.child_by_field_name("name") + if v is not None and ( + promoted_type or _php_multi_typed_annotation(type_node) + ): + php_field_types.setdefault(parent_class_nid, {})[ + _read_text(v, source).lstrip("$") + ] = promoted_type refs: list[tuple[str, str]] = [] _php_collect_type_refs(type_node, source, False, refs) for ref_name, role in refs: @@ -7756,6 +8288,8 @@ def scala_base_name(type_node) -> str | None: java_method_scopes[id(body)] = (node, parent_class_nid) if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid: csharp_method_scopes[id(body)] = (node, parent_class_nid) + if config.ts_module == "tree_sitter_php" and parent_class_nid: + php_method_scopes[id(body)] = (node, parent_class_nid) function_bodies.append((func_nid, body)) if config.ts_module in ( "tree_sitter_javascript", "tree_sitter_typescript" @@ -8157,6 +8691,10 @@ def scala_base_name(type_node) -> str | None: seen_call_pairs: set[tuple[str, str]] = set() seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup + # PHP first-class-callable indirect_call dedup (#15), kept apart from + # seen_call_pairs so a `$this->m(...)` reference can never swallow the real + # `$this->m()` call to the same target. + seen_php_fcc_pairs: set[tuple[str, str]] = set() seen_dyn_import_pairs: set[tuple[str, str]] = set() seen_static_ref_pairs: set[tuple[str, str, str]] = set() seen_helper_ref_pairs: set[tuple[str, str, str]] = set() @@ -8252,6 +8790,55 @@ def _fields_up_chain(tables: dict, class_nid) -> dict: ) for body_id, (method_node, class_nid) in csharp_method_scopes.items() } + # #38: a property assigned in the CONSTRUCTOR BODY from a typed constructor + # parameter (`$this->svc = $svc;`) is typed as surely as a natively typed + # property is — the pre-promotion idiom that dominates a Laravel corpus, + # where the declaration carries only a `@var` docblock. Merged with + # setdefault, so the declared and promoted types the walk already collected + # win, and this fills only the gaps. Runs before the tables below because + # `walk(root)` is complete, so declaration order is free and a call inside + # the constructor itself sees the binding too. + # + # Grouped by class because the refusal is PROPERTY-scoped, not + # constructor-scoped: every OTHER method of the same class is handed to the + # walk as well, so a setter that rewrites the property refuses the + # constructor's binding instead of leaving a stale one behind. `class_nid` + # keys the group, so a second class in the same file can never poison this + # one's properties — and an anonymous class registers no method scope at + # all (it is not a `class_declaration`, so it owns no class_nid; probed), + # which keeps its own `$this` writes out of every group. + php_class_methods: dict[str, list] = {} + for method_node, class_nid in php_method_scopes.values(): + php_class_methods.setdefault(class_nid, []).append(method_node) + + def _php_is_ctor(method_node) -> bool: + # PHP method names are case-insensitive, `__CONSTRUCT` included. + name_node = method_node.child_by_field_name("name") + return (name_node is not None + and _read_text(name_node, source).casefold() == "__construct") + + for class_nid, method_nodes in php_class_methods.items(): + ctor_node = next((m for m in method_nodes if _php_is_ctor(m)), None) + if ctor_node is None: + continue + assigned = _php_ctor_assigned_field_types( + ctor_node, + [m for m in method_nodes if m is not ctor_node], + source, + ) + if assigned: + fields = php_field_types.setdefault(class_nid, {}) + for prop, declared in assigned.items(): + fields.setdefault(prop, declared) + + php_receiver_types = { + body_id: _php_method_receiver_types( + method_node, + source, + php_field_types.get(class_nid, {}), + ) + for body_id, (method_node, class_nid) in php_method_scopes.items() + } def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str, context: str) -> None: @@ -8422,9 +9009,15 @@ def _php_class_const_scope(n) -> str | None: def walk_calls( node, caller_nid: str, - # Java: flat name -> type. C#: the (scoped bindings, field base) pair - # from _csharp_method_receiver_types, resolved positionally (#2472). - receiver_types: dict[str, str] | tuple | None = None, + # Java: a flat name -> plain short type name. C#: the (scoped + # bindings, field base) pair from _csharp_method_receiver_types, + # resolved positionally (#2472). PHP: a flat name -> (short, + # qualified) pair, which may be None — see + # _php_method_receiver_types. Body ids are language-disjoint, so one + # body's table only ever meets its own language's reader. + receiver_types: ( + dict[str, str | _PhpReceiverType | None] | tuple | None + ) = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -8508,6 +9101,22 @@ def walk_calls( is_scoped_call: bool = False swift_receiver: str | None = None member_receiver: str | None = None + # PHP inline instantiation `(new X())->m()` (#1682): the class is + # named in the source, so it needs no type table — keep both the + # short name (for lookup) and the written text (for corroboration). + php_inline_new_type: str | None = None + php_inline_new_qualified: str | None = None + # PHP 8.1 first-class callable `$obj->method(...)` (#15): a + # reference to the method, not an invocation — re-tagged as + # `indirect_call` below and by the cross-file PHP resolver. + php_fcc: bool = False + # PHP `function_call_expression`, i.e. a bare `name(...)` (#52). It + # can only invoke a global/namespaced FUNCTION — a method needs + # `$obj->`, `Class::` or callable syntax — so the cross-file pass + # refuses method candidates for it. Recorded here because the + # raw-call facts otherwise cannot tell it from a `scoped_call` + # (which is also not a member call and names its scope as callee). + php_function_call: bool = False kotlin_qualified_prefix: str | None = None kotlin_object_receiver: str | None = None csharp_qualified_prefix: str | None = None @@ -8820,6 +9429,7 @@ def walk_calls( elif config.ts_module == "tree_sitter_php": # PHP: distinguish call expression subtypes if node.type == "function_call_expression": + php_function_call = True func_node = node.child_by_field_name("function") if func_node: callee_name = _read_text(func_node, source) @@ -8849,28 +9459,76 @@ def walk_calls( if scope_node: member_receiver = _read_text(scope_node, source) else: - # member_call_expression: $obj->method() + # member_call_expression / nullsafe_member_call_expression: + # $obj->method() / $obj?->method() is_member_call = True name_node = node.child_by_field_name("name") if name_node: callee_name = _read_text(name_node, source) + # #1682: capture the receiver so the cross-file PHP pass can + # bind the call to the receiver's DECLARED type. Gated on the + # node type because class_constant_access_expression lands in + # this else-branch too and has no `object`/`name` fields. + if node.type in ("member_call_expression", + "nullsafe_member_call_expression"): + obj = node.child_by_field_name("object") + if obj is not None and obj.type == "variable_name": + # $this->m() -> "this"; $svc->m() -> "svc", typed by + # the method-scoped table. `$this` is a reserved name + # in PHP, so the two key spaces cannot collide. + member_receiver = _read_text(obj, source).lstrip("$") + elif obj is not None and obj.type == "member_access_expression": + # $this->prop->m(): object=variable_name($this), + # name=name(prop). Deeper chains stay uncaptured. + inner = obj.child_by_field_name("object") + prop = obj.child_by_field_name("name") + if (inner is not None and inner.type == "variable_name" + and _read_text(inner, source) == "$this" + and prop is not None): + member_receiver = f"this.{_read_text(prop, source)}" + elif obj is not None and obj.type == "parenthesized_expression": + # (new X())->m(): object_creation_expression is an + # UNFIELDED named child, and the class it names is + # an unfielded `name` (bare) or `qualified_name` + # (namespaced) child. An ANONYMOUS class parses as + # an `anonymous_class` child instead — it has no + # name node, so the scan finds nothing and the + # receiver stays uncaptured (verified by probe). + created = next((c for c in obj.named_children + if c.type == "object_creation_expression"), None) + cls = None + if created is not None: + cls = next((c for c in created.named_children + if c.type in ("name", "qualified_name")), None) + short = _php_name_text(cls, source) if cls is not None else None + # `new self()` / `new static()` / `new parent()` + # need inheritance context the raw-call facts do + # not carry — refused by the same non-concrete set. + if short and short.lower() not in _PHP_NON_CONCRETE_TYPE_NAMES: + member_receiver = "(new)" + php_inline_new_type = short + php_inline_new_qualified = _read_text(cls, source) + # First-class callable `$obj->method(...)` (#15). PHP 8.1 + # reuses member_call_expression for it, so the shared + # `node.type in config.call_types` gate cannot tell it + # from an invocation — the argument list can. Probe- + # verified on the pinned grammar (tree-sitter-php 0.24.1): + # `m(...)` parses as `arguments: (arguments + # (variadic_placeholder))` — exactly one named child of + # that type. `m()`, `m(1)` and the spread `m(...$args)` + # (one `argument` child) do not, so the discriminator is + # unambiguous. + args_node = node.child_by_field_name("arguments") + if args_node is not None: + named_args = args_node.named_children + php_fcc = (len(named_args) == 1 + and named_args[0].type == "variadic_placeholder") receiver = node.child_by_field_name("object") if (receiver is not None and _read_text(receiver, source) == "$this" and callee_name and callee_name.casefold() in _LANGUAGE_BUILTIN_GLOBALS): self_receiver = "this" - # `$this->greeter->greet()` types the receiver by the property name, - # `$greeter->greet()` by the variable; a longer chain names neither. - obj = node.child_by_field_name("object") - if obj is not None and obj.type == "variable_name": - member_receiver = _php_variable_text(obj, source) - elif obj is not None and obj.type == "member_access_expression": - inner = obj.child_by_field_name("object") - if inner is not None and inner.type == "variable_name" and ( - _php_variable_text(inner, source) == "this"): - member_receiver = _read_text( - obj.child_by_field_name("name"), source) elif config.ts_module == "tree_sitter_cpp": # C++: function field, then field_expression/qualified_identifier func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None @@ -9107,6 +9765,37 @@ def walk_calls( _java_defer = ( config.ts_module == "tree_sitter_java" and is_member_call ) + # PHP (#1682): defer when the receiver's type is actually known — + # a typed `$this->prop->m()` must not bare-match an unrelated + # same-named method in this file. Plain `$this->m()` and untyped + # receivers keep today's in-file match, since the resolver could + # add nothing for them anyway. + # + # ALSO defer when the annotation was PRESENT but named several + # candidate classes (`A|B`, `A&B`) — user story 11 (#9). Such a + # receiver stamps no type, so before this the refusal was + # indistinguishable from "untyped" and the in-file arm bound the + # call to whichever same-named method came last in the file, at + # EXTRACTED confidence. The receiver table encodes the difference: + # a PRESENT key mapped to None is a refused multi-class + # annotation, an ABSENT key is no annotation at all. + _php_receiver_type: str | None = None + # The annotation as WRITTEN, when it carried a namespace (#20). + # Stamped alongside the short name; nothing decides on it yet. + _php_receiver_qualified: str | None = None + _php_multi_typed_receiver = False + if config.ts_module == "tree_sitter_php": + if php_inline_new_type: + _php_receiver_type = php_inline_new_type + elif member_receiver and member_receiver != "this": + _php_types = receiver_types or {} + _php_declared = _php_types.get(member_receiver) + if _php_declared is not None: + _php_receiver_type = _php_declared.short + _php_receiver_qualified = _php_declared.qualified + else: + _php_multi_typed_receiver = member_receiver in _php_types + _php_defer = bool(_php_receiver_type) or _php_multi_typed_receiver # Lua: a colon call that was NOT rewritten to a table-qualified name # above (`obj:m()` on an unknown receiver) must not fall back to the # bare-name map, or `m` would bind to any unrelated top-level @@ -9116,9 +9805,14 @@ def walk_calls( and is_member_call and not lua_self_qualified ) - if _python_defer or _java_defer or _csharp_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( + if _python_defer or _java_defer or _php_defer or _csharp_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( is_member_call and member_receiver + # PHP's defer decision is fully expressed by _php_defer. Its + # receivers are variables, `this.` keys and `(new)` — + # never a bare class name — so the capitalized rule below + # would only strip in-file edges off an untypable `$Svc->m()`. + and config.ts_module != "tree_sitter_php" and ( member_receiver[:1].isupper() or is_this_field_call @@ -9193,9 +9887,38 @@ def walk_calls( external_stub_target = bool(tgt_nid and not nid_to_sf.get(tgt_nid)) if tgt_nid and not external_stub_target: pair = (caller_nid, tgt_nid) - if pair not in seen_call_pairs: + if php_fcc: + # `$this->m(...)` names an in-file method: same target, + # same EXTRACTED confidence, distinct relation (#15). A + # direct call to the target already recorded wins the + # pair — the precedence _emit_indirect_ref already uses. + if (pair not in seen_call_pairs + and pair not in seen_php_fcc_pairs): + seen_php_fcc_pairs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "indirect_call", + "context": "call", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + elif pair not in seen_call_pairs: seen_call_pairs.add(pair) line = node.start_point[0] + 1 + if pair in seen_php_fcc_pairs: + # An earlier first-class-callable reference claimed + # this pair; the real invocation supersedes it, so + # the outcome does not depend on source order. + seen_php_fcc_pairs.discard(pair) + edges[:] = [ + e for e in edges + if not (e.get("relation") == "indirect_call" + and e.get("source") == caller_nid + and e.get("target") == tgt_nid) + ] edges.append({ "source": caller_nid, "target": tgt_nid, @@ -9336,6 +10059,30 @@ def walk_calls( ) if chain: rc_entry["java_receiver_chain"] = chain + # PHP: tag the raw_call so _resolve_php_member_calls claims + # it (and so other languages' resolvers can skip it), and + # stamp the receiver type resolved above (#1682). + if config.ts_module == "tree_sitter_php": + rc_entry["lang"] = "php" + if php_function_call: + # Marker read by the shared cross-file pass, which + # drops METHOD candidates for this call site (#52). + rc_entry["php_function_call"] = True + if php_fcc: + # Marker read by _resolve_php_member_calls, which + # emits `indirect_call` for it under the SAME + # target-resolution and refusal rules (#15). + rc_entry["fcc"] = True + if _php_receiver_type: + rc_entry["receiver_type"] = _php_receiver_type + if _php_receiver_qualified: + # Written form of the ANNOTATION that typed the + # receiver (#20) — kept apart from the inline-`new` + # `receiver_qualified` below, which is the class + # named at the call itself. + rc_entry["receiver_type_qualified"] = _php_receiver_qualified + if php_inline_new_qualified: + rc_entry["receiver_qualified"] = php_inline_new_qualified # Kotlin fully-qualified call (#2550): the dotted prefix + # lang tag let _resolve_kotlin_qualified_calls claim it. if kotlin_qualified_prefix: @@ -9655,10 +10402,13 @@ def walk_calls( # (#1630 Pattern B). Guarding on the tracked set prevents double-walking. _tracked_body_ids.update(b for _, b in function_bodies) - # Body ids are unique (one language per file), so the Java (flat) and C# - # (scoped, #2472) per-method receiver tables merge without collision — the - # stamp site branches on language to read the matching shape. - receiver_types_by_body = {**java_receiver_types, **csharp_receiver_types} + # Body ids are unique (one language per file), so the Java (flat), C# + # (scoped, #2472) and PHP (flat, pair-valued) per-method receiver tables + # merge without collision — the stamp site branches on language to read + # the matching shape. + receiver_types_by_body = { + **java_receiver_types, **csharp_receiver_types, **php_receiver_types, + } for caller_nid, body_node in function_bodies: walk_calls( body_node, @@ -9836,6 +10586,51 @@ def _scan_js_module_dispatch(n) -> None: n["_callable_class"] = True if swift_extensions: result["swift_extensions"] = swift_extensions + if php_non_class_type_names: + # Which names this file declares as `interface`/`enum`/`trait` rather + # than `class` (#1682). Sorted for a stable AST-cache payload. The + # member-call resolver consumed this to refuse such receivers until #53 + # lifted the refusal; what is left is a persisted record of declaration + # KIND, which nothing else in a graph carries. + result["php_non_class_types"] = sorted(php_non_class_type_names) + # The per-file payload above only reaches the resolver for files + # dispatched THIS run, so on an incremental rebuild an unchanged + # declaring file used to drop out of the refusal entirely and the + # receiver bound to a stranger class sharing the short name (#11). Also + # stamp the names on the FILE node — the marker rides the node dict into + # graph.json and back in as resolution context, the same channel + # `_callable` uses (#2438). The file node is the host because the marker + # describes the file's declarations as a set; the names are listed + # explicitly rather than read off the node's `.php` label, which + # would only hold under one-declaration-per-file PSR-4 convention. + # `_php_interfaces` is the pre-#12 spelling, carrying interfaces alone; + # readers still accept it, so a graph.json written before enums and + # traits joined the set still hands back what it does name. + for n in nodes: + if n["id"] == file_nid: + n["_php_non_class_types"] = list(result["php_non_class_types"]) + break + if php_class_fqns: + # The `namespace` this file declares for each type it defines — classes + # plus, since #53, interfaces/enums/traits — so the inline-`new` + # corroboration and the `use`-claim guard can compare a written FQN + # against the real one instead of guessing from the path (#14). Same + # `{"path": …}` shape as the type tables, which the cache re-anchors on + # load. + result["php_class_fqns"] = {"path": str_path, "classes": php_class_fqns} + # Like `_php_non_class_types` above, the payload only reaches the + # resolver for files dispatched THIS run — but the declared-FQN + # binding (#22) needs the names of the files an incremental rebuild + # left alone, or every edge it adds silently vanishes on the first + # `graphify update` (#23). Stamp the map on the FILE node too; it + # rides graph.json into the resolution context over the same channel + # as the other persisted markers. The path key is deliberately NOT + # stored: at read-back time it is the node's own `source_file`, in + # whatever form that graph carries. + for n in nodes: + if n["id"] == file_nid: + n["_php_class_fqns"] = dict(php_class_fqns) + break # TS/JS: augment the constructor-injection type table with local `new` # bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and # a call on a typed param (incl. inside a closure) resolve (#1630). The @@ -9849,8 +10644,6 @@ def _scan_js_module_dispatch(n) -> None: _ts_receiver_type_table(root, source, type_table, ts_field_types) _ts_mark_public_twins(root, source, result) _ts_attach_class_fields(result, ts_field_types, str_path) - if config.ts_module == "tree_sitter_php": - _php_receiver_type_table(root, source, type_table) if config.ts_module == "tree_sitter_swift": if type_table or swift_factory_bindings: result["swift_type_table"] = {"path": str_path, "table": type_table} @@ -9864,8 +10657,6 @@ def _scan_js_module_dispatch(n) -> None: result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} - elif config.ts_module == "tree_sitter_php": - result["php_type_table"] = {"path": str_path, "table": type_table} return result def _ts_mark_public_twins(root, source: bytes, result: dict) -> None: diff --git a/graphify/extractors/php.py b/graphify/extractors/php.py new file mode 100644 index 000000000..74281ea52 --- /dev/null +++ b/graphify/extractors/php.py @@ -0,0 +1,321 @@ +"""PHP cross-file resolution. + +The config-driven PHP *extractor* (``extract_php`` → ``_extract_generic``) still +lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the +config-driven languages cannot be ported one-by-one until the shared +``_extract_generic`` core moves as its own coordinated batch. This module is the +PHP home for the parts that *are* cleanly separable — today, the name-resolution +side of the member-call pass: matching a written class name against a definition +node, and the ``use``-import-aware receiver typing built on top of it. +""" +from __future__ import annotations + +from graphify.extractors.resolution import _php_fqn_from_raw + +# Mirrors `_PHP_RESOLVER_SUFFIXES` in graphify/extract.py. Kept local, like +# `_is_cs_file` in csharp.py, so this module imports nothing from the facade. +_PHP_SOURCE_SUFFIXES = ( + ".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps", +) + + +def _is_php_file(value: object) -> bool: + return isinstance(value, str) and value.lower().endswith(_PHP_SOURCE_SUFFIXES) + + +def _metadata(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _php_key(name: object) -> str: + """Fold a PHP class name: the language matches them case-insensitively.""" + return str(name).strip().casefold() + + +def _php_qualified_corroborates( + qualified: str | None, + type_node: dict | None, + declared_fqn: str | None = None, +) -> bool: + """True when a source-written class name corroborates the resolved node (#1682). + + ``(new \\App\\Services\\Svc())`` names the class outright, but the short-name + lookup that found the node ignored the namespace — so the namespace is + independent evidence, and only a match makes the edge EXTRACTED. + + ``declared_fqn`` is the name the DEFINING FILE declares for that class, + read from its ``namespace`` statement at extraction time (#14). When it is + known the comparison is whole-name: `\\App\\Services\\Client` corroborates + `App\\Services\\Client` and nothing else. Two things that used to promote + now do not — a file whose declared namespace disagrees with its PSR-4 path + (the written name then denotes a class that exists nowhere in the corpus), + and a truncated qualifier like `\\Services\\Client`, which is a DIFFERENT + class from `App\\Services\\Client` but matched as a path tail. + + Without a declaration — the file declares no namespace at all, or the node + came from a prior graph on an incremental run — the node's path is the only + corroborating fact left, and PSR-4 maps ``App\\Services\\Svc`` onto + ``app/Services/Svc.php``; every written segment must line up with the tail + of that path, case-insensitively. + + A BARE name (no namespace segment) corroborates nothing and stays INFERRED; + a namespace that does not line up downgrades rather than refusing, since the + class name itself still resolved unambiguously. + """ + if not qualified or not type_node: + return False + want = [seg.casefold() for seg in str(qualified).split("\\") if seg] + if len(want) < 2: + return False # bare `new Svc()`: no namespace written, no evidence + if declared_fqn: + have = [seg.casefold() for seg in str(declared_fqn).split("\\") if seg] + return want == have # whole name, not a suffix of one + source_file = str(type_node.get("source_file") or "") + parts = [p for p in source_file.replace("\\", "/").split("/") + if p and p not in (".", "..")] + if not parts: + return False + parts[-1] = parts[-1].rsplit(".", 1)[0] # drop the file extension + parts = [p.casefold() for p in parts] + return len(parts) >= len(want) and parts[-len(want):] == want + + +def _php_fqn_names_another_class( + fqn: str, + type_node: dict | None, + declared_fqn: str | None, +) -> bool: + """True when ``fqn`` provably names something OTHER than ``type_node`` (#21). + + The contrapositive of ``_php_qualified_corroborates``, with one deliberate + difference: *absent* evidence is not a contradiction. Otherwise the same + comparison the inline-`new` promotion already makes — the declared name when + the defining file was dispatched this run, its PSR-4 path when it was not. + + Two shapes carry no evidence either way and so keep the edge: + * a name with no namespace segment (`use Client;` imports from the global + namespace and writes nothing to compare); + * a path with fewer segments than the written name, once the declaration + is unavailable — composer maps a namespace PREFIX onto a directory + (`App\\Domain\\` -> `src/`), and a stripped prefix is indistinguishable + from a different class. Refusing there would delete true edges on + incremental rebuilds only, where the declaration is what is missing; + persisting it for unchanged files is #23. + + That second branch is why every bindable declaration KIND has to reach the + declared-FQN map: an `interface`/`enum`/`trait` left out of it was judged by + path alone, and a replayed context node's relativized path has fewer + segments than a 4-segment vendor FQN — so `use Illuminate\\Contracts\\…\\ + Notifier;` read as "no evidence" and kept an edge onto the in-corpus + `App\\Contracts\\Notifier` it provably does not name (#53). + """ + if type_node is None: + return False + want = [seg for seg in str(fqn).split("\\") if seg] + if len(want) < 2: + return False + if not declared_fqn: + parts = [ + part + for part in str(type_node.get("source_file") or "").replace("\\", "/").split("/") + if part and part not in (".", "..") + ] + if len(parts) < len(want): + return False + return not _php_qualified_corroborates(fqn, type_node, declared_fqn) + + +class PhpNameResolver: + """``use``-import/namespace-aware PHP receiver-type resolution (#21). + + The PHP twin of ``CsharpNameResolver`` (``extractors/csharp.py``), built for + the same reason: ``_resolve_php_member_calls`` bound a receiver's short type + name through a corpus-wide index whose only refusal rule was "more than one + candidate", so a file that writes ``use Vendor\\Sdk\\Client;`` — CLAIMING the + name ``Client`` for a class outside the corpus — still bound the lone + unrelated ``App\\Local\\Client`` and minted a wrong ``INFERRED 0.8`` edge + (#16). Consulted in front of that fallback, this resolver makes the claim + decisive: it refuses instead of guessing. + + Built from graph-stamped facts only — the ``imports`` edges' ``use`` + metadata (#19), the declared-FQN payload of the files dispatched this run + (#14), and the same type-definition index the fallback uses. Nothing is + re-parsed, and no new persisted marker is needed: the ``use`` map belongs to + the CALLING file, which an incremental rebuild always re-dispatches. + + The refusal (#21) is strictly subtractive; the declared-FQN index layered on + top (#22) is its additive counterpart. When the claimed FQN matches the name + some in-corpus type's own file DECLARES (#14), that match outranks the + short-name census: it picks the imported one of several same-short-named + types, and follows a renaming alias (``use App\\X as Y;``) to a type the + written short name never would have found. The index only knows types whose + declared FQNs are available — for a file left undispatched by an incremental + rebuild that is the persisted ``_php_class_fqns`` marker (#23); a graph + written before that marker simply yields no match and the verdict falls back + to the #21 rules, adding no edge rather than a wrong one. ``interface``, + ``enum`` and ``trait`` declarations are in the index since #53 made them + bindable receiver types; without their FQNs the #21 rules judged them by + PSR-4 path alone, which is weaker and, on replayed context nodes, wrong. + """ + + def __init__( + self, + all_nodes: list[dict], + all_edges: list[dict], + type_def_nids: dict[str, list[str]], + class_fqn_by_file: dict[str, dict[str, str]] | None = None, + ) -> None: + self.type_def_nids = type_def_nids + self.class_fqn_by_file = class_fqn_by_file or {} + self.node_by_id: dict[str, dict] = { + node["id"]: node + for node in all_nodes + if isinstance(node.get("id"), str) and node.get("id") + } + + # Per file: claimed short name -> imported FQN. Read off the edge + # METADATA, never the target node's label — `_resolve_php_type_references` + # re-points import targets (onto an FQN stub, or onto the in-corpus class + # the unique-label rewire finds), and only the metadata still spells what + # the file actually wrote (#19). + self.uses_by_file: dict[str, dict[str, str]] = {} + for edge in all_edges: + if edge.get("relation") != "imports": + continue + source_file = edge.get("source_file") + if not _is_php_file(source_file): + continue + metadata = _metadata(edge.get("metadata")) + # `use function` / `use const` import no class name, in either + # spelling — the shared parser reports the declaration-level keyword + # of the group form too (#26). + if metadata.get("use_kind") != "class": + continue + target_fqn = metadata.get("target_fqn") + if not isinstance(target_fqn, str) or not target_fqn: + continue + alias = metadata.get("alias") + claimed = _php_key( + alias if isinstance(alias, str) and alias + else target_fqn.rsplit("\\", 1)[-1] + ) + if claimed: + # Two `use`s claiming one name is a PHP fatal error; first wins. + self.uses_by_file.setdefault(source_file, {}).setdefault( + claimed, target_fqn + ) + + # Declared FQN -> definition node, over the same node universe as the + # short-name index — every binding this enables is one the fallback + # could have made if only it could tell the namesakes apart (#22). An + # FQN declared by two nodes (copied fixtures, vendored duplicates) is + # poisoned rather than answered with either. + self.fqn_def_nid: dict[str, str | None] = {} + for nids in type_def_nids.values(): + for nid in nids: + node = self.node_by_id.get(nid) + declared = self._declared_fqn(node) + if not declared: + continue + fqn_key = _php_key(declared) + if self.fqn_def_nid.setdefault(fqn_key, nid) != nid: + self.fqn_def_nid[fqn_key] = None + + # Per file: the namespace its declarations sit in, needed to resolve a + # namespace-RELATIVE annotation (`Local\Client`). Derived from the + # declared-FQN payload, which covers every file dispatched this run — + # and the file that writes the annotation always is one. A file that + # declares two namespaces (a PSR-1 violation) is left out rather than + # answered with one of them. + self.namespace_by_file: dict[str, str] = {} + for path, classes in self.class_fqn_by_file.items(): + namespaces = { + fqn.rsplit("\\", 1)[0] if "\\" in fqn else "" + for fqn in classes.values() + } + if len(namespaces) == 1: + self.namespace_by_file[path] = next(iter(namespaces)) + + def _declared_fqn(self, type_node: dict | None) -> str | None: + """The name the DEFINING file declares for ``type_node``'s class (#14). + + Absent for a global-namespace class, and for a class whose file was not + dispatched this run — an incremental rebuild then falls back to the + PSR-4 path comparison, which is why the refusal needs no new marker. + """ + if not type_node: + return None + by_name = self.class_fqn_by_file.get(str(type_node.get("source_file") or "")) + if not by_name: + return None + return by_name.get(_php_key(type_node.get("label", ""))) + + def _written_fqn(self, written: str, source_file: str) -> str | None: + """The FQN a QUALIFIED written annotation denotes, or None if unknowable. + + PHP resolves `\\A\\B` absolutely, `A\\B` through the `use` map's + group-prefix semantics and otherwise relative to the current namespace — + never against the global namespace. A leading backslash is therefore not + something to assume nor to strip blindly (#20). + """ + raw = written.strip() + if raw.startswith("\\"): + return raw.lstrip("\\") + uses = self.uses_by_file.get(source_file, {}) + if _php_key(raw.split("\\", 1)[0]) in uses: + return _php_fqn_from_raw(raw, "", uses) + namespace = self.namespace_by_file.get(source_file) + if namespace is not None: + return _php_fqn_from_raw(raw, namespace, uses) + if source_file in self.class_fqn_by_file: + return None # two namespaces in one file: refuse to pick one + return _php_fqn_from_raw(raw, "", uses) # the file is global-namespace + + def resolve_type_name( + self, type_name: str, qualified: object, source_file: str + ) -> tuple[str | None, bool]: + """Resolve a receiver's declared type to a definition node, with a verdict. + + Returns ``(node_id, decisive)``: + * ``(nid, True)`` — the file's own naming lands on that definition. + * ``(None, True)`` — the file CLAIMS the name (a `use` import, or a + qualified form written at the annotation) and the claim does not + land on an in-corpus class: refuse, and do NOT let the caller fall + back to the looser corpus-wide bare-name match. This is #16. + * ``(None, False)`` — nothing in the file claims the name; the + caller's existing fallback runs unchanged. + """ + short = _php_key(type_name) + if not short: + return None, False + written = qualified.strip() if isinstance(qualified, str) else "" + if "\\" in written: + fqn = self._written_fqn(written, source_file) + else: + fqn = self.uses_by_file.get(source_file, {}).get(short) + if not fqn: + return None, False + + # The claimed FQN against the names the defining files DECLARE (#22): + # a unique match is the strongest verdict there is — the calling file + # names the class in full and some file declares exactly that class — + # so it outranks the short-name census below, selecting the imported + # one of several namesakes and following a renaming alias to a class + # the written short name would never census. Absent a match (the + # defining file declares no namespace, or was not dispatched and left + # no #23 marker) nothing is asserted either way: fall through, where + # the #21 rules refuse or bind exactly as they did before this index. + declared_nid = self.fqn_def_nid.get(_php_key(fqn)) + if declared_nid is not None: + return declared_nid, True + + candidates = self.type_def_nids.get(short, []) + if len(candidates) != 1: + # Nothing in the corpus answers to the written name, or several + # things do — and no declared FQN above told them apart. There + # is no edge the fallback could keep either way: refuse. + return None, True + node = self.node_by_id.get(candidates[0]) + if _php_fqn_names_another_class(fqn, node, self._declared_fqn(node)): + return None, True + return candidates[0], True diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 36b21a816..72ab60e19 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -3891,6 +3891,15 @@ def _external_stub(fqn: str) -> str: _PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in") _PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"}) +# Every PHP declaration kind that mints a definition node (`_PHP_CONFIG.class_types`) +# and can therefore carry an extends/implements/`use`-trait clause. The raw-scan +# below used to read `class_declaration` only, so `interface Reader extends +# Sub\Repo` recorded no raw text and fell through to the same-namespace guess — +# which resolves to the wrong `Repo` when both exist (#47). +_PHP_DECLARATION_TYPES = frozenset({ + "class_declaration", "interface_declaration", "trait_declaration", "enum_declaration", +}) + def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: """Resolve a raw (possibly qualified) PHP class reference to an FQN. @@ -3917,6 +3926,102 @@ def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str: return f"{ns}\\{raw}" if ns else raw +# ── Shared PHP `use`-statement parser (#19) ─────────────────────────────────── +# One parser for both consumers: the `uses_by_file` map below and the `imports` +# edge capture in `_import_php` (extract.py). Group use `use A\{B, C as X};`, +# aliases, leading-backslash absolutes and `use function` / `use const` are all +# handled here so neither consumer has to re-derive them. + +def _php_use_clause_fact( + clause, + source: bytes, + prefix: str = "", + kind: str = "class", +) -> tuple[str, str | None, str] | None: + """Parse one ``namespace_use_clause`` into ``(target_fqn, alias, use_kind)``. + + ``prefix`` is the group-use prefix (empty for a standalone clause) and + ``kind`` the declaration-level ``function``/``const`` keyword, if any; a + clause-level keyword overrides it. Returns ``None`` when the clause names + no target (e.g. a parse error). + """ + target: str | None = None + alias: str | None = None + saw_as = False + for c in clause.children: + if c.type in ("function", "const"): + kind = c.type + elif c.type == "as": + saw_as = True + elif c.type in ("qualified_name", "name"): + if saw_as: + alias = _read_text(c, source) + elif target is None: + target = _read_text(c, source) + if not target: + return None + fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") + return fqn, alias, kind + + +def _php_use_clause_context(clause, source: bytes) -> tuple[str, str]: + """``(group prefix, use kind)`` a ``namespace_use_clause`` inherits from its + parent ``namespace_use_declaration``. + + For consumers that are dispatched per clause and never see the declaration + (`_import_php`). The prefix only applies to clauses inside a + ``namespace_use_group``; a standalone clause carries its own full name. + """ + parent = getattr(clause, "parent", None) + in_group = parent is not None and parent.type == "namespace_use_group" + decl = parent.parent if in_group else parent + prefix, kind = "", "class" + if decl is None or decl.type != "namespace_use_declaration": + return prefix, kind + for c in decl.children: + if c.type == "namespace_name" and in_group: + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + return prefix, kind + + +def _php_use_declaration_facts( + decl, + source: bytes, +) -> list[tuple[str, str | None, str]]: + """Every ``(target_fqn, alias, use_kind)`` a ``namespace_use_declaration`` declares. + + ``use function A\\f;`` puts the keyword on the *clause*, while + ``use function A\\{f, g};`` puts it on the *declaration* — both spellings + yield ``use_kind == "function"`` here (#26). + """ + prefix, kind, group = "", "class", None + direct = [] + for c in decl.children: + if c.type == "namespace_name": + prefix = _read_text(c, source) + elif c.type in ("function", "const"): + kind = c.type + elif c.type == "namespace_use_group": + group = c + elif c.type == "namespace_use_clause": + direct.append(c) + + facts: list[tuple[str, str | None, str]] = [] + for c in direct: + fact = _php_use_clause_fact(c, source, "", kind) + if fact: + facts.append(fact) + if group is not None: + for c in group.children: + if c.type == "namespace_use_clause": + fact = _php_use_clause_fact(c, source, prefix, kind) + if fact: + facts.append(fact) + return facts + + def _resolve_php_type_references( per_file: list[dict], paths: list[Path], @@ -3934,7 +4039,12 @@ def _resolve_php_type_references( proven external by a ``use`` FQN or a qualified name are re-pointed to an FQN-labeled sourceless stub, which the bare-label rewire cannot collapse. References with no namespace facts are left untouched so the unique-label - rewire keeps handling plain (non-namespaced) PHP as before. + rewire keeps handling plain (non-namespaced) PHP as before — except for + class ``imports``, which carry their own ``target_fqn`` (#19) and so are + re-pointed off that even in a file with no ``namespace`` declaration (#48): + the ``use`` FQN is itself the namespace fact, and the edge's bare target was + never a rewire candidate anyway (``_rewire_unique_stub_nodes`` builds its + candidate list from nodes, and a ``Foo::class``-only import mints none). """ try: import tree_sitter_php as tsphp @@ -3976,27 +4086,6 @@ def _record_raw(relation: str, raw: str) -> None: else: raws.setdefault(key, raw) - def _record_use_clause(clause, prefix: str) -> None: - target = None - alias = None - saw_as = False - for c in clause.children: - if c.type in ("function", "const"): - return # not a class import - if c.type == "as": - saw_as = True - elif c.type in ("qualified_name", "name"): - if saw_as: - alias = _read_text(c, source) - elif target is None: - target = _read_text(c, source) - if not target: - return - fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\") - key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() - if key: - uses.setdefault(key, fqn) - def walk(n) -> None: t = n.type if t == "namespace_definition": @@ -4005,21 +4094,14 @@ def walk(n) -> None: namespaces.append(_read_text(c, source)) break elif t == "namespace_use_declaration": - prefix = "" - group = None - for c in n.children: - if c.type == "namespace_name": - prefix = _read_text(c, source) # group-use prefix - elif c.type == "namespace_use_group": - group = c - elif c.type == "namespace_use_clause": - _record_use_clause(c, "") - if group is not None: - for c in group.children: - if c.type == "namespace_use_clause": - _record_use_clause(c, prefix) + for fqn, alias, use_kind in _php_use_declaration_facts(n, source): + if use_kind != "class": + continue # `use function` / `use const` are not class imports + key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower() + if key: + uses.setdefault(key, fqn) return - elif t == "class_declaration": + elif t in _PHP_DECLARATION_TYPES: for child in n.children: if child.type == "base_clause": for sub in child.children: @@ -4029,7 +4111,7 @@ def walk(n) -> None: for sub in child.children: if sub.type in ("name", "qualified_name"): _record_raw("implements", _read_text(sub, source)) - elif child.type == "declaration_list": + elif child.type in ("declaration_list", "enum_declaration_list"): for member in child.children: if member.type != "use_declaration": continue @@ -4102,41 +4184,66 @@ def _external_stub(fqn: str) -> str: if ref_file not in ns_by_file: continue tgt = edge.get("target") - label = stub_label.get(tgt) - uses = uses_by_file.get(ref_file, {}) - if not label and relation == "imports": - label = next((alias for alias in uses if _make_id(alias) == tgt), "") - if not label: - continue - bare = label.strip().lower() ns = ns_by_file[ref_file] + uses = uses_by_file.get(ref_file, {}) - raw = None - if relation in _PHP_SUPERTYPE_RELATIONS: - raw = raw_by_file.get(ref_file, {}).get((relation, bare)) - - explicit = False - if raw and "\\" in raw: - fqn = _php_fqn_from_raw(raw, ns, uses) - explicit = True - elif bare in uses: - fqn = uses[bare] - explicit = True - elif ns: - fqn = f"{ns}\\{label}" + # An `imports` edge already spells its own FQN in metadata (stamped by + # `_import_php` since #19), so it resolves without a stub node to read a + # label from — the `Foo::class`-only import mints no stub at all, and + # the bare target used to dangle all the way into graph.json (#48). + # Only `use`d *types* belong in the type map: `use function`/`use const` + # name callables, not classes. + import_fqn = "" + if relation == "imports": + meta = edge.get("metadata") or {} + if meta.get("use_kind", "class") == "class": + import_fqn = str(meta.get("target_fqn") or "").strip().lstrip("\\") + + if import_fqn: + fqn, explicit = import_fqn, True else: - continue # no namespace facts: legacy unique-label rewire applies + label = stub_label.get(tgt) + if not label and relation == "imports": + # Prefixed use-imports mint no stub whose id matches the edge + # target, so recover the label from the alias map (#2661). + label = next((alias for alias in uses if _make_id(alias) == tgt), "") + if not label: + continue + bare = label.strip().lower() + + raw = None + if relation in _PHP_SUPERTYPE_RELATIONS: + raw = raw_by_file.get(ref_file, {}).get((relation, bare)) + + explicit = False + if raw and "\\" in raw: + fqn = _php_fqn_from_raw(raw, ns, uses) + explicit = True + elif bare in uses: + fqn = uses[bare] + explicit = True + elif ns: + fqn = f"{ns}\\{label}" + else: + continue # no namespace facts: legacy unique-label rewire applies resolved = fqn_to_id.get(fqn.lower()) if resolved and resolved != tgt: edge["target"] = resolved - repointed_from.add(tgt) elif explicit and resolved is None: # Proven external: park the edge on an FQN-labeled stub the # bare-name rewire cannot collapse (this is the #1923 fix). edge["target"] = _external_stub(fqn) + else: + continue # non-explicit miss: leave the bare stub for the legacy rewire + + # Only a *stub* id is a candidate for the orphan prune below. Resolving + # an import off its own metadata means `tgt` may be a bare + # `_make_id()` that never had a node of ours behind it — and + # that id can collide with an unrelated real one (a `Page.md` doc node + # vs `use Vendor\Pkg\Page;`). Vacating this edge must never orphan it. + if tgt in stub_label: repointed_from.add(tgt) - # non-explicit miss: leave the bare stub for the legacy rewire if new_nodes: all_nodes.extend(new_nodes) diff --git a/graphify/serve.py b/graphify/serve.py index 4b5c0e81b..6ec0089f6 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -554,6 +554,21 @@ class _QueryScores(NamedTuple): term_order: tuple[str, ...] = () +def _is_sourceless(data: dict) -> bool: + """True when a node carries no `source_file` — extractor placeholder, not a + declaration. + + A sourceless node is a stub the extractor minted for a reference it could not + resolve (an unresolved base type, a dangling import target); serve also + materializes attributeless nodes for dangling edge endpoints, which have no + `source_file` key at all. Either way, when a real sourced declaration carries + the same label the stub is a broken duplicate of it and never the better + answer — the same presence test (never a count threshold) `_find_node_tiers` + applies to its exact tier (#49). + """ + return not str(data.get("source_file") or "") + + def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: """Combined query scorer returning the existing ranked `(score, node_id)` list. @@ -571,9 +586,11 @@ def _score_query( """Single-pass combined scorer that optionally also records the best seed for each normalized query token. - The combined ranking is byte-identical to what `_score_nodes` produced - before the refactor; `_score_nodes` is now a thin wrapper that asks for - `collect_per_term_seeds=False` and returns only `.ranked`. + The combined ranking is the one `_score_nodes` has always returned — the + single-pass refactor left it byte-identical, and the only deliberate change + since is #54's sourced-preference tie-break in the sort below, which reorders + exact score ties and nothing else. `_score_nodes` is now a thin wrapper that + asks for `collect_per_term_seeds=False` and returns only `.ranked`. When `collect_per_term_seeds=True`, the per-token singleton winner is computed alongside the combined score in the *same* per-node visit (it @@ -582,14 +599,17 @@ def _score_query( straight into `_pick_seeds` and skip the T additional whole-graph rescoring passes the old per-token `_score_nodes([token])` loop ran. - Singleton-winner semantics match the legacy per-token path exactly. The - score itself mirrors `_score_nodes([token])` with `n_terms == 1` (so the - coverage term is 1 and the per-token tier is unscaled) plus the broader - joined-singlet tier (which also checks `label_tokens` and `nid_lower`). - Tie-break order is (1) highest singleton score, (2) highest graph degree, - (3) shortest displayed label, (4) lexicographically smallest node id — - exactly what `max(tied, key=degree)` over a sort by `(-score, label_len, - nid)` produced in the legacy `_pick_seeds` per-token loop. The combined + Singleton-winner semantics match the legacy per-token path, with #54's + sourced preference layered on top of it. The score itself mirrors + `_score_nodes([token])` with `n_terms == 1` (so the coverage term is 1 and + the per-token tier is unscaled) plus the broader joined-singlet tier (which + also checks `label_tokens` and `nid_lower`). Tie-break order is (1) highest + singleton score, (2) a sourced node over a sourceless stub, (3) highest + graph degree, (4) shortest displayed label, (5) lexicographically smallest + node id. Everything but (2) is exactly what `max(tied, key=degree)` over a + sort by `(-score, label_len, nid)` produced in the legacy `_pick_seeds` + per-token loop; (2) is #54's, and sits where the combined sort below puts + it — directly under the score, above degree. The combined trigram candidate set (needles `norm_terms + [joined]`) is a superset of each per-token `[t]` candidate set, so iterating combined candidates discovers every non-zero singleton-score node for every term. @@ -730,8 +750,14 @@ def _score_query( # Tie-break key mirrors the legacy sort+max(degree): # (-singleton, -degree, label_len, nid) — the minimum # tuple wins, exactly matching max(tied, key=degree) - # over (label_len asc, nid asc)-sorted ties. - key = (-singleton, -G.degree(nid), len(data.get("label") or nid), nid) + # over (label_len asc, nid asc)-sorted ties — with the + # sourced preference (#54) inserted directly under the + # score, exactly where the combined sort below puts it, so + # a term's per-token winner cannot be a stub that a sourced + # rival ties with. Same carve-out: an all-sourceless field + # still yields a winner. + key = (-singleton, _is_sourceless(data), -G.degree(nid), + len(data.get("label") or nid), nid) cur = best_by_term.get(t) if cur is None or key < cur[0]: best_by_term[t] = (key, nid) @@ -739,9 +765,26 @@ def _score_query( score += tiered * (matched / n_terms) ** 2 if score > 0: scored.append((score, nid)) - # Sort by score desc; break ties toward the shorter label so a concise exact - # match beats a longer superset that happens to share the same score. - scored.sort(key=lambda s: (-s[0], len(G.nodes[s[1]].get("label") or s[1]), s[1])) + # Sort by score desc; among equal scores prefer a sourced declaration over a + # sourceless stub (#54), then break remaining ties toward the shorter label so + # a concise exact match beats a longer superset that happens to share the same + # score. + # + # The sourced preference sits directly under the score because a stub can + # never *out*-score an otherwise-identical sourced node — the source-file tier + # above only ever adds — so a shadowing stub always arrives here as an exact + # tie, and both remaining keys (label length, node id) are arbitrary with + # respect to which node is real: on the #54 repro two `FooRepository` nodes + # score 5619.08 apiece and the id sort answered with the stub, while + # `_find_node`/`resolve_seed` answered with the declaration. This is #49's rule + # applied to the tie the scored path actually produces; ordering it above the + # label-length key mirrors `_find_node_tiers`, which drops stubs from the tier + # outright rather than ranking them within it. Ties with no sourced candidate + # at all are untouched, so a lone stub still wins its query (#49's carve-out). + scored.sort(key=lambda s: ( + -s[0], _is_sourceless(G.nodes[s[1]]), + len(G.nodes[s[1]].get("label") or s[1]), s[1], + )) best_seed_by_term: dict[str, str] = {} term_order: tuple[str, ...] = () if collect_per_term_seeds and best_by_term: @@ -784,6 +827,7 @@ def _pick_seeds( *, G: "nx.Graph | None" = None, best_seed_by_term: dict[str, str] | None = None, + skip_covered_terms: bool = False, term_order: "tuple[str, ...] | list[str] | None" = None, ) -> list[str]: """Select BFS seed nodes, stopping when score drops too far below the top. @@ -815,6 +859,28 @@ def _pick_seeds( nodes back inside the gap window; this per-term guarantee remains load-bearing for relevant nodes matched only via substrings, whose flat scores a dampened collision can still exceed. + + `skip_covered_terms` (default off, opted into only by `_query_graph_text`) + refines that guarantee to *every term with any match is matched by the + top-ranked seed or by a seed of its own*, from *every term's own singleton + winner gets a slot*: a term that is a substring of the top-ranked seed's + normalized label — the same predicate as _score_nodes' weakest match tier, + and its label alone, so that seed provably would have matched the term in + scoring — is not starved, and starvation is the only thing the guarantee + exists to prevent. A seed with no label covers nothing. Without this skip, a + natural-language question's generic nouns each drag in their own hub: "what + code uses ChargeCustomerService to charge a customer" seeds the `Customer` + model and `.charge()` on top of `ChargeCustomerService`, whose label already + matches both terms, and the hub's member fan-out then floods the traversal + (#37/C2). + + Only the TOP-ranked seed may make that claim, not any picked seed. Coverage + asserts "the query's dominant match already answers this term", which is a + statement about the query's subject; a lower-ranked seed that merely happens + to contain the term's letters (`ReportService` for "port") is asserting a + coincidence, and letting it skip the guarantee starves the term's real + winner — for a term whose winner sits outside the gap window, the guarantee + is the only way in (Graphify-Labs#2516 review). """ if not scored: return [] @@ -833,6 +899,23 @@ def _seed_label_key(nid: str) -> str: return (data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()) or nid + # The `skip_covered_terms` coverage predicate needs the seed's normalized + # LABEL alone — empty when the node has none — not `_seed_label_key`. That + # key's `or nid` tail is correct for the dedup gate above (a labelless node + # still needs something unique to dedup on) but wrong for coverage: a + # `norm_label == ""` node is real (build.py's `_fold_node_aliases`: an + # alias-only node enters the graph with no label) and is still seedable + # through _score_nodes' source-file tier, so the tail would let its node-id + # path fragments declare unrelated terms covered — a match _score_nodes never + # makes, since its substring tier reads `norm_label` only. That would starve + # the term's real winner and break the very invariant this flag refines. + def _seed_norm_label(nid: str) -> str: + if G is None: + return "" + data = G.nodes[nid] + return (data.get("norm_label") + or _strip_diacritics(data.get("label") or "").lower()) + top_score = scored[0][0] seeds: list[str] = [] seen_labels: set[str] = set() @@ -869,35 +952,23 @@ def _seed_label_key(nid: str) -> str: # Honor the same per-label cap so the per-term guarantee can't # reintroduce a second copy of an already-seeded generic label. key = _seed_label_key(best_nid) - if best_nid not in seeds and key not in seen_labels: - seen_labels.add(key) - seeds.append(best_nid) + if best_nid in seeds or key in seen_labels: + continue + # Layered after that dedup gate, but only the TOP-ranked seed — the + # query's dominant match, always `scored[0]` and so always already + # present here — can declare a term covered. Letting any picked seed + # make that claim lets a coincidental substring collision inside an + # unrelated, lower-ranked seed's label silently starve a distinct + # term's real winner: `ReportService` contains "port", which would + # cost a corpus symbol literally named `port` the only seat it can + # get (Graphify-Labs#2516 review; the #1597 concern one layer down). + if skip_covered_terms and seeds and term in _seed_norm_label(seeds[0]): + continue + seen_labels.add(key) + seeds.append(best_nid) return seeds -# Verb-shaped tokens that express the RELATION a query asks about ("who calls -# X", "what uses Y") rather than a symbol to look up. `_query_terms` keeps them -# on purpose (a corpus can legitimately define an identifier named `calls`, see -# #1597), but they must not be handed a guaranteed seed slot in `_pick_seeds`: -# an incidental prefix match (e.g. "calls" prefixing `.callStoreWithAmount()`) -# would otherwise seat an unrelated decoy as a BFS root (#2507). Demotion -# happens at the `_query_graph_text` call site, so `_score_query`'s ranking — -# where such a verb can still win a seat on merit via the gap window — is -# untouched. Deliberately verbs only; relation NOUNS (module, field, return) -# stay eligible for the guarantee. -_RELATIONAL_INTENT_TERMS: frozenset[str] = frozenset({ - "call", "calls", "called", "caller", "callers", - "invoke", "invokes", "invoked", - "use", "uses", "used", "using", - "import", "imports", "imported", - "export", "exports", "exported", - "extend", "extends", "extended", - "implement", "implements", "implemented", - "depend", "depends", - "reference", "references", "referenced", -}) - - _CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("call", ("call", "calls", "called", "caller", "callers", "invoke", "invokes", "invoked")), ("import", ("import", "imports", "imported", "module", "modules")), @@ -946,6 +1017,77 @@ def _seed_label_key(nid: str) -> str: } +# Words that name the *relation* a question asks about ("who CALLS X", "CALLERS +# of X", "what USES X") rather than naming a symbol in the corpus. They lose the +# unconditional seat the #1445 per-term seed guarantee hands every query term — +# and nothing else. They are NOT stopworded: they still score, still compete in +# the gap window, so a corpus symbol literally named `calls`/`uses` keeps its +# exact-match dominance and stays seedable on merit (and `explain`/`path`/ +# `find_node` never take this code path at all). +# +# Why: a relational verb's junk matches lose everywhere on merit, but the +# guarantee hands them a seat anyway, so an incidental prefix decoy — a +# `callStoreWithAmount()` test helper prefix-matching "calls" — enters the +# traversal and spends the budget on an unrelated test neighborhood instead of +# the queried identifier's. See fork #37 (C1) / upstream #2507 direction 2. +# +# The vocabulary is derived from the two tables that already encode relational +# intent, so there is one auditable definition instead of a scattered word list: +# every `_CONTEXT_HINTS` word plus every `_CONTEXT_FILTER_ALIASES` key. The +# first extension group exists because "uses" appears in NEITHER table (it never +# triggers the traversal-filter heuristic) yet carries exactly the same intent — +# an intent-consumed-only rule misses two of the three measured phrasings. It +# still lists "caller"/"callers" for the same reason; upstream 0.9.35 has since +# added them to `_CONTEXT_HINTS`' `call` entry (adopted in the 0.9.37 sync), so +# the derivation now yields them too and the listing is belt-and-braces against +# that hint entry changing back. `_CONTEXT_HINTS` itself stays untouched on +# purpose beyond what upstream ships in it: extending it further would change +# which queries get context-filtered, a separate behavior change. +# +# The extension set also ABSORBS upstream 0.9.35's own verbs-only +# `_RELATIONAL_INTENT_TERMS` (their #2507 direction 2), which the 0.9.37 sync +# replaced with this derived definition: the second group below is exactly the +# twelve words upstream demoted that neither table yields here — `depend(s)`, +# `export`, `extend/extends/extended`, `implement/implements/implemented`, +# `reference/references/referenced`. (`exports`/`exported` and the `use*` family +# are already covered by `_CONTEXT_FILTER_ALIASES` and the first group.) So no +# demotion either fork ever shipped is lost, and the union stays a superset of +# both parents' vocabularies. +_RELATIONAL_INTENT_TERMS: frozenset[str] = frozenset( + {word for _ctx, hints in _CONTEXT_HINTS for word in hints} + | set(_CONTEXT_FILTER_ALIASES) + | {"caller", "callers", "use", "uses", "used", "using", "usage", + "listen", "listens", "listener", "listeners"} + | {"depend", "depends", "export", + "extend", "extends", "extended", + "implement", "implements", "implemented", + "reference", "references", "referenced"} +) + + +def _demote_relational_intent_terms(best_seed_by_term: dict[str, str]) -> dict[str, str]: + """Drop relational-intent terms from the per-term seed guarantee map. + + Applied by `_query_graph_text` between `_score_query` and `_pick_seeds`, + which is the whole point of the placement: `_score_query` stays + byte-identical (its legacy-equality property tests and the query-scoring + benchmark's equality gate hold untouched) and `_pick_seeds` keeps its + signature and semantics for every other caller, so the change is inert + outside the natural-language query pipeline. + + All-relational fallback: if demotion would empty a non-empty map — the + query is nothing but relational words, e.g. `graphify query "calls"` — keep + it unfiltered, mirroring `_query_terms`' all-stopword fallback. Such a query + has no identifier to seed from, so the guarantee is all it has. + """ + kept = { + term: nid + for term, nid in best_seed_by_term.items() + if term not in _RELATIONAL_INTENT_TERMS + } + return kept or best_seed_by_term + + def _normalize_context_filters(filters: list[str] | None) -> list[str]: if not filters: return [] @@ -1441,20 +1583,19 @@ def _query_graph_text( # time; on a 100k-node, three-term benchmark ~71% of scoring time was # spent in those redundant per-term passes. qs = _score_query(G, terms, collect_per_term_seeds=True) - # Relational-intent verbs ("calls", "uses", ...) describe the relation the - # question asks about, not a symbol to seed from; drop them from the - # per-term seed GUARANTEE so an incidental verb match cannot seat a decoy - # BFS root (#2507). They keep their place in `qs.ranked`, so a genuine - # identifier named after a verb can still win a seat on merit via the gap - # window — and when the query consists ONLY of intent words (bare "calls"), - # the guarantee is left intact so such an identifier stays reachable. - best_seed_by_term = qs.best_seed_by_term - intent = {t for t in best_seed_by_term if t in _RELATIONAL_INTENT_TERMS} - if intent and any(t not in _RELATIONAL_INTENT_TERMS for t in terms): - best_seed_by_term = { - t: nid for t, nid in best_seed_by_term.items() if t not in intent - } - start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term, term_order=qs.term_order) + # A relational word ("calls", "callers", "uses") describes the question, not + # a symbol to start from, so it forfeits its unconditional per-term seed + # here — after scoring, before picking — while still competing for a seed on + # merit through the gap window below (#37/C1). + best_seed_by_term = _demote_relational_intent_terms(qs.best_seed_by_term) + # `skip_covered_terms` is the other half of the seed hygiene: a term an + # already-picked seed's label matches is not starved, so it claims no extra + # seed and a generic noun stops dragging in its own hub (#37/C2). Opted into + # here only, so every other `_pick_seeds` caller keeps the legacy guarantee. + start_nodes = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=best_seed_by_term, skip_covered_terms=True, + term_order=qs.term_order, + ) if not start_nodes: return _QUERY_NO_MATCH return _render_query(G, question, start_nodes, mode=mode, depth=depth, token_budget=token_budget, @@ -1475,8 +1616,33 @@ def _render_query( unresolved: list[str] | None = None, ) -> str: resolved_filters, filter_source = _resolve_context_filters(question, context_filters) - traversal_graph = _filter_graph_by_context(_traversal_view(G), resolved_filters) - nodes, edges = _dfs(traversal_graph, start_nodes, depth) if mode == "dfs" else _bfs(traversal_graph, start_nodes, depth) + # Traverse undirected (upstream #29b7a21) so a seed with no outgoing edges + # still reaches its callers; the view carries `_src`/`_tgt` per edge so the + # renderer keeps caller->callee direction. Built once and shared by the + # filtered traversal and the relaxed retraversal below. + view = _traversal_view(G) + traversal_graph = _filter_graph_by_context(view, resolved_filters) + traverse = _dfs if mode == "dfs" else _bfs + nodes, edges = traverse(traversal_graph, start_nodes, depth) + # A guessed filter that reaches nothing is worse than no filter: "Who calls + # ChargeCustomerService?" infers a `call` filter, but a class node owns no + # call edges — calls land on its methods and the class->member edge carries + # `context=None` — so the filtered traversal cannot leave the seed and the + # answer comes back near-empty yet confident. Retraverse unfiltered instead, + # and say so in the header so a relaxed answer never reads as a filtered one + # (#37/C3). + # + # Both traversals visit every seed, so `nodes <= set(start_nodes)` means the + # traversal discovered nothing at all. That zero-expansion threshold is + # deliberate: "few nodes" would need a tuning constant, and a filter that + # found *something* is doing its job. Mode-independent by construction. + # + # Only the heuristic is second-guessed. An explicitly requested filter is an + # instruction, not a guess, and is honored even when it strands the seeds. + relaxed = filter_source == "heuristic" and nodes <= set(start_nodes) + if relaxed: + traversal_graph = view + nodes, edges = traverse(view, start_nodes, depth) header_parts = [ f"Traversal: {mode.upper()} depth={depth}", f"Start{' (given)' if given else ''}: {[G.nodes[n].get('label', n) for n in start_nodes]}", @@ -1495,7 +1661,8 @@ def _render_query( header_parts.insert(0, f"Graph: {_display_graph_path(graph_path)} " f"({G.number_of_nodes()} nodes)") if resolved_filters: - header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})") + note = "; relaxed — no matches beyond seeds" if relaxed else "" + header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source}{note})") header_parts.append(f"{len(nodes)} nodes found") header = " | ".join(header_parts) + "\n\n" # Pass the seeds so the queried symbol renders first and survives truncation @@ -1691,6 +1858,16 @@ def _find_node_tiers( elif term in norm_label or term in label_tokens or norm_query in norm_label: substring.append(nid) + # A sourceless node is a placeholder the extractor minted for a reference it + # could not resolve (an unresolved base type, a dangling import target). When + # a real, sourced declaration carries the same label, the stub is a broken + # duplicate of it — never the better answer, and never something the caller + # could disambiguate anyway, since it has no path to retry with. Drop the + # stubs so the exact tier holds only real declarations (#49, RC3 of #46). + sourced_exact = [nid for nid in exact if str(G.nodes[nid].get("source_file") or "")] + if sourced_exact: + exact = sourced_exact + if source_exact: query_basename = _strip_diacritics(Path(label).name).lower() preferred = [] @@ -1732,6 +1909,19 @@ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]: tier is split that way, else `[]`. Several matches *within one file* (a file node plus its members) are ordinary precedence, not ambiguity, and return `[]`. + Sourceless nodes are the exception to the per-file grouping, in *every* tier: + each one counts as its own rival. They all carry `source_file == ""`, so keying + them by source made N unrelated stub nodes look like N members of a single + file — the 18 stubs shadowing `BalanceitemRepository` reported no ambiguity at + all, and the caller answered with `matches[0]` (#49, RC3 of #46). + + Note the two halves of #49 have different reach. `_find_node_tiers` drops + stubs from a mixed tier only for the *exact* tier, so an exact tier arrives + here already reduced to real declarations and the per-stub keying changes + nothing for it. A winning prefix or substring tier is not reduced and can + still arrive mixed; there the keying is what stops its stubs from hiding + behind one another. + `_disambiguate_file_node_labels` (#2032) already relabels colliding *file* nodes; this covers the symbol case it does not reach. """ @@ -1742,11 +1932,15 @@ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]: def _rivals_across_files(G: nx.Graph, tier: list[str]) -> list[str]: - """One node id per distinct source file in *tier*, or `[]` for a single file.""" + """One node id per distinct source file in *tier*, or `[]` for a single file. + + Each sourceless node counts as its own rival (#49). + """ by_source: dict[str, str] = {} for nid in tier: source = str(G.nodes[nid].get("source_file") or "") - by_source.setdefault(source, nid) + # "\0" can't occur in a path, so a stub never joins a real file's group. + by_source.setdefault(source or "\0" + nid, nid) return list(by_source.values()) if len(by_source) > 1 else [] diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 1332d32b5..508ba73d5 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -429,6 +429,7 @@ def resolve_bash_source_edges( paths: Sequence[Path], root: Path, existing_edges: list[dict] | None = None, + file_nids: dict[Path, str] | None = None, ) -> list[dict]: """Resolve Bash source/import edges and source-backed function calls. @@ -451,7 +452,17 @@ def resolve_bash_source_edges( Anything else is silently skipped. """ path_by_index = [Path(p).resolve() for p in paths] - file_nid_by_path = {p: _file_node_id_for_path(p, root) for p in path_by_index} # resolved paths only + # `file_nids` carries the file node ids as they actually stand in the graph. + # This pass runs AFTER id-disambiguation, so when a same-stem sibling made a + # file id collide, the salted id no longer matches what + # `_file_node_id_for_path` derives from the path — every edge built from the + # formula would then name a node that does not exist (#33). Fall back to the + # formula for any path the caller did not resolve (and for direct callers + # that pass nothing), which is exactly the previous behavior. + known_nids = file_nids or {} + file_nid_by_path = { # resolved paths only + p: known_nids.get(p) or _file_node_id_for_path(p, root) for p in path_by_index + } functions_by_file: dict[str, dict[str, str]] = {} for result, path in zip(per_file, path_by_index): diff --git a/graphify/watch.py b/graphify/watch.py index afb5f7efc..6bcc0b126 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -2032,9 +2032,22 @@ def _add_deleted_source(path: Path) -> None: # Persisted resolver markers are never re-derived from a # label: callability protects indirect calls (#2438), and # Rust impl identity connects alpha-renamed generic blocks. + # `_php_non_class_types` (#11, #12) rides the same channel: + # it is the only record of which of an unchanged PHP file's + # declarations are interfaces, enums or traits rather than + # classes. It drove a receiver refusal until #53 lifted it; + # it is still carried, and `_php_interfaces` — that marker's + # pre-#12 spelling — with it, so a graph.json written before + # enums and traits joined the set still round-trips. + # `_php_class_fqns` (#23) is what resolution reads today: + # the declared FQNs that let a claimed `use` import bind + # into an unchanged defining file (#22) and let the guard + # refuse a vendor import that merely shares a short name. for marker in ( "_callable", "_callable_class", "_elixir_module", "_rust_impl_key", "_rust_declaration_count", + "_php_non_class_types", "_php_interfaces", + "_php_class_fqns", "_ts_public_twin", "_py_property", "_java_ret", "_ts_fields", "_ts_static_fields", ): diff --git a/pyproject.toml b/pyproject.toml index 092c5eb48..06bc8b93a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.91" +version = "0.9.92" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" diff --git a/tests/seeding_fixtures.py b/tests/seeding_fixtures.py new file mode 100644 index 000000000..420108477 --- /dev/null +++ b/tests/seeding_fixtures.py @@ -0,0 +1,202 @@ +"""Synthetic corpus fixture for the seeding-hygiene work (fork issue #37). + +Shared by the three slices of the consolidated spec — C1 relational-intent +demotion (#40), C2 covered-term guarantee skip (#41), C3 heuristic-filter +starvation fallback (#42) — so all of them grade the same three phrasings +against one corpus instead of re-inventing a graph each time. + +The shape is the sketch from the spec's Testing Decisions, distilled from the +measured 48.5k-node repro graph: + +* a **service class** (`ChargeCustomerService`) with one member method, wired + the way extraction really wires it: the class->method edge carries + ``context=None``, so a ``call`` context filter cannot leave the class node. +* **three known callers**, two through real ``call``-context edges and one + (`BalanceCustomerAccountService.settle()`) reachable only through a + ``references`` edge — the docblock-typed-receiver extraction gap filed as + #38, deliberately reproduced rather than papered over. +* a **camelCase verb-prefix decoy** (`callStoreWithAmount()`) whose normalized + label prefix-matches the query word "calls", sitting in a **busy test-method + neighborhood** — the junk that the per-term seed guarantee used to buy an + unconditional seat. Two of its neighbors carry the other relational words the + measured phrasings use ("callers", "uses"). +* a **generic-noun hub class** (`Customer`) with a wide member fan-out, one + reference hop away from the service — the explosion C2 targets. +* a **doc file** (`coder.md`) that prefix-matches the query word "code". + +The graph is undirected with per-edge ``_src``/``_tgt`` markers, exactly how +``graphify query`` loads a graph (cli.py: query keeps the graph undirected so +BFS reaches callers as well as callees, and preserves direction per edge for +rendering). Tests that drive ``_query_graph_text`` therefore see what the CLI +sees. +""" +from __future__ import annotations + +import ast + +import networkx as nx + +# --- node ids, exported so tests never hard-code string literals ------------- + +SERVICE = "app/Services/ChargeCustomerService.php::ChargeCustomerService" +SERVICE_METHOD = "app/Services/ChargeCustomerService.php::ChargeCustomerService.charge" +CALLERS = ( + "app/Http/Controllers/BillingController.php::BillingController.processPayment", + "app/Jobs/SubscriptionRenewalJob.php::SubscriptionRenewalJob.handle", + "app/Services/BalanceCustomerAccountService.php::BalanceCustomerAccountService.settle", +) +DECOY = "tests/Feature/StorePaymentTest.php::callStoreWithAmount" +CALLERS_DECOY = "tests/Feature/StorePaymentTest.php::StorePaymentTest.testCallersAreNotified" +USES_DECOY = "tests/Feature/StorePaymentTest.php::StorePaymentTest.testStoreUsesRetryPolicy" +HUB = "app/Models/Customer.php::Customer" +DOC = "docs/coder.md" +CALLS_SYMBOL = "app/Support/EventLog.php::EventLog.calls" + +# Labels are what the rendered output and the `Start:` header actually show, so +# assertions read these rather than node ids. +LABELS = { + SERVICE: "ChargeCustomerService", + SERVICE_METHOD: "ChargeCustomerService.charge()", + CALLERS[0]: "BillingController.processPayment()", + CALLERS[1]: "SubscriptionRenewalJob.handle()", + CALLERS[2]: "BalanceCustomerAccountService.settle()", + DECOY: "callStoreWithAmount()", + CALLERS_DECOY: "StorePaymentTest.testCallersAreNotified()", + USES_DECOY: "StorePaymentTest.testStoreUsesRetryPolicy()", + HUB: "Customer", + DOC: "coder.md", + CALLS_SYMBOL: "calls", +} + +_OTHER_TEST_METHODS = ( + "testRefundIsIssued", + "testTimeoutIsRetried", + "testAmountIsRounded", + "testIdempotencyKeyIsStable", + "testCurrencyIsValidated", + "testPartialFailureIsLogged", +) + +_HUB_MEMBERS = ( + "getName", "getEmail", "getPhone", "getAddress", "isActive", "markActive", + "subscriptions", "invoices", "notes", "toArray", "scopeActive", "fresh", +) + +_DOC_SECTIONS = ("Overview", "Setup", "Runbook") + + +def label_of(node_id: str) -> str: + """The rendered label for a fixture node id.""" + return LABELS[node_id] + + +def caller_labels() -> list[str]: + """Labels of the three ground-truth callers of the service.""" + return [LABELS[nid] for nid in CALLERS] + + +def _add(G: nx.Graph, nid: str, label: str, src: str, loc: str, community: int) -> None: + G.add_node(nid, label=label, source_file=src, source_location=loc, community=community) + + +def _link(G: nx.Graph, src: str, tgt: str, relation: str, context: str | None) -> None: + # `_src`/`_tgt` preserve the logical direction on an undirected graph, the + # same way `graphify query` loads one. + G.add_edge(src, tgt, relation=relation, context=context, + confidence="EXTRACTED", _src=src, _tgt=tgt) + + +def make_charge_fixture(*, calls_symbol: bool = False) -> nx.Graph: + """Build the shared seeding fixture. + + `calls_symbol` adds a corpus-legit production symbol literally labelled + `calls` (an `EventLog.calls` property). It is off by default so the + phrasing tests see the same corpus the measurements were taken on, and on + only for the test that pins "demotion is not stopwording": that symbol must + still win a seed on exact-match merit. + """ + G = nx.Graph() + + # --- the service and its three known callers ----------------------------- + _add(G, SERVICE, LABELS[SERVICE], "app/Services/ChargeCustomerService.php", "L12", 0) + _add(G, SERVICE_METHOD, LABELS[SERVICE_METHOD], + "app/Services/ChargeCustomerService.php", "L28", 0) + # Class -> member edges carry no context: this is why a heuristic `call` + # filter strands a class-node seed (the co-cause C3 addresses). + _link(G, SERVICE, SERVICE_METHOD, "method", None) + + _add(G, CALLERS[0], LABELS[CALLERS[0]], + "app/Http/Controllers/BillingController.php", "L40", 0) + _link(G, CALLERS[0], SERVICE_METHOD, "calls", "call") + + _add(G, CALLERS[1], LABELS[CALLERS[1]], + "app/Jobs/SubscriptionRenewalJob.php", "L22", 0) + _link(G, CALLERS[1], SERVICE_METHOD, "calls", "call") + + # Third caller: its receiver is a docblock-typed, constructor-assigned + # property, so extraction never emits the `calls` edge (#38). It stays + # reachable through the import/reference edge it does get. + _add(G, CALLERS[2], LABELS[CALLERS[2]], + "app/Services/BalanceCustomerAccountService.php", "L31", 0) + _link(G, CALLERS[2], SERVICE, "references", "import") + + # --- the verb-prefix decoy and its busy test neighborhood ---------------- + test_file = "tests/Feature/StorePaymentTest.php" + _add(G, DECOY, LABELS[DECOY], test_file, "L44", 1) + for nid, loc in ( + (CALLERS_DECOY, "L58"), + (USES_DECOY, "L71"), + ): + _add(G, nid, LABELS[nid], test_file, loc, 1) + _link(G, nid, DECOY, "calls", "call") + for i, name in enumerate(_OTHER_TEST_METHODS): + nid = f"{test_file}::StorePaymentTest.{name}" + _add(G, nid, f"StorePaymentTest.{name}()", test_file, f"L{80 + i * 12}", 1) + _link(G, nid, DECOY, "calls", "call") + + # --- the generic-noun hub, one reference hop from the service ------------ + _add(G, HUB, LABELS[HUB], "app/Models/Customer.php", "L9", 0) + _link(G, SERVICE_METHOD, HUB, "references", "parameter_type") + for i, name in enumerate(_HUB_MEMBERS): + nid = f"app/Models/Customer.php::Customer.{name}" + _add(G, nid, f"Customer.{name}()", "app/Models/Customer.php", f"L{20 + i * 9}", 0) + _link(G, HUB, nid, "method", None) + + # --- the doc file that prefix-matches "code" ----------------------------- + _add(G, DOC, LABELS[DOC], "docs/coder.md", "L1", 2) + for i, section in enumerate(_DOC_SECTIONS): + nid = f"docs/coder.md#{section}" + _add(G, nid, f"coder.md#{section}", "docs/coder.md", f"L{10 + i * 20}", 2) + _link(G, DOC, nid, "contains", None) + + # --- optional: a production symbol literally named `calls` --------------- + if calls_symbol: + owner = "app/Support/EventLog.php::EventLog" + _add(G, owner, "EventLog", "app/Support/EventLog.php", "L7", 0) + _add(G, CALLS_SYMBOL, LABELS[CALLS_SYMBOL], "app/Support/EventLog.php", "L15", 0) + _link(G, owner, CALLS_SYMBOL, "field", "field") + + return G + + +def start_labels(text: str) -> list[str]: + """Seed labels parsed out of a `_query_graph_text` header's `Start: [...]`. + + Seed assertions read this rather than the whole body, so "is it seeded?" + can never be confused with "did it get traversed into?", and exact list + membership keeps `ChargeCustomerService` from matching + `ChargeCustomerService.charge()`. + """ + for part in text.split("\n", 1)[0].split(" | "): + if part.startswith("Start:"): + return ast.literal_eval(part[len("Start:"):].strip()) + raise AssertionError(f"no Start: segment in header: {text.splitlines()[:1]}") + + +def shown_nodes(text: str) -> list[str]: + """Labels of the NODE lines that actually survived the token budget.""" + labels = [] + for line in text.splitlines(): + if line.startswith("NODE "): + labels.append(line[len("NODE "):].split(" [", 1)[0]) + return labels diff --git a/tests/test_import_alias_disambiguation.py b/tests/test_import_alias_disambiguation.py new file mode 100644 index 000000000..7c1260617 --- /dev/null +++ b/tests/test_import_alias_disambiguation.py @@ -0,0 +1,141 @@ +"""An import edge must survive a same-stem file in another language (#33). + +Several extractors name an import's target by the bare file-stem id of the +imported file (``import lead`` -> ``lead``). That works only while the id is +unique: add ANY same-stem file -- a ``lead.md`` will do -- and the two file +nodes collide, so ``_disambiguate_colliding_node_ids`` salts them apart into +``lead_ex_lead`` and ``lead_md_lead``. The import edge's target salt is keyed by +the IMPORTER's own source_file, which matches neither, so the edge is left +pointing at an id that no longer names anything and is silently dropped. + +The disambiguator already accepts a ``target_file`` hint for exactly this shape +(#1814), keying the target salt by that file instead. Every language below +stamps it now. + +Each case asserts BOTH directions, so a fixture that never produced an import +edge in the first place cannot pass by accident: the control corpus must +resolve, and the collision corpus must resolve to the SAME file. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from graphify.extract import extract + +_IMPORT_RELATIONS = ("imports", "imports_from", "re_exports") + +# A language-neutral collider: it shares the stem, mints a file node, and has +# nothing whatsoever to do with the import under test. +_COLLIDER = ("lead.md", "# Lead\n\nUnrelated notes.\n") + +# (case id, importer file, importer body, target file, target body) +_CASES = [ + ( + "powershell-dot-source", + "caller.ps1", ". ./lead.ps1\nfunction Run { Search }\n", + "lead.ps1", "function Search { return @() }\n", + ), + ( + "powershell-import-module", + "caller.ps1", "Import-Module ./lead.ps1\nfunction Run { Search }\n", + "lead.ps1", "function Search { return @() }\n", + ), + ( + "rust-use", + "caller.rs", "use crate::lead;\n\npub fn run() { lead::search(); }\n", + "lead.rs", "pub fn search() {}\n", + ), + ( + "pascal-uses", + "caller.pas", + "unit Caller;\ninterface\nuses Lead;\nimplementation\nend.\n", + "lead.pas", + "unit Lead;\ninterface\nprocedure Search;\n" + "implementation\nprocedure Search; begin end;\nend.\n", + ), + ( + "zig-at-import", + "caller.zig", + 'const lead = @import("lead.zig");\npub fn run() void { lead.search(); }\n', + "lead.zig", "pub fn search() void {}\n", + ), + ( + "elixir-import", + "caller.ex", + "defmodule Caller do\n import Lead\n def run, do: search()\nend\n", + "lead.ex", "defmodule Lead do\n def search, do: []\nend\n", + ), + ( + "bash-source", + "caller.sh", "source ./lead.sh\nrun() { search; }\n", + "lead.sh", "search() { echo hi; }\n", + ), +] + + +def _extract(tmp_path: Path, files: list[tuple[str, str]]) -> dict: + tmp_path.mkdir(parents=True, exist_ok=True) + paths = [] + for name, body in files: + path = tmp_path / name + path.write_text(body, encoding="utf-8") + paths.append(path) + return extract(paths, cache_root=tmp_path) + + +def _import_targets(result: dict) -> list[dict]: + """The node each import edge points at, or ``None`` where it dangles.""" + by_id = {node["id"]: node for node in result["nodes"]} + return [ + by_id.get(edge.get("target")) + for edge in result["edges"] + if edge.get("relation") in _IMPORT_RELATIONS + ] + + +@pytest.mark.parametrize( + ("importer", "importer_body", "target", "target_body"), + [case[1:] for case in _CASES], + ids=[case[0] for case in _CASES], +) +def test_import_edge_survives_a_same_stem_foreign_sibling( + tmp_path: Path, importer: str, importer_body: str, target: str, target_body: str, +): + corpus = [(importer, importer_body), (target, target_body)] + + control = _import_targets(_extract(tmp_path / "control", corpus)) + assert any( + node is not None and str(node.get("source_file", "")).endswith(target) + for node in control + ), f"fixture is inert: no import edge reached {target} even without a collider" + + collided = _import_targets(_extract(tmp_path / "collided", [*corpus, _COLLIDER])) + assert None not in collided, \ + "an import edge dangled on the pre-disambiguation stem id" + assert any( + str(node.get("source_file", "")).endswith(target) for node in collided + ), f"the import edge no longer reaches {target}" + assert not any( + str(node.get("source_file", "")).endswith(_COLLIDER[0]) for node in collided + ), "an import edge was repointed onto the unrelated collider" + + +def test_the_transient_target_file_hint_never_reaches_the_graph(tmp_path: Path): + """``target_file`` carries an absolute path and is popped by its only reader. + + Asserted across every case at once: a language that stamps the hint but is + somehow not reached by the disambiguator would ship the analysing machine's + filesystem layout inside `graph.json`. + """ + # Two cases share `caller.ps1`; dedupe by name (last wins). The point is + # breadth of emitters in one graph, not per-case isolation. + corpus = {_COLLIDER[0]: _COLLIDER[1]} + for _, importer, importer_body, target, target_body in _CASES: + corpus[importer] = importer_body + corpus[target] = target_body + result = _extract(tmp_path, list(corpus.items())) + + leaked = [edge for edge in result["edges"] if "target_file" in edge] + assert leaked == [], f"transient hint reached the graph: {leaked[:3]}" diff --git a/tests/test_mixed_corpus_member_calls.py b/tests/test_mixed_corpus_member_calls.py new file mode 100644 index 000000000..74c91e315 --- /dev/null +++ b/tests/test_mixed_corpus_member_calls.py @@ -0,0 +1,768 @@ +"""Mixed-corpus isolation for the member-call resolvers (#6, #8, #10, #24, spec #1682). + +A corpus that mixes languages must not let one language's raw call data mint an +edge through a different language's member-call resolver. Two independent +mechanisms have to hold for that: + +* **Raw-call ownership** -- a resolver consumes only raw calls from source files + it owns. The cpp/csharp/java/objc/php resolvers claim theirs by the + extractor-stamped ``lang``; Swift, Python and TypeScript raw calls carry no + tag, so those three filter by source-file suffix instead (#10). +* **Definition-index scoping** -- the receiver-type index a resolver builds + holds only types declared in its own sources (#8 for PHP/ObjC, #10 for + Java/C#, #24 for C++, Swift, TypeScript and Python). + +Every test goes through the public ``extract()`` seam, and the Python class +here doubles as the cross-language decoy: it owns an identically named method, +so a bare method-name match cannot tell it apart from the target. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str], cache_root: Path | None = None): + """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=cache_root or (tmp_path / "graphify-out")) + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _nid(result: dict, label: str, file_suffix: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label + and str(node.get("source_file", "")).endswith(file_suffix) + ) + + +def _call_context_pairs(result: dict) -> set[tuple[str, str]]: + """Every ``context: "call"`` edge as (source, target) pairs. + + Wider than ``_calls``: the Swift resolver falls back to a ``references`` + edge onto the receiver's TYPE when the type has no such method, so a leak + through it can surface as ``references`` rather than ``calls``. The + TypeScript resolver had the same fallback until upstream's #2553 dropped it + (it was its own fabrication vector); the wider check is kept for TS anyway, + since it can only catch more. + """ + return { + (edge["source"], edge["target"]) + for edge in result["edges"] + if edge.get("context") == "call" + } + + +def _cross_language_targets(result: dict, caller: str, file_suffix: str) -> set[str]: + """Call-context targets of ``caller`` that live in ``file_suffix``. + + Wider than naming one node: an index leak surfaces as ``calls`` onto the + foreign METHOD, or -- when the callee name misses on the foreign type -- as a + ``references`` edge onto the foreign TYPE itself. Both are the same bug. + """ + foreign = { + node["id"] + for node in result["nodes"] + if str(node.get("source_file") or "").endswith(file_suffix) + } + return { + target + for source, target in _call_context_pairs(result) + if source == caller and target in foreign + } + + +# A Python class whose method name collides with the other languages' callee. +# Nothing written in another language may ever bind to it. +_PY_DECOY = ( + "class Lead:\n" + " def search(self, filters):\n" + " return []\n" +) + + +def test_php_capitalized_variable_receiver_yields_no_python_edge(tmp_path: Path): + """A capitalized PHP *variable* receiver must not reach the Python resolver. + + `$Lead->search()` spells a receiver that, read as a Python receiver, would + hit the Python resolver's capitalized-receiver class arm and bind to the + Python `Lead.search`. The PHP raw call is tagged `lang: "php"`, so the + Python resolver skips it. + """ + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "app/Runner.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + py_search = _nid(result, ".search()", "svc.py") + # No edge from anywhere in the PHP file may land on the Python method. + php_sourced = [ + (src, tgt) for (src, tgt) in calls + if str(calls[(src, tgt)].get("source_file", "")).endswith(".php") + ] + assert not [pair for pair in php_sourced if pair[1] == py_search], ( + "a PHP raw call minted an edge into the Python decoy method" + ) + + +def test_python_member_calls_still_resolve_in_a_mixed_corpus(tmp_path: Path): + """Positive control: the tag skip must not disable the Python resolver. + + Without this, the test above would pass even if the skip discarded every + raw call. A genuine Python capitalized-receiver call still resolves, and a + decoy class with the same method name gets no edge. + """ + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "decoy.py": ( + "class Audit:\n" + " def search(self, filters):\n" + " return []\n" + ), + "caller.py": ( + "from svc import Lead\n" + "\n" + "def run():\n" + " Lead.search({})\n" + ), + "app/Runner.php": ( + "search([]); }\n" + "}\n" + ), + }) + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, ".search()", "svc.py") + decoy_search = _nid(result, ".search()", "decoy.py") + assert (run, py_search) in calls, "genuine Python member call stopped resolving" + assert (run, decoy_search) not in calls, "decoy class received an edge" + + +# ── Language-scoped receiver type index (#8) ───────────────────────────────── +# +# The `lang` tag above keeps one language's raw calls out of another +# language's resolver. It does NOT scope the DEFINITION index each resolver +# builds: `type_def_nids` was assembled from every type-like node in the +# corpus, so a receiver type name was matched against classes written in any +# language. That cut both ways — a foreign class could be bound as the +# receiver's type, and a foreign class sharing the name could trip the +# single-definition guard and suppress the correct same-language edge. + + +def test_php_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1: no PHP `class Lead` exists, only a Python one — refuse.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "app/Runner.php": ( + "lead->search([]); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.php") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a PHP receiver type must not resolve against a Python class" + + +def test_php_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2 (the damaging one): a cross-language name collision must not + make the god-node guard suppress the legitimate PHP-to-PHP edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "app/Lead.php": ( + "lead->search([]); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.php") + php_search = _nid(result, ".search()", "Lead.php") + py_search = _nid(result, ".search()", "svc.py") + assert (go, php_search) in calls, \ + "a same-named class in another language suppressed the real PHP edge" + assert (go, py_search) not in calls + assert calls[(go, php_search)]["confidence"] == "INFERRED" + + +def test_objc_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1, ObjC twin: `[Lead search]` with no ObjC `Lead` in the corpus. + + Asserted through ``_cross_language_targets`` rather than on one named node, + so a leak that lands as a ``references`` edge onto the Python CLASS (instead + of a ``calls`` edge onto its method) is caught too. + """ + _, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "src/Runner.m": ( + "@implementation Runner\n" + "- (void)go { [Lead search]; }\n" + "@end\n" + ), + }) + + go = _nid(result, "-go", "Runner.m") + assert not _cross_language_targets(result, go, "svc.py"), \ + "an ObjC receiver type must not resolve against a Python class" + + +def test_objc_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2, ObjC twin: the collision must not suppress the ObjC edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "src/Lead.h": "@interface Lead : NSObject\n- (void)search;\n@end\n", + "src/Lead.m": ( + '#import "Lead.h"\n@implementation Lead\n- (void)search {}\n@end\n' + ), + "src/Runner.m": ( + '#import "Lead.h"\n@implementation Runner\n' + "- (void)go { [Lead search]; }\n@end\n" + ), + }) + + go = _nid(result, "-go", "Runner.m") + py_search = _nid(result, ".search()", "svc.py") + objc_search = next( + node["id"] for node in result["nodes"] + if node.get("label") == "-search" + and str(node.get("source_file", "")).endswith((".h", ".m")) + ) + assert (go, objc_search) in calls, \ + "a same-named Python class suppressed the real ObjC edge" + assert (go, py_search) not in calls + + +# ── Untagged resolvers consume each other's raw calls (#10) ────────────────── +# +# The `lang` tag the tests above rely on is stamped only on cpp/csharp/java/ +# objc/php raw calls. Swift, Python and TypeScript raw calls carry none, so a +# `if rc.get("lang"): continue` guard in those three resolvers excluded the +# tagged languages and let the untagged ones flow into each other: a TypeScript +# receiver reached the Python resolver's capitalized-receiver class arm and +# minted a cross-language edge at EXTRACTED. The guard cannot close that by +# construction; only a positive source-file suffix filter can. +# +# Each test below is built so that exactly one resolver can be the miner: the +# resolver that legitimately owns the raw call refuses it on its own terms, so +# any surviving edge is another language's resolver reaching across. + +_TS_TYPED_RECEIVER = ( + "class Dep { go() { return 1; } }\n" + "export class Widget {\n" + " constructor(private dep: Dep) {}\n" + " run() { return this.dep.go(); }\n" + "}\n" +) +"""A TS constructor parameter property. Produces the `ts_type_table` the +TypeScript resolver requires -- without one it returns early and cannot leak.""" + +_SWIFT_TYPED_RECEIVER = ( + "class Dep { func go() {} }\n" + "class Widget {\n" + " let dep: Dep = Dep()\n" + " func run() { dep.go() }\n" + "}\n" +) +"""The Swift twin: produces the `swift_type_table` the Swift resolver requires.""" + +# Calls a method the Python class does not own, so the Python resolver itself +# refuses (its method index misses) -- any edge onto `Lead` is foreign. +_PY_CALLER_MISSING_METHOD = ( + "from svc import Lead\n" + "\n" + "\n" + "def run():\n" + " Lead.missing()\n" +) + + +def test_typescript_receiver_mints_no_edge_into_a_python_method(tmp_path: Path): + """The reported repro (#10): no TS `Lead` exists anywhere in the corpus. + + `Lead.search({})` is a TypeScript raw call. Read as a Python raw call it + hits the Python resolver's capitalized-receiver class arm and binds to the + Python `Lead.search` at EXTRACTED -- the strongest confidence label -- for + a call written in a file the Python resolver does not own. + """ + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "runner.ts": "class Runner {\n go() { return Lead.search({}); }\n}\n", + }) + + go = _nid(result, ".go()", "runner.ts") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a TypeScript raw call minted an edge into a Python method" + + +def test_python_raw_call_is_not_mined_by_the_typescript_resolver(tmp_path: Path): + """The reverse direction: a Python raw call reaching the TS resolver. + + `Lead.missing()` is refused by the Python resolver -- the Python `Lead` has + no `missing`. The TypeScript resolver, handed the same raw call, resolves + the receiver type and falls back to a `references` edge onto the class, + inventing a call-context edge out of Python source. + """ + _, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "caller.py": _PY_CALLER_MISSING_METHOD, + "widget.ts": _TS_TYPED_RECEIVER, + }) + pairs = _call_context_pairs(result) + + run = _nid(result, "run()", "caller.py") + py_lead = _nid(result, "Lead", "svc.py") + assert (run, py_lead) not in pairs, \ + "the TypeScript resolver mined a Python raw call" + # Positive control: the TS resolver still resolves its own typed receiver. + ts_run = _nid(result, ".run()", "widget.ts") + ts_go = _nid(result, ".go()", "widget.ts") + assert (ts_run, ts_go) in pairs, "the TypeScript resolver stopped resolving" + + +def test_python_raw_call_is_not_mined_by_the_swift_resolver(tmp_path: Path): + """Same shape, Swift twin: `Lead.missing()` is Python's raw call to refuse.""" + _, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "caller.py": _PY_CALLER_MISSING_METHOD, + "Widget.swift": _SWIFT_TYPED_RECEIVER, + }) + pairs = _call_context_pairs(result) + + run = _nid(result, "run()", "caller.py") + py_lead = _nid(result, "Lead", "svc.py") + assert (run, py_lead) not in pairs, \ + "the Swift resolver mined a Python raw call" + # Positive control: the Swift resolver still resolves its own typed receiver. + swift_run = _nid(result, ".run()", "Widget.swift") + swift_go = _nid(result, ".go()", "Widget.swift") + assert (swift_run, swift_go) in pairs, "the Swift resolver stopped resolving" + + +def test_swift_raw_call_is_not_mined_by_the_python_resolver(tmp_path: Path): + """A Swift raw call the Swift resolver refuses must not reach Python. + + `Bundle` is in `_LANGUAGE_BUILTIN_GLOBALS`, so the Swift resolver skips the + receiver outright (#2147) rather than binding a same-named user symbol. The + Python resolver has no such guard, so the Swift raw call flowed into its + class arm and bound to a Python `class Bundle` at EXTRACTED. + """ + calls, result = _calls(tmp_path, { + "svc.py": "class Bundle:\n def load(self):\n return []\n", + "Runner.swift": "class Runner {\n func go() { Bundle.load() }\n}\n", + "Widget.swift": _SWIFT_TYPED_RECEIVER, + }) + + go = _nid(result, ".go()", "Runner.swift") + py_load = _nid(result, ".load()", "svc.py") + assert (go, py_load) not in calls, \ + "a Swift raw call minted an edge into a Python method" + # Positive control, with a decoy: the Swift resolver still resolves its own + # typed receiver and does not fall back to a bare method-name match. + swift_run = _nid(result, ".run()", "Widget.swift") + swift_go = _nid(result, ".go()", "Widget.swift") + assert (swift_run, swift_go) in calls, "the Swift resolver stopped resolving" + assert (swift_run, go) not in calls, "the decoy Swift class received an edge" + + +# ── Language-scoped Java and C# receiver type indexes (#10) ────────────────── +# +# The definition-index half of the family #8 fixed for PHP/ObjC and recorded as +# knowingly deferred elsewhere. Java and C# still built `type_def_nids` from +# every type-like node in the corpus, so a Java `Lead lead; lead.search()` +# bound to a Python `class Lead` at INFERRED. + + +def test_java_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1, Java: no Java `class Lead` exists, only a Python one.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Runner.java": ( + "class Runner {\n" + " private Lead lead;\n" + " void go() { lead.search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.java") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a Java receiver type must not resolve against a Python class" + + +def test_java_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2, Java: the cross-language name collision pushed the + single-definition guard to 2 and suppressed the legitimate Java edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Lead.java": "class Lead { void search() {} }\n", + "Runner.java": ( + "class Runner {\n" + " private Lead lead;\n" + " void go() { lead.search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".go()", "Runner.java") + java_search = _nid(result, ".search()", "Lead.java") + py_search = _nid(result, ".search()", "svc.py") + assert (go, java_search) in calls, \ + "a same-named class in another language suppressed the real Java edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, java_search)]["confidence"] == "INFERRED" + + +def test_csharp_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """Defect 1, C#: no C# `class Lead` exists, only a Python one.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Runner.cs": ( + "public class Runner {\n" + " private Lead lead;\n" + " public void Go() { lead.search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".Go()", "Runner.cs") + py_search = _nid(result, ".search()", "svc.py") + assert (go, py_search) not in calls, \ + "a C# receiver type must not resolve against a Python class" + + +def test_csharp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """Defect 2, C#: the legitimate C#-to-C# edge survives the collision.""" + calls, result = _calls(tmp_path, { + "svc.py": "class Lead:\n def Search(self):\n return []\n", + "Lead.cs": "public class Lead { public void Search() {} }\n", + "Runner.cs": ( + "public class Runner {\n" + " private Lead lead;\n" + " public void Go() { lead.Search(); }\n" + "}\n" + ), + }) + + go = _nid(result, ".Go()", "Runner.cs") + cs_search = _nid(result, ".Search()", "Lead.cs") + py_search = _nid(result, ".Search()", "svc.py") + assert (go, cs_search) in calls, \ + "a same-named class in another language suppressed the real C# edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + + +# ── Language-scoped C++, Swift and TypeScript receiver type indexes (#24) ───── +# +# The remaining copies of the same shape. Each of these three resolvers built +# `type_def_nids` from every type-like node in the corpus, so the receiver's +# declared type name was matched against class definitions written in ANY +# language. Both directions of the defect are covered per language: the foreign +# class TYPING the receiver, and the foreign class merely SHARING the short name +# pushing the single-definition guard to 2 and deleting the correct edge. +# +# Raw-call ownership (above) cannot close this: the raw call being resolved is +# genuinely the resolver's own, and the leak is in what its INDEX offers up. +# +# ObjC is not repeated here -- #8 scoped its index, and the two ObjC cases above +# already guard both directions. + +_CPP_CALLER = ( + "class Runner {\n" + "public:\n" + " void go() { Lead lead; lead.search(); }\n" + "};\n" +) +"""`Lead lead;` is a local declaration, so the C++ `cpp_type_table` types the +receiver and the call resolves at INFERRED.""" + +_SWIFT_CALLER = ( + "class Runner {\n" + " let lead: Lead\n" + " func go() { lead.search() }\n" + "}\n" +) +"""Declared without an initializer on purpose: `= Lead()` would additionally be +picked up by the shared cross-file CALL pass, which is a different mechanism +and would muddy what this test pins down.""" + +_TS_CALLER = ( + "export class Runner {\n" + " constructor(private lead: Lead) {}\n" + " go() { return this.lead.search(); }\n" + "}\n" +) + +# The same caller, importing the type it names. Upstream's origin gate (#2553) +# resolves a TS member call only when the matched type is visible to the calling +# file -- same file, a named import, or a module the file imports -- so the +# positive-direction test needs the import that real TypeScript would require +# anyway. `_TS_CALLER` deliberately keeps no import, so the negative test below +# still pins the #24 definition-index scoping on its own evidence. +_TS_CALLER_IMPORTING = "import { Lead } from './lead';\n" + _TS_CALLER + + +def test_cpp_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No C++ `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "Runner.cpp": _CPP_CALLER}) + + go = _nid(result, ".go()", "Runner.cpp") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a C++ receiver type must not resolve against a Python class" + + +def test_cpp_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real C++ edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "lead.cpp": "class Lead {\npublic:\n void search() {}\n};\n", + "Runner.cpp": _CPP_CALLER, + }) + + go = _nid(result, ".go()", "Runner.cpp") + cpp_search = _nid(result, ".search()", "lead.cpp") + py_search = _nid(result, ".search()", "svc.py") + assert (go, cpp_search) in calls, \ + "a same-named Python class suppressed the real C++ edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, cpp_search)]["confidence"] == "INFERRED" + + +def test_swift_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No Swift `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "Runner.swift": _SWIFT_CALLER}) + + go = _nid(result, ".go()", "Runner.swift") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a Swift receiver type must not resolve against a Python class" + + +def test_swift_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real Swift edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "Lead.swift": "class Lead { func search() {} }\n", + "Runner.swift": _SWIFT_CALLER, + }) + + go = _nid(result, ".go()", "Runner.swift") + swift_search = _nid(result, ".search()", "Lead.swift") + py_search = _nid(result, ".search()", "svc.py") + assert (go, swift_search) in calls, \ + "a same-named Python class suppressed the real Swift edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + assert calls[(go, swift_search)]["confidence"] == "INFERRED" + + +def test_typescript_receiver_type_does_not_match_a_python_class(tmp_path: Path): + """No TypeScript `Lead` exists in the corpus, only a Python one.""" + _, result = _calls(tmp_path, {"svc.py": _PY_DECOY, "runner.ts": _TS_CALLER}) + + go = _nid(result, ".go()", "runner.ts") + assert not _cross_language_targets(result, go, "svc.py"), \ + "a TypeScript receiver type must not resolve against a Python class" + + +def test_typescript_receiver_resolves_despite_a_same_named_python_class(tmp_path: Path): + """The same-named Python class must not suppress the real TypeScript edge.""" + calls, result = _calls(tmp_path, { + "svc.py": _PY_DECOY, + "lead.ts": "export class Lead { search() { return []; } }\n", + "runner.ts": _TS_CALLER_IMPORTING, + }) + + go = _nid(result, ".go()", "runner.ts") + ts_search = _nid(result, ".search()", "lead.ts") + py_search = _nid(result, ".search()", "svc.py") + assert (go, ts_search) in calls, \ + "a same-named Python class suppressed the real TypeScript edge" + assert (go, py_search) not in calls, "the Python decoy received an edge" + # INFERRED, not EXTRACTED: the receiver is typed through the + # constructor-injection table rather than named at the call site, which + # upstream's #2553 tiers like the Swift/C#/Java resolvers (sibling Swift + # test above asserts the same). + assert calls[(go, ts_search)]["confidence"] == "INFERRED" + + +# ── Language-scoped Python class and module indexes (#24) ──────────────────── +# +# The Python resolver's two arms are indexed differently from every resolver +# above -- its class index is built by walking `method` edges rather than by +# filtering source-backed nodes, and its module arm resolves through the +# caller's own `imports` edges -- but both were corpus-wide all the same. +# +# Both arms are covered in both directions. The module arm's suppression case +# turned out not to be an index defect at all: two same-stem files disambiguate +# the FILE node ids (`lead_py_lead`, `lead_ts_lead`) while the `imports` edge +# target stays the bare alias `lead`, so the arm saw ZERO candidates rather +# than an ambiguous two. That is fixed one layer down, in the import-alias +# hinting (#33), and asserted at both layers below. + +_JAVA_DECOY = "class Lead { void search() {} }\n" + + +def test_python_class_receiver_does_not_match_a_java_class(tmp_path: Path): + """Class arm, defect 1: no Python `Lead` exists, only a Java one. + + `Lead.search()` is a Python raw call the Python resolver rightly owns. Its + class index held every class in the corpus, so the Java `Lead` answered for + the receiver and the call was minted at EXTRACTED -- the label reserved for + an explicitly named, unambiguous class reference. + """ + _, result = _calls(tmp_path, { + "caller.py": "def run():\n Lead.search()\n", + "Lead.java": _JAVA_DECOY, + }) + + run = _nid(result, "run()", "caller.py") + assert not _cross_language_targets(result, run, "Lead.java"), \ + "a Python class receiver must not resolve against a Java class" + + +def test_python_class_receiver_resolves_despite_a_same_named_java_class(tmp_path: Path): + """Class arm, defect 2: the Java decoy must not delete the Python edge.""" + calls, result = _calls(tmp_path, { + "caller.py": "from svc import Lead\n\n\ndef run():\n Lead.search()\n", + "svc.py": _PY_DECOY, + "Lead.java": _JAVA_DECOY, + }) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, ".search()", "svc.py") + java_search = _nid(result, ".search()", "Lead.java") + assert (run, py_search) in calls, \ + "a same-named Java class suppressed the real Python edge" + assert (run, java_search) not in calls, "the Java decoy received an edge" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +# The module arm resolves an `import` to the imported file's node, which only +# lines up when ids are relativized against the corpus root itself -- hence the +# explicit `cache_root`, matching the prior art in +# `tests/test_extract.py::test_python_module_qualified_call_resolves_extracted`. +_MOD_CALLER = "import lead\n\n\ndef run():\n lead.search()\n" +_TS_MODULE_DECOY = "export function search() { return []; }\n" + + +def test_python_module_receiver_does_not_match_a_typescript_file(tmp_path: Path): + """Module arm, defect 1: `import lead` must not reach `lead.ts`. + + No `lead.py` exists. The arm matched any corpus file whose stem equalled the + receiver, so a TypeScript file of the same stem satisfied it and + `lead.search()` bound to a TypeScript function at EXTRACTED -- an import + edge Python could not possibly have. + """ + _, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + assert not _cross_language_targets(result, run, "lead.ts"), \ + "a Python module receiver must not resolve against a TypeScript file" + + +def test_python_module_receiver_still_resolves_its_own_module(tmp_path: Path): + """The module arm's positive control: scoping must not cost the real edge.""" + calls, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, "search()", "lead.py") + assert (run, py_search) in calls, "the module arm stopped resolving" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +def test_python_module_receiver_resolves_despite_a_same_stem_typescript_file( + tmp_path: Path, +): + """Module arm, defect 2: a same-stem foreign file must not delete the edge. + + `lead.py` and `lead.ts` both derive the file node id `lead`, so + disambiguation salts them into `lead_py_lead` and `lead_ts_lead` — while the + `imports` edge target stays the bare alias `lead`, which now names nothing. + The arm then sees ZERO candidate modules rather than an ambiguous two, and + the one edge Python's own import genuinely supports disappears (#33). + """ + calls, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + run = _nid(result, "run()", "caller.py") + py_search = _nid(result, "search()", "lead.py") + ts_search = _nid(result, "search()", "lead.ts") + assert (run, py_search) in calls, \ + "a same-stem TypeScript file suppressed the real Python edge" + assert (run, ts_search) not in calls, "the TypeScript decoy received an edge" + assert calls[(run, py_search)]["confidence"] == "EXTRACTED" + + +def test_python_import_edge_survives_a_same_stem_foreign_sibling(tmp_path: Path): + """The `imports` edge itself, one layer below the call edge above (#33). + + Asserted separately because the module arm is only one consumer: any query + over Python imports lost this edge to the same dangling alias. + """ + _, result = _calls(tmp_path, { + "caller.py": _MOD_CALLER, + "lead.py": "def search():\n return []\n", + "lead.ts": _TS_MODULE_DECOY, + }, cache_root=tmp_path) + + node_ids = {node["id"] for node in result["nodes"]} + py_file = _nid(result, "lead.py", "lead.py") + ts_file = _nid(result, "lead.ts", "lead.ts") + imports = { + (edge["source"], edge["target"]) + for edge in result["edges"] + if edge["relation"] in ("imports", "imports_from") + } + caller_file = _nid(result, "caller.py", "caller.py") + assert (caller_file, py_file) in imports, \ + "the Python import edge dangled on the pre-disambiguation alias" + assert (caller_file, ts_file) not in imports, \ + "a Python import must never resolve to a TypeScript file" + for source, target in imports: + assert target in node_ids, f"import edge dangles: {source} -> {target}" diff --git a/tests/test_php_alias_binding.py b/tests/test_php_alias_binding.py new file mode 100644 index 000000000..0c08bbf88 --- /dev/null +++ b/tests/test_php_alias_binding.py @@ -0,0 +1,425 @@ +"""PHP positive alias binding via the declared-FQN index (#22, #23). + +#21's `PhpNameResolver` made a claimed name DECISIVE — a `use` import whose +target is not in the corpus refuses instead of binding a same-short-named +stranger — but it could only delete edges. This is the additive counterpart: +when the claimed FQN matches the name some in-corpus file DECLARES for a class +(#14's `php_class_fqns` payload), that match binds, selecting the imported one +of several namesakes and following a renaming alias (`use App\\A\\X as Y;`) to +a class the written short name would never census. + +The index only knows classes whose declared FQNs are available. On a full run +that is every dispatched file; on an incremental rebuild the defining file is +NOT re-dispatched, so the declared map rides the persisted `_php_class_fqns` +marker on the file node (#23) — the same channel as `_php_non_class_types`. +A graph written before the marker fails closed: the #22 binding is simply +absent (never guessed), while #21's verdicts still stand on the path evidence. + +Every test goes through the public `extract()` seam, and every positive case +carries same-short-named decoys in other namespaces asserted to get NO edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def _sends(calls, caller: str) -> list[str]: + return [tgt for src, tgt in calls if src == caller] + + +# Two in-corpus namesakes: only the caller's `use` import can tell them apart, +# so the pre-#22 single-definition guard refused both. +_X_A = ( + " str: + return ( + "c->send(); }\n" + "}\n" + ) + + +# ── the binding: a claimed FQN selects among namesakes ──────────────────────── + + +def test_use_selects_the_imported_one_of_two_namesakes(tmp_path: Path): + """Acceptance #1: `use App\\Alpha\\X;` binds the receiver to `App\\Alpha\\X` + and ONLY it — the `App\\Beta\\X` namesake gets nothing.""" + calls, r = _calls(tmp_path, { + **_NAMESAKES, + "app/Http/I.php": _caller("use App\\Alpha\\X;\n"), + }) + + go = _find(r, ".go()", "_go") + alpha = _find(r, ".send()", "alpha") + assert (go, alpha) in calls, "the imported namesake must bind" + assert calls[(go, alpha)]["confidence"] == "INFERRED" + assert _sends(calls, go) == [alpha], "the other namesake must get NO edge" + + +def test_renaming_alias_binds_its_target(tmp_path: Path): + """Acceptance #2: `use App\\Alpha\\X as Y;` with `private Y $c;` follows the + alias to `App\\Alpha\\X`. An unrelated in-corpus class actually NAMED `Y` + is exactly the stranger #21 refused — it must stay refused, not bound.""" + calls, r = _calls(tmp_path, { + **_NAMESAKES, + "app/Other/Y.php": ( + " `src/`), + and with the declaration unavailable the shorter path must not read as a + contradiction — a stripped prefix looks exactly like one.""" + caller = ( + "o->ping(); }\n" + "}\n" + ) + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "src/Odd.php": ( + "chargeCustomerService = $chargeCustomerService; + } + +The measured case is `app/Services/Billing/BalanceCustomerAccountService.php:14-20` +of api.lawnstarter.com @ `d2d4bed7ae`, whose `handle()` minted no `calls` edge to +`ChargeCustomerService::handle()`. The parameter's signature is the evidence — +the docblock is NOT read, and a property whose only type is a docblock stays +unbound (locked by `test_docblock_alone_still_emits_no_edge`). + +The refusal discipline mirrors the local-variable one (#4): a property whose +constructor-body binding is not provably single-typed is POISONED — left out of +the receiver table entirely — rather than bound to a guess. + +Every test goes through the public `extract()` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's bound type can. +_SERVICE = " str: + """A controller class whose `index()` calls `$this->leadHunter->search([])`.""" + return ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ) + + +def _no_search_edge(calls: dict, caller: str) -> bool: + return not any(src == caller and "search" in tgt.lower() for src, tgt in calls) + + +# ── The measured case (#38) ─────────────────────────────────────────────────── + +def test_docblock_typed_property_assigned_in_ctor_body_resolves(tmp_path: Path): + """The exact shape of BalanceCustomerAccountService.php:14-20: untyped + property + `@var` docblock + ctor-body assignment from a typed param.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " /** @var LeadHunterService */\n" + " protected $leadHunter;\n" + "\n" + " public function __construct(\n" + " LeadHunterService $leadHunter\n" + " ) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + assert edge["context"] == "call" + + +def test_property_with_no_docblock_at_all_resolves(tmp_path: Path): + """The docblock is decoration, not evidence: the parameter's type is what + binds, so the same code without any docblock resolves identically.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_property_never_declared_at_all_resolves(tmp_path: Path): + """A dynamic property (assigned but never declared) binds the same way — + the assignment is the only declaration PHP needs.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_property_and_param_names_may_differ(tmp_path: Path): + """The property name comes from the assignment's LEFT side, the type from + its right — the two names need not agree.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_same_namespace_param_type_resolves_without_a_use_import(tmp_path: Path): + """The measured case names its collaborator with no `use` line: it lives in + the caller's own namespace. The namespace-aware binding (#22) applies to a + ctor-body-bound receiver exactly as it does to a declared one.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": _SERVICE, + "app/Audit/AuditLog.php": _DECOY, + "app/Services/LeadFinder.php": ( + "leadHunter = $leadHunter;\n" + " }\n" + " public function find(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + find = _find(r, ".find()", "leadfinder") + assert (find, _find(r, ".search()", "leadhunterservice")) in calls + assert (find, _find(r, ".search()", "auditlog")) not in calls + + +def test_qualified_param_type_resolves(tmp_path: Path): + """A param type written out fully qualified binds the same way.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(\\App\\Services\\LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_nullable_param_type_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(?LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_promoted_param_may_also_type_a_second_property(tmp_path: Path): + """A promoted param is a typed variable in the constructor's scope too, so + aliasing it onto another property binds that property as well.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(private LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_the_constructor_body_itself_can_use_the_binding(tmp_path: Path): + """The table is built before resolution, so a call in the constructor body + resolves against a property the same body binds.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $leadHunter;\n" + " $this->leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + ctor = _find(r, ".__construct()", "leadcontroller") + assert (ctor, _find(r, ".search()", "leadhunterservice")) in calls + assert (ctor, _find(r, ".search()", "auditlog")) not in calls + + +def test_two_classes_in_one_file_bind_their_own_constructors(tmp_path: Path): + """The binding is class-scoped: the same property name assigned from a + different type in each class resolves to its own class's parameter.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $svc;\n" + " }\n" + " public function index(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + "class AuditController {\n" + " protected $leadHunter;\n" + " public function __construct(AuditLog $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function index(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + lead_index = _find(r, ".index()", "leadcontroller") + audit_index = _find(r, ".index()", "auditcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "audit_auditlog") + assert (lead_index, service_search) in calls + assert (lead_index, decoy_search) not in calls + assert (audit_index, decoy_search) in calls + assert (audit_index, service_search) not in calls + + +# ── Hard scope limit: docblocks are NOT read ───────────────────────────────── + +def test_docblock_alone_still_emits_no_edge(tmp_path: Path): + """`@var` docblocks stay out of scope (#38): with no constructor assignment + to corroborate it, a docblock-only property types no receiver.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " /** @var LeadHunterService */\n" + " protected $leadHunter;\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), "a docblock is not evidence — no edge" + + +def test_docblock_does_not_override_the_assigned_type(tmp_path: Path): + """When the docblock and the assigned parameter disagree, the SIGNATURE + wins — proof the docblock is never parsed.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " /** @var \\App\\Audit\\AuditLog */\n" + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +# ── Refusals: the binding must be provably single-typed ─────────────────────── + +def test_second_assignment_of_a_different_type_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc, AuditLog $log) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter = $log;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), \ + "two conflicting parameter types poison the property — no edge to EITHER" + + +def test_second_assignment_of_an_untypable_value_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter = app('lead.hunter');\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), \ + "a container resolve names no type — the property is poisoned" + + +def test_assignment_from_an_untyped_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct($leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_from_a_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService|AuditLog $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), "a union names several classes — refuse" + + +def test_assignment_from_a_local_new_emits_no_edge(tmp_path: Path): + """Scope limit: only a typed constructor PARAMETER binds. A local built in + the body is not a parameter, and it poisons rather than binds.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct() {\n" + " $svc = new LeadHunterService();\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_from_an_inline_new_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct() {\n" + " $this->leadHunter = new LeadHunterService();\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_param_rebound_in_the_body_poisons_the_property(tmp_path: Path): + """The parameter's own binding must survive the body: a rebind to an + untypable value makes the name — and so the property — unresolvable.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $svc = $other;\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_param_rebound_to_another_class_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $svc = new AuditLog();\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_closure_param_shadowing_the_ctor_param_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $fn = function (AuditLog $svc) { return $svc; };\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_inside_a_closure_emits_no_edge(tmp_path: Path): + """A deferred write is not a constructor binding: the closure may run never, + later, or more than once.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->boot = function () use ($svc) { $this->leadHunter = $svc; };\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_inside_a_closure_poisons_a_good_binding(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->boot = function () { $this->leadHunter = null; };\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_augmented_assignment_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter ??= $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_reference_assignment_poisons_the_property(tmp_path: Path): + """`$this->prop = &$svc` aliases the property to the variable's storage, so + a later rebind of that variable changes the property's type too. + + Two statements on purpose: `$this->prop = &$svc` alone is a + `reference_assignment_expression`, which binds nothing whether or not the + refusal exists, so a one-statement fixture could not tell POISONED from + never-bound. The plain assignment first makes the refusal observable. + """ + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " $this->leadHunter = &$svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_list_destructuring_target_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " [$this->leadHunter, $rest] = $pair;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_foreach_target_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " foreach ($rows as $this->leadHunter) {}\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_assignment_in_a_non_constructor_method_emits_no_edge(tmp_path: Path): + """Scope limit: the CONSTRUCTOR body is the binding site. A setter runs at + an unknown time and may be called with anything.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function setHunter(LeadHunterService $leadHunter): void {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_interface_typed_param_assigned_to_a_property_binds_the_interface(tmp_path: Path): + """A ctor-body-bound receiver typed with an INTERFACE binds to the + interface's own method (#53), not to a same-short-named implementation. + + #5/#12 refused this outright, back when an interface minted no definition + node and the only thing a receiver could land on was a stranger class. Post + #47/#53 the contract has its own `search()` node and the `use` names it + unambiguously — `App\\Services\\LeadHunterService` is a DIFFERENT type that + the controller never imported, and it stays unbound, which is the whole + point the original refusal was protecting.""" + calls, r = _calls(tmp_path, { + "app/Contracts/LeadHunterService.php": ( + "leadHunter = $leadHunter;\n" + " }\n", + uses="use App\\Contracts\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "contracts_leadhunterservice")) in calls, \ + "the imported contract is the receiver's declared type" + assert (index, _find(r, ".search()", "services_leadhunterservice")) not in calls, \ + "an interface names no implementation — never guess one" + + +def test_out_of_corpus_param_type_emits_no_edge(tmp_path: Path): + """A `use`-claimed name that names no in-corpus class is decided and never + falls back to the short-name index (#16).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $leadHunter) {\n" + " $this->leadHunter = $leadHunter;\n" + " }\n", + uses="use Vendor\\Sdk\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +# ── The refusal is property-scoped, not constructor-scoped ──────────────────── +# +# A property is not a local: any method of the class can rewrite it, and unlike a +# natively typed property (which PHP fatals on an incompatible write) an untyped +# one carries no runtime guarantee. So "poison on reassignment" is judged over +# the whole CLASS — a write anywhere outside the constructor is the same category +# of deferred write as a closure's, and refuses the constructor's binding. + +def test_setter_reassigning_to_another_type_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function setHunter(AuditLog $log): void {\n" + " $this->leadHunter = $log;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index), \ + "a setter rewrites the property to another class — the ctor binding is not " \ + "what the property provably holds at the call site" + + +def test_setter_assigning_an_untypable_value_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(): void {\n" + " $this->leadHunter = app('lead.hunter');\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_setter_assigning_the_same_type_also_refuses(tmp_path: Path): + """Deliberately conservative: refusal is unconditional on the write, not + conditional on the written type. A same-typed setter is provably harmless, + so this costs a little recall — the trade the refusal discipline chooses. + Documented as intended behavior so a future reader does not read it as a bug. + """ + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function setHunter(LeadHunterService $svc): void {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_a_setter_that_leaves_the_property_alone_keeps_the_binding(tmp_path: Path): + """Control for the class-wide sweep: it must refuse only the properties a + method actually WRITES, not every property of a class that has setters.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " protected $other;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function setOther(AuditLog $log): void {\n" + " $this->other = $log;\n" + " $log->search([]);\n" + " }\n" + " public function read(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + read = _find(r, ".read()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (read, service_search) in calls + assert (index, _find(r, ".search()", "audit_auditlog")) not in calls + + +def test_non_constructor_augmented_assignment_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(): void {\n" + " $this->leadHunter ??= null;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_non_constructor_list_destructuring_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(array $pair): void {\n" + " [$this->leadHunter, $rest] = $pair;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_non_constructor_foreach_target_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(array $rows): void {\n" + " foreach ($rows as $this->leadHunter) {}\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_non_constructor_reference_assignment_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(LeadHunterService $other): void {\n" + " $this->leadHunter = &$other;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_write_inside_a_closure_in_another_method_poisons_the_property(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function boot(): void {\n" + " $this->defer(function () { $this->leadHunter = null; });\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_a_write_to_another_objects_property_does_not_poison(tmp_path: Path): + """`$other->leadHunter = …` writes a DIFFERENT object's property.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function copyTo(LeadController $other): void {\n" + " $other->leadHunter = null;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_a_write_in_an_anonymous_class_does_not_poison_the_outer_class(tmp_path: Path): + """An anonymous class's `$this` is its OWN instance, so a write to a + same-named property inside one says nothing about the outer class's.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n" + " public function make(): object {\n" + " return new class {\n" + " public $leadHunter;\n" + " public function boot(): void { $this->leadHunter = null; }\n" + " };\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_a_write_in_another_class_does_not_poison(tmp_path: Path): + """The sweep is class-scoped: a same-named property written by a DIFFERENT + class in the same file leaves this class's binding alone.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $svc;\n" + " }\n" + " public function index(): array {\n" + " return $this->leadHunter->search([]);\n" + " }\n" + "}\n" + "class OtherController {\n" + " protected $leadHunter;\n" + " public function reset(): void {\n" + " $this->leadHunter = null;\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + + +def test_declared_typed_property_survives_a_setter(tmp_path: Path): + """The class-wide sweep must not reach the DECLARED path: PHP enforces a + native property type on every write, so a setter cannot retype it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected LeadHunterService $leadHunter;\n" + " public function setHunter(LeadHunterService $svc): void {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_promoted_property_survives_a_setter(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(protected LeadHunterService $leadHunter) {}\n" + " public function setHunter(LeadHunterService $svc): void {\n" + " $this->leadHunter = $svc;\n" + " }\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +# ── Precedence: a real annotation always outranks the assignment ────────────── + +def test_declared_property_type_outranks_the_ctor_assignment(tmp_path: Path): + """A natively typed property is the author's stated contract; the ctor-body + assignment only fills a GAP.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected AuditLog $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "auditlog")) in calls + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + + +def test_union_typed_property_declaration_stays_refused(tmp_path: Path): + """A union annotation is a PRESENT-but-unresolved binding (#9), not a gap: + the ctor assignment must not resolve it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " protected LeadHunterService|AuditLog $leadHunter;\n" + " public function __construct(LeadHunterService $svc) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert _no_search_edge(calls, index) + + +def test_promoted_param_outranks_a_conflicting_ctor_assignment(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(\n" + " protected AuditLog $leadHunter,\n" + " LeadHunterService $svc\n" + " ) {\n" + " $this->leadHunter = $svc;\n" + " }\n", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "auditlog")) in calls + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + + +# ── Regressions: the two already-covered sibling forms keep working ─────────── + +def test_regression_promoted_param_receiver_still_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " public function __construct(protected readonly LeadHunterService $leadHunter) {}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_regression_typed_property_receiver_still_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + " private LeadHunterService $leadHunter;\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_regression_typed_method_param_receiver_still_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter = $leadHunter;\n" + " }\n" + " public function handle(LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert (handle, _find(r, ".search()", "leadhunterservice")) in calls + assert (handle, _find(r, ".search()", "auditlog")) not in calls diff --git a/tests/test_php_declaration_nodes.py b/tests/test_php_declaration_nodes.py new file mode 100644 index 000000000..c4f72c5c2 --- /dev/null +++ b/tests/test_php_declaration_nodes.py @@ -0,0 +1,308 @@ +"""PHP `interface` / `trait` / `enum` declarations mint canonical nodes (#47, RC1 of #46). + +`_PHP_CONFIG.class_types` used to hold only `class_declaration`, so no node was +ever minted for a PHP interface, trait or enum. Every resolution pass that could +canonicalize an edge then had nothing to land on: the implements edge kept a bare +sourceless stub, `Foo::CONST` fan-in fragmented across per-file stubs, and +`imports`/parameter-type `references` parked on the file node (or, when the file +name differs from the type name, on a sourceless FQN-labeled stub). + +The control experiment in #46 is the shape these tests pin: change +`interface FooRepository` to `class FooRepository` and every one of those edges +canonicalizes onto the single declaration node. Interfaces, traits and enums must +behave the same way. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _defs(result: dict, label: str) -> list[dict]: + """Sourced (declaration) nodes carrying `label`.""" + return [n for n in result["nodes"] if n.get("label") == label and n.get("source_file")] + + +def _one_def(result: dict, label: str) -> dict: + defs = _defs(result, label) + assert len(defs) == 1, f"expected exactly one sourced `{label}` node, got {len(defs)}" + return defs[0] + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _edges(result: dict, relation: str) -> list[dict]: + return [e for e in result["edges"] if e.get("relation") == relation] + + +def _repro_corpus(tmp_path: Path) -> list[Path]: + """The 7-file minimal repro from #46, verbatim in shape.""" + return [ + _write( + tmp_path / "app/Repo/FooRepository.php", + "repo = $repo;\n }\n\n" + " public function tag(): string\n {\n return FooRepository::BAR;\n }\n}\n", + ), + _write( + tmp_path / "app/Uses/Consumer2.php", + "repo = $repo;\n }\n\n" + " public function tag(): string\n {\n return FooRepository::BAR;\n }\n}\n", + ), + _write( + tmp_path / "app/Repo/Extras.php", + "value;\n }\n}\n", + ), + _write( + tmp_path / "app/Uses/OnlyImport.php", + " {e['target']}" + ) + + # Cascade A / B: no sourceless FooRepository stub survives anywhere. + stubs = [ + n for n in result["nodes"] + if not n.get("source_file") and "foorepository" in n.get("id", "").lower() + ] + assert stubs == [], f"sourceless FooRepository stubs survived: {[n['id'] for n in stubs]}" + + # The loops above filter candidates by looking their target's LABEL up in the + # node set, so an edge left pointing at a bare `foorepository` with no node at + # all resolves to no label, drops out of `landed`, and is never checked. Close + # that blind spot separately: a FooRepository edge target that names no node is + # a regression whatever else canonicalized correctly. + # + # Matched on the id's last segment, not as a substring: the bare Cascade A id + # (`foorepository`) and the Cascade B per-file salts (`_php_foorepository`) + # both end in it, while `dbfoorepository` — a different type, and the repro's + # one legitimately dangling target until RC2 (#48) lands — does not. + node_ids = {n["id"] for n in result["nodes"]} + dangling = sorted( + { + e["target"] for e in result["edges"] + if str(e.get("target", "")).lower().rsplit("_", 1)[-1] == "foorepository" + and e["target"] not in node_ids + } + ) + assert dangling == [], f"FooRepository edge targets with no node: {dangling}" + + +def test_php_trait_and_enum_declared_in_a_differently_named_file(tmp_path: Path): + # #47 criterion 2: `trait Loggable` and `enum Status` live in `Extras.php` + # (filename != type name), so the id-collision accident that lets a class's + # edges land on its file node cannot save them. Both need real nodes. + result = extract(_repro_corpus(tmp_path), cache_root=tmp_path) + + loggable = _one_def(result, "Loggable") + status = _one_def(result, "Status") + assert loggable["source_file"].endswith("app/Repo/Extras.php") + assert status["source_file"].endswith("app/Repo/Extras.php") + + mixes_in = _edges(result, "mixes_in") + assert mixes_in, "expected a mixes_in edge from Consumer3" + for e in mixes_in: + assert e["target"] == loggable["id"] + + imports_by_target = {e["target"] for e in _edges(result, "imports")} + assert loggable["id"] in imports_by_target, "trait import did not land on the trait node" + + # The trait's edges used to park on a sourceless `App\Repo\Loggable` FQN stub, + # because `Extras.php` cannot absorb them by the id-collision accident that + # saves a PSR-4-named class. No such stub may survive. + assert not [ + n for n in result["nodes"] + if not n.get("source_file") and n.get("label") in ("App\\Repo\\Loggable", "Loggable", "Status") + ] + + # `enum Status` is imported by Consumer3 but only used as `Status::Active`, so + # nothing mints a stub node for it. RC1 (#47) supplies the sourced node with + # the FQN, and RC2 (#48) resolves the edge from its own `target_fqn` metadata + # instead of a stub label — with both landed, the import canonicalizes onto + # the enum's declaration node. + enum_import = next( + e for e in _edges(result, "imports") + if (e.get("metadata") or {}).get("target_fqn") == "App\\Repo\\Status" + ) + assert enum_import["target"] == status["id"], ( + "the enum's imports edge must land on the sourced declaration node " + "now that RC1 (#47) and RC2 (#48) are both present" + ) + + +def test_php_enum_body_members_and_clauses(tmp_path: Path): + # An enum's body is an `enum_declaration_list`, not the `declaration_list` + # every other PHP declaration uses. Its methods, its `implements` clause and + # its trait `use` must all be picked up regardless (#47 enum caveat). + contract = _write( + tmp_path / "app/Contracts/HasLabel.php", + "value; }\n}\n", + ) + result = extract([contract, trait, enum], cache_root=tmp_path) + + status = _one_def(result, "Status") + has_label = _one_def(result, "HasLabel") + describes = _one_def(result, "Describes") + + methods = [e for e in _edges(result, "method") if e["source"] == status["id"]] + assert [ + (_node_by_id(result, e["target"]) or {}).get("label") for e in methods + ] == [".label()"], "enum method did not attach to the enum node" + + assert [e["target"] for e in _edges(result, "implements")] == [has_label["id"]] + assert [e["target"] for e in _edges(result, "mixes_in")] == [describes["id"]] + + +def test_php_interface_extends_qualified_interface_resolves(tmp_path: Path): + # #47 criterion 4: the `_resolve_php_type_references` raw-scan only recognised + # `class_declaration`, so an interface's `extends` clause was never recorded. + # `interface Reader extends Sub\Repo` would then fall through to the + # same-namespace guess and bind to the WRONG `Repo`. + outer = _write( + tmp_path / "app/Contracts/Repo.php", + "method(...)`` creates a ``Closure`` — the method is *named*, not invoked, +so control flow does not transfer at that line. The repo already models +"named but not invoked" as the distinct ``indirect_call`` relation, and PHP only +leaked into ``calls`` because the 8.1 grammar reuses ``member_call_expression`` +for first-class-callable syntax. + +Discriminator (probe-verified on the pinned tree-sitter-php 0.24.1): the +``arguments`` node of ``m(...)`` has exactly one named child, of type +``variadic_placeholder``. ``m()``, ``m(1)`` and ``m(...$args)`` do not. + +Target resolution and the refuse-don't-guess rules are UNCHANGED — only the +relation moves. Every positive test carries a decoy class with an identically +named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _edges(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt, rel): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + edges = { + (edge["source"], edge["target"], edge.get("relation")): edge + for edge in result["edges"] + if edge.get("relation") in ("calls", "indirect_call") + } + return edges, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's declared type can. +_SERVICE = " str: + return ( + "leadHunter->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + # Target resolution is unchanged: same receiver typing, same confidence as + # the ordinary `$this->leadHunter->search([])` call would get. + edge = edges[(index, service_search, "indirect_call")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_nullsafe_first_class_callable_emits_indirect_call(tmp_path: Path): + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return $this->leadHunter?->search(...);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + + assert (index, service_search, "indirect_call") in edges + assert (index, service_search, "calls") not in edges + assert (index, decoy_search, "indirect_call") not in edges + assert (index, decoy_search, "calls") not in edges + + +def test_first_class_callable_on_this_emits_indirect_call(tmp_path: Path): + """`$this->helper(...)` resolves in-file, so it must re-tag on that path too.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "normalise(...);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + # Decoy: a same-named method in another class must not pick up the edge. + "app/Audit/Normaliser.php": ( + "leadHunter->search(['status' => 'open']);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + edge = edges[(index, service_search, "calls")] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + assert edge["context"] == "call" + + +def test_ordinary_this_call_still_emits_calls(tmp_path: Path): + """Regression guard for the in-file path: `$this->normalise()` stays EXTRACTED `calls`.""" + edges, r = _edges(tmp_path, { + "app/Http/Controllers/LeadController.php": ( + "normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + own = _find(r, ".normalise()", "leadcontroller") + + assert (index, own, "calls") in edges + assert (index, own, "indirect_call") not in edges + assert edges[(index, own, "calls")]["confidence"] == "EXTRACTED" + + +def test_spread_argument_is_not_a_first_class_callable(tmp_path: Path): + """`search(...$args)` IS an invocation — only a bare `...` placeholder re-tags.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$args = []; return $this->leadHunter->search(...$args);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + +def test_direct_call_wins_over_first_class_callable_to_the_same_method(tmp_path: Path): + """Both forms in one caller: the real invocation keeps the pair (existing + indirect-dispatch precedence), regardless of which appears first.""" + edges, r = _edges(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$f = $this->leadHunter->search(...);\n" + " return $this->leadHunter->search([]);" + ), + "app/Http/Controllers/TeamController.php": ( + "normalise(...);\n" + " return $this->normalise([]);\n" + " }\n" + " public function normalise(array $row): array { return $row; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search, "calls") in edges + assert (index, service_search, "indirect_call") not in edges + + team_index = _find(r, ".index()", "teamcontroller") + own = _find(r, ".normalise()", "teamcontroller") + assert (team_index, own, "calls") in edges + assert (team_index, own, "indirect_call") not in edges diff --git a/tests/test_php_function_call_never_method.py b/tests/test_php_function_call_never_method.py new file mode 100644 index 000000000..4916ff393 --- /dev/null +++ b/tests/test_php_function_call_never_method.py @@ -0,0 +1,275 @@ +"""A PHP function-call site never binds to a METHOD node cross-file (#52). + +In PHP a bare ``name(...)`` (``function_call_expression``) can only invoke a +global or namespaced *function*. Reaching a method requires ``$obj->m()``, +``Class::m()`` or first-class-callable syntax. The shared cross-file pass in +``extract()`` matched by normalized label, and the label index strips the +member marker (``.event()`` -> ``event``), so Laravel's ``event(...)`` helper +bound to whatever class happened to declare an ``event()`` METHOD — 848 +incoming ``calls`` edges on a single test method in the measured corpus. + +Refusal is language-semantic, not a heuristic: methods are simply not +candidates at a function-call site. Real global functions still resolve, and +no other language's candidate filtering changes (Ruby/Python bare calls keep +today's behavior — implicit-self dispatch makes a method a plausible target +there). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + +CALLER = ( + " source) and return (result, {(src, tgt): edge}).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return result, calls + + +def _find(result: dict, label: str, id_contains: str) -> str: + nid = next( + ( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ), + None, + ) + assert nid is not None, f"no node {label}/{id_contains}" + return nid + + +def test_function_call_does_not_bind_to_method(tmp_path): + """`event(...)` + a class method `event()` elsewhere -> no `calls` edge.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/Decoy.php": DECOY_METHOD, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + assert (caller, method) not in calls + + +def test_function_call_still_binds_to_global_function(tmp_path): + """The legitimate global-function resolution must not regress.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + function = _find(result, "event()", "helpers") + assert (caller, function) in calls + + +def test_function_wins_when_method_and_function_both_exist(tmp_path): + """With both candidates present the call binds to the function, never the method.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/Decoy.php": DECOY_METHOD, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + function = _find(result, "event()", "helpers") + assert (caller, method) not in calls + assert (caller, function) in calls + + +def test_case_insensitive_path_does_not_bind_to_method(tmp_path): + """`EVENT(...)` matches `.event()` only via the folded index — still refused.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_FOLDED, + "app/Decoy.php": DECOY_METHOD, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + assert (caller, method) not in calls + + +def test_case_insensitive_path_still_binds_to_global_function(tmp_path): + """PHP is case-insensitive: `EVENT(...)` still resolves to `function event()`.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_FOLDED, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + function = _find(result, "event()", "helpers") + assert (caller, function) in calls + + +def test_case_insensitive_function_wins_over_method(tmp_path): + """Folded path, both candidates: the function is picked, the method refused.""" + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_FOLDED, + "app/Decoy.php": DECOY_METHOD, + "app/helpers.php": GLOBAL_FUNCTION, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + function = _find(result, "event()", "helpers") + assert (caller, method) not in calls + assert (caller, function) in calls + + +def test_case_mismatched_function_resolves_when_method_shadows_exact_key(tmp_path): + """The folded index is retried after the refusal, not skipped. + + `event(...)` finds the METHOD `.event()` on the exact-case key, so the + folded fallback never fires on its own; refusing that sole candidate must + not lose `function Event()`, which PHP's case-insensitive function lookup + makes the real target and which only exists under the folded key. + """ + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER, + "app/Decoy.php": DECOY_METHOD, + "app/helpers.php": GLOBAL_FUNCTION_FOLDED, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".event()", "decoy") + function = _find(result, "Event()", "helpers") + assert (caller, method) not in calls + assert (caller, function) in calls + + +def test_function_call_does_not_bind_to_class_through_folded_retry(tmp_path): + """A CLASS is no more invocable by `name(...)` than a method is. + + `report($e)` with a `.report()` method holding the exact-case key: the + refusal empties that list, and the folded retry then reaches the + capitalized `class Report` — a candidate the exact-case path never offered. + """ + result, calls = _extract(tmp_path, { + "app/Caller.php": CALLER_REPORT, + "app/Decoy.php": DECOY_REPORT_METHOD, + "app/Report.php": CLASS_REPORT, + }) + caller = _find(result, ".handle()", "caller") + method = _find(result, ".report()", "decoy") + klass = _find(result, "Report", "report") + assert (caller, klass) not in calls + assert (caller, method) not in calls + + +def test_function_call_does_not_bind_to_class_unshadowed(tmp_path): + """`foo(...)` + only `class Foo` cross-file -> no edge (no method involved).""" + result, calls = _extract(tmp_path, { + "app/Caller.php": ( + " Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def _targets(result: dict, relation: str, source_substr: str) -> list[dict]: + """Target nodes of every `relation` edge coming out of a matching source.""" + return [ + _node_by_id(result, e["target"]) + for e in result["edges"] + if e.get("relation") == relation + and source_substr in e.get("source", "").lower() + ] + + +def test_php_group_use_function_behaves_like_the_plain_form(tmp_path: Path): + # `use function Vendor\Sdk\{Render};` imports a *function*, so `Render` in a + # class position is not an explicitly imported class name. The braced form + # must land exactly where the unbraced control lands: on the bare stub the + # legacy unique-label rewire owns, never on an FQN-labeled external stub. + group = _write( + tmp_path / "app/A/UsesGroup.php", + " Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_by_id(result: dict, nid: str) -> dict | None: + return next((n for n in result["nodes"] if n.get("id") == nid), None) + + +def _imports_from(result: dict, needle: str) -> list[dict]: + return [ + e for e in result["edges"] + if e["relation"] == "imports" and needle in e.get("source_file", "") + ] + + +def test_php_import_only_used_as_class_const_lands_on_the_real_class(tmp_path: Path): + # #48 (RC2 of #46): `use App\Repo\DbFooRepository;` used only via `::class` + # mints no stub node for the imported name, so the repoint pass used to skip + # the edge (no `stub_label` entry) and the bare target dangled — even though + # the real class node exists in the corpus. The edge's own `target_fqn` + # metadata is enough to resolve it. + repo = _write( + tmp_path / "app/Repo/DbFooRepository.php", + "\`. The edge's + # own `target_fqn` carries the truth. + repo = _write( + tmp_path / "app/Repo/DbFooRepository.php", + ")` that no node of ours ever owned — and that id can + # collide with a real, sourced, edgeless node (`Page.md` -> id `page`). The + # orphan prune must not delete it just because an unrelated `use` line + # mentioned the same short name. + doc = _write(tmp_path / "Page.md", "Some prose about pages.\n") + consumer = _write( + tmp_path / "app/U.php", + "prop->method()`` call +must select the method owned by the property's DECLARED type; receivers whose +type is untyped, union-typed or ambiguous stay unlinked rather than minting a +false call edge. + +Every test goes through the public ``extract()`` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import _php_context_interface_entry, extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# Shared service + decoy: both define `search()`, so a bare method-name match +# cannot tell them apart — only the receiver's declared type can. +_SERVICE = "leadHunter->search(['status' => 'open']);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + assert (index, service_search) in calls + assert (index, decoy_search) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + assert edge["context"] == "call" + + +def test_typed_property_call_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_property_declared_after_the_caller_still_resolves(tmp_path: Path): + """The type table is complete before resolution — declaration order is free.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + " private LeadHunterService $leadHunter;\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_method_name_match_is_case_insensitive(tmp_path: Path): + """PHP method names are case-insensitive, so `SEARCH()` still binds.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->SEARCH([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_nullsafe_member_call_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter?->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert calls[(index, service_search)]["confidence"] == "INFERRED" + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_nullable_typed_property_unwraps_and_resolves(tmp_path: Path): + """`?Foo` is still concretely Foo — the nullable wrapper is unwrapped.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_untyped_property_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "an untyped receiver must not be guessed onto a same-named method" + + +def test_union_typed_property_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "a union-typed receiver has no single concrete type — refuse" + + +def test_self_typed_property_emits_no_edge(tmp_path: Path): + """`self`/`static`/`parent` are not concrete class names in the type table.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_duplicate_class_name_emits_no_edge(tmp_path: Path): + """Two `LeadHunterService` definitions and no claim on the name: the + single-definition guard refuses. (With a `use` import naming one of them + the declared-FQN index now binds it instead — that recall win is #22's, + pinned in `test_php_alias_binding.py`.)""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "legacy/Services/LeadHunterService.php": ( + "leadHunter->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "an ambiguous short class name must not resolve to either definition" + + +def test_unknown_method_has_no_fallback_edge(tmp_path: Path): + """The receiver's type is known but has no such method — refuse entirely.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "leadHunter->missingMethod();\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service = _find(r, "LeadHunterService", "app_services_leadhunterservice_leadhunterservice") + assert not any(src == index for src, _tgt in calls), \ + "an unknown method on a typed receiver must not fall back to any edge" + assert not any( + e.get("relation") == "references" + and e.get("source") == index + and e.get("target") == service + for e in r["edges"] + ), "no `references` consolation edge either — refuse, don't guess" + + +def test_this_self_call_still_extracted(tmp_path: Path): + """Plain `$this->method()` keeps today's same-file bare-name edge.""" + calls, r = _calls(tmp_path, { + "app/Http/ApiClient.php": ( + "fetch($path);\n" + " }\n" + " private function fetch(string $path): string { return $path; }\n" + "}\n" + ), + }) + + get = _find(r, ".get()", "apiclient") + fetch = _find(r, ".fetch()", "apiclient") + assert (get, fetch) in calls + + +def test_untyped_receiver_keeps_same_file_edge(tmp_path: Path): + """Deferral is gated on a stamped receiver type: an untyped receiver keeps + the in-file bare-name match it produced before this feature.""" + calls, r = _calls(tmp_path, { + "app/Http/ApiClient.php": ( + "helper->fetch($path);\n" + " }\n" + " private function fetch(string $path): string { return $path; }\n" + "}\n" + ), + }) + + get = _find(r, ".get()", "apiclient") + fetch = _find(r, ".fetch()", "apiclient") + assert (get, fetch) in calls + + +def test_static_call_edge_unchanged(tmp_path: Path): + """`Class::method()` targets the called METHOD, EXTRACTED (#3872). + + It targeted the CLASS node until the 0.9.92 sync folded upstream's static-call + tier into `_resolve_php_member_calls`.""" + calls, r = _calls(tmp_path, { + "app/Context/SucursalContext.php": ( + "method()` (#3) ────────── +# +# The source names the class outright, so the receiver needs no type table. The +# edge is EXTRACTED only when the written qualified name CORROBORATES the +# resolved node (its namespace segments match the node's file path, PSR-4 +# style); a bare name carries no such evidence and stays INFERRED. + + +def _controller(body: str, uses: str = "") -> str: + return ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "EXTRACTED" + assert edge["confidence_score"] == 1.0 + + +def test_inline_new_bare_name_resolves_inferred(tmp_path: Path): + """A bare `new Service()` names no namespace — nothing corroborates it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new LeadHunterService())->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_inline_new_without_ctor_parens_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\App\\Services\\LeadHunterService)->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_inline_new_non_corroborating_namespace_downgrades(tmp_path: Path): + """`\\Legacy\\...\\LeadHunterService` resolves by short name to the only + definition in the corpus, but the written namespace does not match that + node's path — so the edge is emitted as INFERRED, not EXTRACTED.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\Legacy\\Services\\LeadHunterService())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +# The corroborating fact is the namespace the DEFINING FILE declares (#14). +# PSR-4 is a convention, not an invariant, so the path alone promoted two wrong +# names to EXTRACTED 1.0: one naming a class that exists nowhere in the corpus +# (declared namespace ≠ path), and one naming a different class that merely +# matched as a path tail. The path survives only as the fallback for a file +# that declares no namespace at all. + + +def test_declared_namespace_disagreeing_with_the_path_does_not_promote(tmp_path: Path): + """`app/Services/LeadHunterService.php` declaring `namespace App\\Vendor;` + means `App\\Services\\LeadHunterService` exists NOWHERE — the short name + still resolves to the one definition, but at INFERRED, not 1.0.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_truncated_root_namespace_does_not_corroborate(tmp_path: Path): + """`\\Services\\LeadHunterService` is a ROOT-namespace class, a different + one from `App\\Services\\LeadHunterService` — a missing `use` plus a leading + backslash is a common bug and must not be rewarded with 1.0.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\Services\\LeadHunterService())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_braced_namespace_block_corroborates(tmp_path: Path): + """`namespace App\\Services { … }` declares the same fact as the statement + form, so the whole-name match still promotes.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + + +def test_file_declaring_no_namespace_still_corroborates_by_path(tmp_path: Path): + """A file that declares nothing leaves the PSR-4 path as the only evidence + there is — unchanged behaviour, deliberately kept.""" + calls, r = _calls(tmp_path, { + "app/Services/LeadHunterService.php": ( + "search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + + +def test_written_namespace_match_is_case_insensitive(tmp_path: Path): + """PHP namespaces are case-insensitive, so `\\app\\services\\…` names the + same class the file declares.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\app\\services\\LeadHunterService())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, _find(r, ".search()", "auditlog")) not in calls + assert calls[(index, service_search)]["confidence"] == "EXTRACTED" + + +def test_inline_new_beats_same_file_same_named_method(tmp_path: Path): + """The named class wins over an identically named method in the caller's + own file — the bare-name match must not shadow an explicit `new`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + " public function search(array $filters): array { return []; }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "leadcontroller")) not in calls + + +def test_inline_new_self_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new self())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "`new self()` needs inheritance context the raw-call facts lack — refuse" + + +def test_inline_new_static_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new static())->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_anonymous_class_inline_new_emits_no_edge(tmp_path: Path): + """`new class { ... }` has no class name at all — nothing to resolve, and + no guess onto a same-named method elsewhere in the corpus.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new class { public function search(array $f): array " + "{ return []; } })->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_bare_new_statement_without_call_links_only_the_class(tmp_path: Path): + """`new Service();` on its own is not a MEMBER call, so no method edge. + + Since upstream #3115 the construction itself is a `calls` edge to the + constructed class (the PHP twin of Java #1373 / C# #2997), so the only edge + the statement may leave is `index() -> LeadHunterService`; `search()` on + the service and on the decoy stay untouched because nothing was called on + the instance. + """ + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "new \\App\\Services\\LeadHunterService();\n return [];" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_class = _find(r, "LeadHunterService", "leadhunterservice") + assert {tgt for src, tgt in calls if src == index} == {service_class} + + +def test_inline_new_unknown_method_emits_no_method_edge(tmp_path: Path): + """The named class has no such method — refuse, don't fall back. + + The construction itself still links `index()` to the class (upstream + #3115); that is the ONLY edge allowed here. Neither `search()` may be + reached through a bare-name fallback. + """ + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "return (new \\App\\Services\\LeadHunterService())->missingMethod();" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_class = _find(r, "LeadHunterService", "leadhunterservice") + assert {tgt for src, tgt in calls if src == index} == {service_class}, \ + "the named class has no such method — refuse, don't fall back" + + +# ── Typed locals and typed params, with scope poisoning (#4) ───────────────── +# +# A method-scoped receiver layer types `$var->m()` from `$var = new T()` locals +# and natively typed parameters. Raw calls carry no lexical scope, so any name +# whose binding is not provably single-typed is POISONED: a non-`new` rebind, a +# conflicting `new`, a closure/arrow-fn parameter, a foreach target, or a +# list-destructuring element. Anonymous-class bodies are a different scope +# entirely and bind nothing in the enclosing method. + + +def test_local_new_var_call_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_local_new_qualified_var_call_resolves_inferred(tmp_path: Path): + """A local binding stays INFERRED even when the `new` is fully qualified — + FQN corroboration is scoped to the inline-new receiver form (#3).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert calls[(index, service_search)]["confidence"] == "INFERRED" + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_typed_param_receiver_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (handle, service_search) in calls + assert (handle, _find(r, ".search()", "auditlog")) not in calls + assert calls[(handle, service_search)]["confidence"] == "INFERRED" + + +def test_nullable_typed_param_receiver_resolves(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert (handle, _find(r, ".search()", "leadhunterservice")) in calls + assert (handle, _find(r, ".search()", "auditlog")) not in calls + + +def test_locals_resolve_per_method_independently(tmp_path: Path): + """The receiver layer is method-scoped: the same local name bound to two + different classes in two methods resolves to its own binding in each.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + " public function audit(): array {\n" + " $svc = new AuditLog();\n" + " return $svc->search([]);\n" + " }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + audit = _find(r, ".audit()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + decoy_search = _find(r, ".search()", "auditlog") + assert (index, service_search) in calls + assert (index, decoy_search) not in calls + assert (audit, decoy_search) in calls + assert (audit, service_search) not in calls + + +def test_non_new_reassignment_poisons_local(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $svc = $other;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "a rebind to an untypable value poisons the name" + + +def test_conflicting_new_types_poison_local(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $svc = new AuditLog();\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "two conflicting `new` types poison the name — no edge to EITHER class" + + +def test_augmented_assignment_poisons_local(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $svc ??= new AuditLog();\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_closure_param_shadow_poisons_outer_name(tmp_path: Path): + """Calls inside a closure are attributed to the enclosing method, so a + closure parameter that shadows an outer name makes BOTH unresolvable.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $fn = function (AuditLog $svc) { return $svc->search([]); };\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_arrow_fn_param_shadow_poisons_outer_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $fn = fn(AuditLog $svc) => $svc->search([]);\n" + " return $svc->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_foreach_target_shadow_poisons_outer_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " foreach ($rows as $svc) { $svc->search([]); }\n" + " return [];", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "a foreach target rebinds the name to an unknown element type" + + +def test_list_destructuring_poisons_outer_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " [$svc, $rest] = $pair;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_global_statement_poisons_local(tmp_path: Path): + """`global $svc;` makes the name an alias of the GLOBAL slot — the local + `new` is discarded, so the type learned from it is stale (#13).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " global $svc;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls), \ + "at runtime $svc is the global, never the locally constructed service" + + +def test_static_statement_poisons_local(tmp_path: Path): + """`static $svc;` rebinds the name to the function-static slot, which starts + out null and survives across calls.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " static $svc;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_global_statement_poisons_regardless_of_order(tmp_path: Path): + """Poisoning is order-independent: the raw calls carry no statement order, + so a `global` BEFORE the `new` must refuse just the same.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "global $svc;\n" + " $svc = new LeadHunterService();\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert not any(src == index and "search" in tgt.lower() for src, tgt in calls) + + +def test_multi_name_global_poisons_every_listed_name(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $log = new AuditLog();\n" + " global $log, $svc;\n" + " $svc->search([]);\n" + " return $log->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_multi_name_static_with_initializer_poisons_every_listed_name(tmp_path: Path): + """`static $x = 1, $svc;` declares two names; the constant initializer names + no variable, so exactly the declared ones are poisoned.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " $log = new AuditLog();\n" + " static $x = 1, $log, $svc;\n" + " $svc->search([]);\n" + " return $log->search([]);", + uses="use App\\Audit\\AuditLog;\nuse App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_global_statement_naming_another_variable_keeps_the_binding(tmp_path: Path): + """The poison is name-targeted, not statement-targeted: `global $other;` + says nothing about `$svc`, whose `new` still types it.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " global $other;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_static_statement_naming_another_variable_keeps_the_binding(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$svc = new LeadHunterService();\n" + " static $conn = null;\n" + " return $svc->search([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_new_inside_anonymous_class_does_not_bind_enclosing_name(tmp_path: Path): + """An anonymous-class body is its own scope — its `new` must not type a + same-named variable in the method that contains the literal.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": _controller( + "$anon = new class {\n" + " public function q(): void { $svc = new \\App\\Services\\LeadHunterService(); }\n" + " };\n" + " return $svc->search([]);" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) not in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + + +def test_chained_receiver_emits_no_edge(tmp_path: Path): + """`$a->b()->c()`: the outer receiver is a call result, not a typed name.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Report/Formatter.php": ( + "search([])->format([]);", + uses="use App\\Services\\LeadHunterService;\n", + ), + }) + + index = _find(r, ".index()", "leadcontroller") + assert (index, _find(r, ".search()", "leadhunterservice")) in calls, \ + "the INNER call still resolves through the typed local" + assert (index, _find(r, ".format()", "formatter")) not in calls, \ + "the chained call's receiver has no known type" + + +def test_untyped_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +def test_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +def test_self_typed_param_emits_no_edge(tmp_path: Path): + """`self`/`static` parse as a plain `named_type` in parameter position, so + the non-concrete name set is what refuses them (probe-verified).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +def test_variadic_typed_param_emits_no_edge(tmp_path: Path): + """`Service ...$svcs` binds an ARRAY of Service, not a Service.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/Controllers/LeadController.php": ( + "search([]);\n" + " }\n" + "}\n" + ), + }) + + handle = _find(r, ".handle()", "leadcontroller") + assert not any(src == handle and "search" in tgt.lower() for src, tgt in calls) + + +# ── Interface-typed receivers bind to the interface itself (#5, #53) ───────── +# +# Pre-#47 a PHP `interface_declaration` minted NO definition node, so an +# interface-typed receiver could only ever bind a same-short-named STRANGER. +# The dangerous case is Laravel's Contracts convention: `App\Contracts\Notifier` +# (interface) next to an unrelated `App\Support\Notifier` (class) — the +# short-name lookup found exactly one definition, the wrong one, and satisfied +# the ambiguity guard. #5 answered that with a blanket refusal keyed off a +# pre-scan of every interface/enum/trait name in the corpus. +# +# Post-#47 the declaration mints a canonical sourced node and its own methods +# attach to it, so the blanket refusal is no longer what keeps the stranger out: +# the collision now presents TWO definitions under the short name and is refused +# by the single-definition guard, or decisively by `PhpNameResolver` when the +# calling file `use`-imports the name. #53 lifts it, so a receiver typed with an +# UNAMBIGUOUS interface name binds to that interface's own method. Both halves +# are pinned below: the collisions stay unbound, the unique names now bind. +# Implementations are still never guessed. + +# One `Notifier` in the corpus (an interface), plus a decoy class carrying the +# same METHOD name under a different type name — a bare method-name match would +# take the decoy, only the receiver's declared type picks the interface. +_UNIQUE_IFACE_CORPUS = { + "app/Contracts/Notifier.php": ( + " bool: + return any(src == caller and "notify" in tgt.lower() for src, tgt in calls) + + +def test_unique_interface_typed_property_binds_to_the_interface_method(tmp_path: Path): + """#53 criterion 1: `private Notifier $n` + `$this->n->send()` where the + corpus holds exactly ONE `Notifier` — the interface — binds to the + interface's own `send()` declaration node (post-#47 it has one).""" + calls, r = _calls(tmp_path, { + **_UNIQUE_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->send('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + contract_send = _find(r, ".send()", "contracts_notifier") + assert (go, contract_send) in calls, \ + "an unambiguous interface-typed receiver names the interface's method" + assert (go, _find(r, ".send()", "auditlog")) not in calls, \ + "the same method name on an unrelated class is not the receiver's type" + edge = calls[(go, contract_send)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + assert edge["context"] == "call" + + +def test_unique_interface_binds_through_the_single_definition_guard(tmp_path: Path): + """The same binding with NO `use` import to claim the name, so + `PhpNameResolver` abstains and the corpus-wide short-name census decides — + the plain single-definition guard path. Both files sit in the global + namespace, which is what makes the unqualified annotation name the + interface.""" + calls, r = _calls(tmp_path, { + "app/Contracts/Notifier.php": ( + "notifier->send('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".send()", "contracts_notifier")) in calls + assert (go, _find(r, ".send()", "auditlog")) not in calls + + +def test_interface_typed_param_binds_to_the_interface_method(tmp_path: Path): + """The typed-parameter receiver path (#4) reaches the interface too.""" + calls, r = _calls(tmp_path, { + **_UNIQUE_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "send('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".send()", "contracts_notifier")) in calls + assert (go, _find(r, ".send()", "auditlog")) not in calls + + +def test_colliding_interface_and_class_short_name_emits_no_edge(tmp_path: Path): + """#53 criterion 3, on the guard the lifted refusal hands the job to: with + `App\\Contracts\\Notifier` (interface) and `App\\Support\\Notifier` (class) + both in the corpus the short name censuses TWO definitions, so the + single-definition guard refuses on its own. No `use` import here — the + caller shares the interface's namespace, which is what makes the bare + annotation name it — so `PhpNameResolver` abstains and the guard is the only + thing standing between the receiver and the stranger. PHP itself would bind + the interface; refusing is a recall gap, never a wrong edge.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Contracts/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls, \ + "the same-short-named class is not the interface the receiver declares" + assert not _notified(calls, go) + + +def test_interface_typed_property_does_not_guess_implementation(tmp_path: Path): + """The contract is the receiver's type; its implementations are not. + + `MailNotifier implements Notifier` is the only class that could satisfy the + annotation at runtime, and it still gets NOTHING — an interface names a + contract, and picking one implementation out of the corpus is the guess #5 + forbade and #53 does not reintroduce. What the receiver does bind is the + interface's OWN `notify()` node (#47), which is where the fan-in belongs.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "mailnotifier")) not in calls, \ + "an interface names a contract, not an implementation — never guess" + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls + + +def test_interface_short_name_collision_binds_the_imported_one(tmp_path: Path): + """`App\\Contracts\\Notifier` (interface) and `App\\Support\\Notifier` + (unrelated class) share a short name, and the calling file's `use` says + WHICH one it means. + + #5 could only refuse here: the interface minted no node, so the short-name + census saw the stranger alone and would have bound it. Post-#47/#53 both + are definitions, and the declared-FQN index (#22, extended to non-class + declarations for #53) matches the imported FQN against the name the + interface's own file declares — decisively, without ever consulting the + census. The stranger is still what must not be bound.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls, \ + "the same-short-named class is not the interface the receiver declares" + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls + + +def test_interface_binding_is_case_insensitive(tmp_path: Path): + """PHP type names are case-insensitive: `notifier` IS `Notifier`, so the + lowercase annotation reaches the same interface and still never the + same-short-named class.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + + +def test_interface_typed_param_binds_the_imported_interface(tmp_path: Path): + """The typed-parameter receiver path (#4) reaches the interface too.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + assert (go, _find(r, ".notify()", "mailnotifier")) not in calls + + +def test_interface_inline_new_emits_no_edge(tmp_path: Path): + """The inline-new receiver path (#3) emits nothing here, and for the reason + that survives the lift: this file imports nothing, and an inline `new` + carries its written namespace on `receiver_qualified` — which only feeds the + EXTRACTED promotion, not `resolve_type_name`. So the resolver abstains, the + short name censuses TWO `Notifier` definitions, and the single-definition + guard refuses. `new` on an interface is invalid PHP anyway; the invariant + worth pinning is that the same-short-named stranger is never what it binds.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notify('x');\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + assert not _notified(calls, go) + + +def test_interface_typed_local_new_binds_the_imported_interface(tmp_path: Path): + """The typed-local receiver path (#4): `$n = new Notifier()` is broken PHP + for an interface, but the local's declared type is still the name the file + imported — so it binds the interface it NAMES, never the stranger the short + name would otherwise census.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Http/Dispatcher.php": ( + "notify('x');\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + assert (go, _find(r, ".notify()", "contracts_notifier")) in calls + + +def test_class_receiver_still_resolves_when_an_interface_exists(tmp_path: Path): + """Name scoping: a CLASS-typed receiver resolves to its class, and the + same-named interface elsewhere in the corpus changes nothing.""" + calls, r = _calls(tmp_path, { + **_IFACE_CORPUS, + "app/Audit/AuditTrail.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "dispatcher") + assert (go, _find(r, ".notify()", "mailnotifier")) in calls + assert (go, _find(r, ".notify()", "audittrail")) not in calls + assert (go, _find(r, ".notify()", "support_notifier")) not in calls + + +# ── Enum- and trait-typed receivers (#12, #53) ─────────────────────────────── +# +# Pre-#47 `enum_declaration` and `trait_declaration` minted no definition node +# either, so they leaked exactly like interfaces did before #5: `App\Enums\Status` +# (enum) beside an unrelated `App\Legacy\Status` (class) left ONE definition under +# that short name, and the single-definition guard bound the stranger. The +# Laravel shape is an enum mirroring a model. Post-#47/#53 the collision censuses +# two definitions and is refused on that basis, while an enum's own methods ARE +# call targets — the recall gap #12 documented is closed below. + +_ENUM_CORPUS = { + "app/Enums/Status.php": ( + " bool: + return any(src == caller and "label" in tgt.lower() for src, tgt in calls) + + +def _runner(body: str) -> str: + return ( + "status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert (go, _find(r, ".label()", "enums_status")) in calls + + +def test_enum_promoted_ctor_param_binds_the_imported_enum(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function __construct(private Status $status) {}\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert (go, _find(r, ".label()", "enums_status")) in calls + + +def test_enum_typed_param_binds_the_imported_enum(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function go(Status $s): void { $s->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert (go, _find(r, ".label()", "enums_status")) in calls + + +def test_enum_fqn_typed_property_binds_the_written_enum(tmp_path: Path): + """The sharpest form: the source names `\\App\\Enums\\Status` outright, so + binding `App\\Legacy\\Status` would contradict the written type — and the + written type is precisely what the declared-FQN index now matches.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " private \\App\\Enums\\Status $status;\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert (go, _find(r, ".label()", "enums_status")) in calls + + +def test_enum_typed_local_new_binds_the_imported_enum(tmp_path: Path): + """The typed-local receiver path (#4): `new Status()` is broken PHP for an + enum, but the local's declared type still names the imported enum and never + the stranger.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function go(): void {\n" + " $s = new Status();\n" + " $s->label();\n" + " }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert (go, _find(r, ".label()", "enums_status")) in calls + + +def test_enum_inline_new_binds_the_written_enum(tmp_path: Path): + """The inline-new receiver path (#3) contrasted with the interface one + above: `new` on an enum is equally invalid PHP, but here the runner DOES + `use App\\Enums\\Status`, so the claim is decided by the declared-FQN index + rather than left to the two-candidate census. The written + `\\App\\Enums\\Status` then corroborates the enum's declared name, which is + what promotes the edge to EXTRACTED. The stranger stays unbound either + way — that is the invariant #12 was protecting.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " public function go(): void {\n" + " (new \\App\\Enums\\Status())->label();\n" + " }" + ), + }) + + go = _find(r, ".go()", "runner") + enum_label = _find(r, ".label()", "enums_status") + assert (go, _find(r, ".label()", "legacy_status")) not in calls + assert (go, enum_label) in calls + assert calls[(go, enum_label)]["confidence"] == "EXTRACTED" + + +def test_enum_binding_is_case_insensitive(tmp_path: Path): + """PHP type names are case-insensitive: `status` IS `Status`.""" + calls, r = _calls(tmp_path, { + **_ENUM_CORPUS, + "app/Runner.php": _runner( + " private status $status;\n" + " public function go(): void { $this->status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "enums_status")) in calls + assert (go, _find(r, ".label()", "legacy_status")) not in calls + + +def test_enum_without_a_colliding_class_binds_to_the_enum_method(tmp_path: Path): + """#53 criterion 2: drop the colliding class and `Status` names exactly one + type — the enum — whose `label()` hangs off its own declaration node + (#47). The receiver binds there. This is the recall gap #12 documented, + now closed; the decoy class proves it is the declared type doing the work + and not a bare method-name match.""" + calls, r = _calls(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "status->label(); }" + ), + }) + + go = _find(r, ".go()", "runner") + enum_label = _find(r, ".label()", "enums_status") + assert (go, enum_label) in calls, \ + "an unambiguous enum-typed receiver names the enum's own method" + assert (go, _find(r, ".label()", "models_lead")) not in calls + edge = calls[(go, enum_label)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_trait_typed_receiver_binds_the_imported_trait(tmp_path: Path): + """A trait is not a type, so a trait-typed receiver is already broken PHP. + The graph follows the name the source actually writes — the imported + `App\\Support\\Cache` trait, whose `flush()` is a real node post-#47 — and + never the same-short-named `App\\Legacy\\Cache` class, which is what the + #12 refusal existed to prevent.""" + calls, r = _calls(tmp_path, { + "app/Support/Cache.php": ( + "cache->flush(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".flush()", "legacy_cache")) not in calls + assert (go, _find(r, ".flush()", "support_cache")) in calls + + +def test_class_receiver_still_resolves_when_an_enum_exists(tmp_path: Path): + """Name scoping: a CLASS-typed receiver resolves to its class with an + unrelated enum (and a same-named-method decoy class) in the corpus.""" + calls, r = _calls(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "lead->label(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "runner") + assert (go, _find(r, ".label()", "lead")) in calls + assert (go, _find(r, ".label()", "audittrail")) not in calls + + +# ── Full and incremental builds must AGREE (#11, #12, #53) ─────────────────── +# +# Every test above goes through ONE full extract(), where a declaring file's +# facts reach the resolver through `per_file` — which aligns 1:1 with the files +# dispatched this run. `graphify update`/watch dispatch only the CHANGED files +# and hand the unchanged corpus back as read-only resolution context, so any +# fact that lives only in `per_file` stops applying the moment the declaring +# file is not re-extracted. That asymmetry has bitten in both directions: #11 +# lost the interface REFUSAL and bound a same-short-named stranger, and #53's +# `use`-claim guard lost its DECLARED FQN and bound an in-corpus interface a +# vendor import provably does not name (`..._refuses_on_both_builds` below). +# Each test here therefore asserts the incremental verdict AND the full one. +# The context is assembled exactly as watch.py builds it from graph.json +# (watch.py:1205-1240): a FIELD SUBSET of the persisted AST nodes — id, label, +# source_file, file_type, type plus the persisted underscore markers — and the +# corpus's contains/method edges, both scoped to the files NOT being re-extracted. + +_CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_non_class_types", + "_php_interfaces", "_php_class_fqns") + + +def _watch_resolution_context(result: dict, unchanged: set[str]): + """Mirror watch.py's resolution-context assembly for the `unchanged` files.""" + nodes = [] + for node in result["nodes"]: + if not node.get("id") or node.get("source_file") not in unchanged: + continue + ctx = {"id": node["id"]} + ctx.update({field: node.get(field) for field in _CTX_NODE_FIELDS}) + ctx.update({m: node[m] for m in _CTX_MARKERS if node.get(m)}) + nodes.append(ctx) + edges = [ + { + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": edge.get("source_file"), + } + for edge in result["edges"] + if edge.get("relation") in ("contains", "method") + and edge.get("source_file") in unchanged + ] + return nodes, edges + + +def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): + """Full-extract `files`, then re-extract ONLY `changed` (its body edited) with + the rest supplied as watch-shaped resolution context. + + Returns ((full_calls, full_result), (inc_calls, inc_result)). Both runs share + `cache_root`, the anchor watch passes, so node ids line up across them. + """ + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + # Edit the caller only — an unrelated statement, so its raw_calls are re-derived + # while every other file stays byte-identical and therefore undispatched. + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed} + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_DISPATCHER = "app/Http/Dispatcher.php" + + +def test_interface_binding_agrees_across_an_incremental_rebuild(tmp_path: Path): + """The interface file is unchanged and therefore NOT dispatched: its + declaration node, its `method` edge and its declared FQN all reach the + resolver through the replay channel alone, and the verdict must match the + full build's (#11, #53).""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + contract = _find(full, ".notify()", "contracts_notifier") + assert (go, contract) in full_calls, "full-build baseline binds the contract" + assert (go, contract) in inc_calls, \ + "an undispatched interface file must keep its replayed binding" + + +def test_interface_short_name_collision_never_binds_the_stranger_incrementally(tmp_path: Path): + """The Laravel Contracts collision across a rebuild. Whatever the resolver + decides about the imported `App\\Contracts\\Notifier`, the unrelated + `App\\Support\\Notifier` class is what must never receive the edge — it was + the lone DEFINITION under that short name before #47, which is exactly how + #11 used to bind it.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + # The stranger's node lives in an unchanged file, so its id comes from the + # full result — the incremental run returns only fresh nodes. + stranger = _find(full, ".notify()", "support_notifier") + assert (go, stranger) not in full_calls + assert (go, stranger) not in inc_calls, \ + "a rebuild must not bind a contract-typed receiver to a same-named class" + + +def test_interface_binding_is_case_insensitive_incrementally(tmp_path: Path): + """PHP type names are case-insensitive on the incremental path too: names + are folded on both sides, never compared verbatim.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + contract = _find(full, ".notify()", "contracts_notifier") + assert (go, contract) in full_calls + assert (go, contract) in inc_calls + assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls + + +def test_class_typed_receiver_still_resolves_incrementally(tmp_path: Path): + """Positive control for the two tests above: binding stays name-scoped + across a rebuild — a CLASS-typed receiver still binds into its unchanged + file (#2437), and the decoys still get nothing.""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + "app/Audit/AuditTrail.php": ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + assert (go, _find(full, ".notify()", "mailnotifier")) in inc_calls, \ + "the incremental path must still resolve a class-typed receiver" + assert (go, _find(full, ".notify()", "audittrail")) not in inc_calls + assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls + + +def _sent(calls, caller: str) -> bool: + return any(src == caller and "send" in tgt.lower() for src, tgt in calls) + + +def test_vendor_import_shadowing_an_interface_refuses_on_both_builds(tmp_path: Path): + """A `use` of a same-short-named type from OUTSIDE the corpus must refuse — + on the incremental path as well as the full one (#16, #53). + + `use Illuminate\\Contracts\\Notifications\\Notifier;` CLAIMS the short name + for a vendor interface the corpus does not contain, so the in-corpus + `App\\Contracts\\Notifier` is a different type and must get no edge. That + verdict is `PhpNameResolver`'s (#21), and it is only decisive when the + declaration carries a declared FQN: without one the guard falls back to + comparing the node's PSR-4 PATH, and a replayed context node's path is + RELATIVIZED — fewer segments than the vendor FQN has — which trips the + "not enough evidence" bail-out and binds. The full build never sees that + (its paths are still absolute at resolver time), so full and incremental + must be asserted TOGETHER or the hole hides in the mode `graphify update` + actually runs in.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Contracts/Notifier.php": ( + "notifier->send('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + assert not _sent(full_calls, go), "full-build baseline must refuse the claim" + assert not _sent(inc_calls, go), \ + "a vendor `use` must not bind the in-corpus interface on a rebuild" + + +def test_vendor_import_shadowing_an_enum_refuses_on_both_builds(tmp_path: Path): + """The enum shape of the test above, same channel and same verdict.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + _INCR_RUNNER: ( + "status->label(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + assert not _labelled(full_calls, go), "full-build baseline must refuse the claim" + assert not _labelled(inc_calls, go), \ + "a vendor `use` must not bind the in-corpus enum on a rebuild" + + +def test_unique_interface_binding_survives_incremental_rebuild(tmp_path: Path): + """#53 criterion 4: the interface's file is unchanged and therefore NOT + dispatched — its declaration node and its `method` edge reach the resolver + only through the #2437 replay channel. The binding must be the same one the + full build makes, or every interface edge would evaporate on the first + `graphify update`.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_UNIQUE_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->send('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + go = _find(inc, ".go()", "dispatcher") + contract_send = _find(full, ".send()", "contracts_notifier") + assert (go, contract_send) in full_calls, "full-build baseline must bind" + assert (go, contract_send) in inc_calls, \ + "an undispatched interface file must keep its replayed binding" + assert (go, _find(full, ".send()", "auditlog")) not in inc_calls + + +# ENUM and TRAIT declarations replay over the same channel and get the same +# parity treatment (#12). Before #47 an unchanged `App\Enums\Status` file left +# `App\Legacy\Status` as the one visible definition on a rebuild — the wrong edge +# #12 had closed on the full-build path, coming straight back on the incremental +# one. Now it is the declared FQN that has to survive the replay for the two +# builds to agree; the assertions below pin both ends of that. + +_INCR_RUNNER = "app/Http/Runner.php" + + +def _incr_enum_corpus(body: str) -> dict[str, str]: + return { + **_ENUM_CORPUS, + _INCR_RUNNER: ( + "status->label(); }" + ), + changed=_INCR_RUNNER, + ) + + go = _find(inc, ".go()", "runner") + stranger = _find(full, ".label()", "legacy_status") + enum_label = _find(full, ".label()", "enums_status") + assert (go, enum_label) in full_calls, "full-build baseline binds the enum" + assert (go, enum_label) in inc_calls, \ + "an undispatched enum file must keep its replayed binding" + assert (go, stranger) not in inc_calls, \ + "a rebuild must not hand the edge to App\\Legacy\\Status" + + +def test_enum_typed_param_binding_agrees_across_an_incremental_rebuild(tmp_path: Path): + """The typed-parameter entry point keeps full/incremental parity too.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental( + tmp_path, + _incr_enum_corpus(" public function go(Status $s): void { $s->label(); }"), + changed=_INCR_RUNNER, + ) + + go = _find(inc, ".go()", "runner") + enum_label = _find(full, ".label()", "enums_status") + assert (go, enum_label) in full_calls + assert (go, enum_label) in inc_calls + assert (go, _find(full, ".label()", "legacy_status")) not in inc_calls + + +def test_trait_binding_agrees_across_an_incremental_rebuild(tmp_path: Path): + """A trait-typed receiver is broken PHP, but its verdict must not depend on + which files a rebuild happened to dispatch: the imported trait on both + builds, the same-short-named `App\\Legacy\\Cache` class on neither.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Support/Cache.php": ( + "cache->flush(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + trait_flush = _find(full, ".flush()", "support_cache") + assert (go, trait_flush) in full_calls + assert (go, trait_flush) in inc_calls + assert (go, _find(full, ".flush()", "legacy_cache")) not in inc_calls + + +def test_class_typed_receiver_still_resolves_incrementally_beside_an_enum(tmp_path: Path): + """Positive control for the three above: binding stays name-scoped on the + incremental path — a CLASS-typed receiver still binds into its unchanged + file, with an unrelated enum and a same-named-method decoy in the corpus.""" + (_, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "lead->label(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + assert (go, _find(full, ".label()", "models_lead")) in inc_calls, \ + "the incremental path must still resolve a class-typed receiver" + assert (go, _find(full, ".label()", "audittrail")) not in inc_calls + + +def test_unique_enum_binding_survives_incremental_rebuild(tmp_path: Path): + """#53 criterion 4 for the enum shape: the enum's file is unchanged, so its + declaration node and its `method` edge reach the resolver only through the + #2437 replay channel — and the binding must match the full build's.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "app/Enums/Status.php": _ENUM_CORPUS["app/Enums/Status.php"], + "app/Models/Lead.php": ( + "status->label(); }\n" + "}\n" + ), + }, changed=_INCR_RUNNER) + + go = _find(inc, ".go()", "runner") + enum_label = _find(full, ".label()", "enums_status") + assert (go, enum_label) in full_calls, "full-build baseline must bind" + assert (go, enum_label) in inc_calls, \ + "an undispatched enum file must keep its replayed binding" + assert (go, _find(full, ".label()", "models_lead")) not in inc_calls + + +def test_legacy_php_interfaces_marker_spelling_is_still_read(tmp_path: Path): + """Cache compatibility: a graph.json written before #12 carries the names + under `_php_interfaces`, and both spellings must keep being read back off + the resolution context — the rename must not silently drop a channel that + older graphs are still using. + + #53 lifted the RECEIVER REFUSAL these names used to feed, so what is pinned + here is the channel itself: `_php_context_interface_entry` recovering the + unchanged corpus's declared names under either spelling. Resolution now runs + off a different marker on the same nodes — `_php_class_fqns` (#23) — which + is why the downgraded context still binds the imported contract and still + leaves `App\\Support\\Notifier` alone.""" + (_, full), _ = _full_then_incremental(tmp_path, { + **_IFACE_CORPUS, + _INCR_DISPATCHER: ( + "notifier->notify('x'); }\n" + "}\n" + ), + }, changed=_INCR_DISPATCHER) + + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(_IFACE_CORPUS) + ) + downgraded = 0 + for node in ctx_nodes: + names = node.pop("_php_non_class_types", None) + if names: + node["_php_interfaces"] = names # the pre-#12 spelling + downgraded += 1 + assert downgraded == 1, "exactly the interface's file node carries the names" + assert _php_context_interface_entry(ctx_nodes) == { + "php_non_class_types": ["Notifier"] + }, "the pre-#12 spelling must still be recovered off the context nodes" + + caller = tmp_path / "corpus" / _INCR_DISPATCHER + inc = extract([caller], cache_root=tmp_path / "corpus", + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges) + inc_calls = { + (edge["source"], edge["target"]) + for edge in inc["edges"] if edge.get("relation") == "calls" + } + + go = _find(inc, ".go()", "dispatcher") + assert (go, _find(full, ".notify()", "support_notifier")) not in inc_calls + assert (go, _find(full, ".notify()", "contracts_notifier")) in inc_calls + + +# ── Same-file union / intersection receivers (user story 11, #9) ────────────── +# +# The separate-file negatives above pass for the wrong reason: with the decoys in +# other files, the extractor's LEGACY in-file bare-name arm never runs, so only +# the cross-file resolver is exercised. When the candidate classes live in the +# SAME file as the call, that arm fires and binds the receiver to whichever +# same-named method the label index saw last — pure file order, stamped +# EXTRACTED. A union or intersection annotation proves the receiver has MORE +# THAN ONE possible class, so the extractor must defer to the resolver (which +# refuses an unstamped receiver) instead. +# +# Deletion scope is deliberately limited to `A|B` / `A&B`. The concrete-type +# policy also refuses `self`/`static`/`parent`, primitives and +# `mixed`/`object`/`iterable`/`callable`, but none of those declares MULTIPLE +# candidate classes — `self` in particular makes the in-file match likely +# correct — so they keep today's edge, exactly like a genuinely untyped receiver. + +def _same_file(receiver_decl: str, *, second_class: str = "", call: str = "$this->svc") -> str: + """One PHP file: candidate class(es) plus a Ctrl whose property is `$svc`.""" + return ( + "run();\n" + " }\n" + "}\n" + ) + + +_BETA = "class Beta { public function run(): int { return 2; } }\n" + + +def _ran(calls, go: str) -> list[str]: + """Every ``calls`` target of ``go``. Each fixture below makes exactly one call + (``->run()``), so the whole target list doubles as the assertion.""" + return sorted(tgt for src, tgt in calls if src == go) + + +def test_same_file_union_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha|Beta $svc` with BOTH candidates in the call's own file.""" + calls, r = _calls(tmp_path, { + "app/U.php": _same_file("private Alpha|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [], \ + "a union-typed receiver has no single class — the in-file bare-name " \ + "match would bind it to one of them by file order" + + +def test_same_file_intersection_typed_property_emits_no_edge(tmp_path: Path): + """`Alpha&Beta $svc`: an intersection is named by user story 11 too, and had + no test at all before this ticket.""" + calls, r = _calls(tmp_path, { + "app/I.php": _same_file("private Alpha&Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/UP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_intersection_typed_param_emits_no_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/IP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_union_typed_promoted_param_emits_no_edge(tmp_path: Path): + """A promoted constructor param is a typed property, reached by the same + `this.` key — the refusal must travel that channel too.""" + calls, r = _calls(tmp_path, { + "app/UPP.php": ( + "svc->run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + +def test_same_file_untyped_property_keeps_its_edge(tmp_path: Path): + """Regression guard for #2's accepted deviation (user story 9): a receiver + with NO annotation keeps today's same-file bare-name edge. Only an + annotation that was PRESENT and refused as multi-typed defers.""" + calls, r = _calls(tmp_path, { + "app/N.php": _same_file("protected $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_untyped_param_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + "app/NP.php": ( + "run();\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_this_call_keeps_its_edge(tmp_path: Path): + """Regression guard (user story 9): `$this->method()` never carries a + receiver type and must stay on the in-file arm.""" + calls, r = _calls(tmp_path, { + "app/T.php": ( + "run(); }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "ctrl")] + + +def test_same_file_self_typed_property_keeps_its_edge(tmp_path: Path): + """Deletion-scope boundary: `self` is refused by the concrete-type policy but + declares no multiplicity, so it is NOT deferred and keeps today's edge.""" + calls, r = _calls(tmp_path, { + "app/S.php": _same_file("protected self $svc;"), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [_find(r, ".run()", "alpha")] + + +def test_same_file_dnf_typed_property_emits_no_edge_and_references_its_types(tmp_path: Path): + """PHP 8.2 disjunctive normal form (`(A&B)|C`) parses as its own node type, + which the property scanner's type-node list did not name — so a DNF property + reached neither the receiver table (it kept minting the bare-name edge this + ticket removes) nor the type-reference walk (its classes went unreferenced). + It is a union at top level, so it refuses like one, and references like one.""" + calls, r = _calls(tmp_path, { + "app/D.php": _same_file("private (Alpha&Beta)|Beta $svc;", second_class=_BETA), + }) + + go = _find(r, ".go()", "ctrl") + assert _ran(calls, go) == [] + + refs = { + (edge["source"], edge["target"]) + for edge in r["edges"] if edge.get("relation") == "references" + } + ctrl = _find(r, "Ctrl", "d_ctrl") + assert (ctrl, _find(r, "Alpha", "d_alpha")) in refs + assert (ctrl, _find(r, "Beta", "d_beta")) in refs diff --git a/tests/test_php_name_resolver.py b/tests/test_php_name_resolver.py new file mode 100644 index 000000000..4e56a2af4 --- /dev/null +++ b/tests/test_php_name_resolver.py @@ -0,0 +1,400 @@ +"""PHP `use`-import-aware receiver typing: the decisive refusal (#21, closes #16). + +`_resolve_php_member_calls` used to bind a receiver's short type name through a +corpus-wide index whose only refusal rule was "more than one candidate". A file +that writes `use Vendor\\Sdk\\Client;` has CLAIMED the name `Client` for a class +that is not in the corpus at all — but the resolver never saw `use` statements, +so the lone unrelated `App\\Local\\Client` satisfied the single-definition guard +and minted a wrong `INFERRED 0.8` edge. + +`PhpNameResolver` (mirroring `CsharpNameResolver`) answers with a +`(node_id, decisive)` verdict and is consulted IN FRONT of that fallback: a +claimed name that does not land on an in-corpus class refuses instead of falling +back. The refusal is strictly subtractive; its additive counterpart — binding +the claimed FQN to the class whose file declares exactly that name (#22) — is +covered by `test_php_alias_binding.py`, and +`test_alias_renaming_to_an_unclaimed_short_name_binds_since_22` below pins the +boundary from this side. + +Every test goes through the public `extract()` seam, and every positive case +carries a decoy class with an identically named method that must get no edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +# The canonical #16 fixture (spec #18 carries the same copy) plus a decoy that +# declares an identically named method: only the receiver's type tells them +# apart, so a bare-name match would light the decoy up. +_CLIENT = ( + " str: + return ( + "c->send(); }\n" + "}\n" + ) + + +def _sends(calls, caller: str) -> list[str]: + return [tgt for src, tgt in calls if src == caller] + + +# ── the refusal: a claimed name that names no in-corpus class ───────────────── + + +def test_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """#16, cross-file: `use Vendor\\Sdk\\Client;` claims `Client` for a class + this corpus does not contain. The lone `App\\Local\\Client` is a stranger.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], "an out-of-corpus `use` target must refuse" + + +def test_use_alias_outside_corpus_emits_no_edge_same_file(tmp_path: Path): + """#16, same file: the stranger is declared in another namespace block of + the calling file, so the same-file matcher could mint the edge too.""" + calls, r = _calls(tmp_path, { + "app/Mixed/Both.php": ( + "c->send(); }\n" + " }\n" + "}\n" + ), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [], \ + "a same-file stranger must be refused like a cross-file one" + + +def test_group_use_alias_outside_corpus_emits_no_edge(tmp_path: Path): + """The group form carries its FQN on the declaration's prefix (#19); the + claim it makes is the same one.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\{Client};\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_renaming_alias_over_an_out_of_corpus_target_emits_no_edge(tmp_path: Path): + """`use X as Client;` claims the short name `Client` just as firmly.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use Vendor\\Sdk\\Handler as Client;\n"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_written_fqn_outside_corpus_emits_no_edge(tmp_path: Path): + """The compounding half of #16: an annotation that names the out-of-corpus + class outright, with no `use` statement at all (#20 kept the written form).""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\Vendor\\Sdk\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +def test_namespace_relative_annotation_outside_corpus_emits_no_edge(tmp_path: Path): + """A written qualified name with no leading backslash is RELATIVE: inside + `App\\Http`, `Local\\Client` means `App\\Http\\Local\\Client`, which exists + nowhere — not `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert _sends(calls, go) == [] + + +# ── the guards: everything the refusal must NOT touch ───────────────────────── + + +def test_in_corpus_use_alias_keeps_its_edge(tmp_path: Path): + """The positive guard: a `use` that names the real in-corpus class binds + exactly as it does today (through the unique-short-name fallback), and the + decoy that merely shares the method name still gets nothing.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client;\n"), + }) + + go = _find(r, ".go()", "_go") + send = _find(r, ".send()", "client") + assert (go, send) in calls + assert calls[(go, send)]["confidence"] == "INFERRED" + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_written_fqn_naming_the_in_corpus_class_keeps_its_edge(tmp_path: Path): + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(annotation="\\App\\Local\\Client"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_namespace_relative_annotation_in_corpus_keeps_its_edge(tmp_path: Path): + """Inside namespace `App`, `Local\\Client` IS `App\\Local\\Client`.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/I.php": _caller(annotation="Local\\Client", namespace="App"), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_use_function_import_makes_no_class_claim(tmp_path: Path): + """`use function` / `use const` import no class name (#26), in either + spelling — so they claim nothing and the fallback runs untouched.""" + for index, uses in enumerate(( + "use function Vendor\\Sdk\\Client;\n", + "use function Vendor\\Sdk\\{Client};\n", + "use const Vendor\\Sdk\\Client;\n", + )): + calls, r = _calls(tmp_path / f"case{index}", { + **_CORPUS, "app/Http/I.php": _caller(uses), + }) + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls, uses + assert (go, _find(r, ".send()", "recorder")) not in calls, uses + + +def test_unclaimed_short_name_still_falls_back(tmp_path: Path): + """No `use`, no qualified form: the resolver knows nothing about the name + and the corpus-wide fallback decides, exactly as before.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller(), + }) + + go = _find(r, ".go()", "_go") + assert (go, _find(r, ".send()", "client")) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +def test_alias_renaming_to_an_unclaimed_short_name_binds_since_22(tmp_path: Path): + """The #21/#22 boundary, from the #21 side. `use App\\Local\\Client as Api;` + names an in-corpus class under a WRITTEN short name (`Api`) nothing in the + corpus is called, so #21's fallback found nothing and refused. The declared- + FQN index (#22) follows the alias to its target — the recall win this test + used to pin as out of scope.""" + calls, r = _calls(tmp_path, { + **_CORPUS, + "app/Http/I.php": _caller("use App\\Local\\Client as Api;\n", annotation="Api"), + }) + + go = _find(r, ".go()", "_go") + send = _find(r, ".send()", "client") + assert (go, send) in calls + assert (go, _find(r, ".send()", "recorder")) not in calls + + +# ── the same verdicts across an incremental rebuild ─────────────────────────── +# +# The `use` map belongs to the CALLING file, which `graphify update`/watch always +# re-dispatch, so the refusal needs no persisted marker (spec #18, decision 3). +# The context below is assembled exactly as `test_php_member_calls.py` mirrors +# watch.py: a field subset of the persisted nodes plus contains/method edges, +# both scoped to the files that are NOT re-extracted. + +_CTX_NODE_FIELDS = ("label", "source_file", "file_type", "type") +_CTX_MARKERS = ("_callable", "_callable_class", "_php_non_class_types", + "_php_interfaces", "_php_class_fqns") + + +def _watch_resolution_context(result: dict, unchanged: set[str]): + nodes = [] + for node in result["nodes"]: + if not node.get("id") or node.get("source_file") not in unchanged: + continue + ctx = {"id": node["id"]} + ctx.update({field: node.get(field) for field in _CTX_NODE_FIELDS}) + ctx.update({m: node[m] for m in _CTX_MARKERS if node.get(m)}) + nodes.append(ctx) + edges = [ + { + "source": edge.get("source"), + "target": edge.get("target"), + "relation": edge.get("relation"), + "source_file": edge.get("source_file"), + } + for edge in result["edges"] + if edge.get("relation") in ("contains", "method") + and edge.get("source_file") in unchanged + ] + return nodes, edges + + +def _full_then_incremental(tmp_path: Path, files: dict[str, str], changed: str): + corpus = tmp_path / "corpus" + paths = {} + for name, body in files.items(): + path = corpus / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths[name] = path + + def _calls_of(result): + return { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + + full = extract(list(paths.values()), cache_root=corpus) + paths[changed].write_text( + files[changed].replace("class ", "// touched\nclass ", 1), encoding="utf-8" + ) + ctx_nodes, ctx_edges = _watch_resolution_context( + full, unchanged=set(files) - {changed} + ) + inc = extract( + [paths[changed]], + cache_root=corpus, + resolution_context_nodes=ctx_nodes, + resolution_context_edges=ctx_edges, + ) + return (_calls_of(full), full), (_calls_of(inc), inc) + + +_INCR_CALLER = "app/Http/I.php" + + +def test_alias_refusal_survives_incremental_rebuild(tmp_path: Path): + """`App\\Local\\Client` is unchanged and therefore not dispatched; the claim + lives in the caller, which is, so the refusal must fire either way.""" + (full_calls, _), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use Vendor\\Sdk\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + assert _sends(full_calls, go) == [], "full-build baseline must refuse" + assert _sends(inc_calls, go) == [], \ + "an undispatched defining file must not resurrect the false edge" + + +def test_in_corpus_alias_still_resolves_incrementally(tmp_path: Path): + """Positive control for the test above: the defining file's declared FQN + now rides the persisted `_php_class_fqns` marker (#23), so the binding holds + across the rebuild — decoy still empty. The pre-#23 path, where the marker + is absent and the class is corroborated against its PSR-4 path instead, is + pinned in `test_php_alias_binding.py`.""" + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + **_CORPUS, + _INCR_CALLER: _caller("use App\\Local\\Client;\n"), + }, changed=_INCR_CALLER) + + go = _find(inc, ".go()", "_go") + # The target lives in an unchanged file, so its id comes from the full run. + send = _find(full, ".send()", "client") + assert (go, send) in full_calls + assert (go, send) in inc_calls, \ + "a rebuild must keep an edge whose `use` names the in-corpus class" + assert (go, _find(full, ".send()", "recorder")) not in inc_calls + + +def test_non_psr4_layout_keeps_its_edge_incrementally(tmp_path: Path): + """Composer maps a namespace PREFIX onto a directory (`App\\Weird\\` -> + `src/`), so `App\\Weird\\Odd` legitimately lives at `src/Odd.php`. The full + run corroborates the `use` against the namespace that file DECLARES, and + since #23 the incremental run reads the same declaration off the persisted + marker. `test_php_alias_binding.py` pins the pre-marker variant, where the + shorter path must not read as a contradiction — a stripped prefix looks + exactly like one.""" + caller = ( + "o->ping(); }\n" + "}\n" + ) + (full_calls, full), (inc_calls, inc) = _full_then_incremental(tmp_path, { + "src/Odd.php": ( + " Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _facts(tmp_path: Path, body: str) -> list[dict]: + """Raw-call facts of one PHP file (the callee is never defined in it, so + every member call stays unresolved and reaches `raw_calls`).""" + path = _write(tmp_path / "app/Http/Controllers/LeadController.php", body) + return extract_php(path).get("raw_calls", []) + + +def _fact(facts: list[dict], receiver: str) -> dict: + matches = [f for f in facts if f.get("receiver") == receiver] + assert len(matches) == 1, facts + return matches[0] + + +def _class(members: str) -> str: + return ( + "c->send(); }\n" + )) + + fact = _fact(facts, "this.c") + assert fact["callee"] == "send" + assert fact["receiver_type"] == "Client" + assert fact["receiver_type_qualified"] == "\\Vendor\\Sdk\\Client" + + +def test_qualified_promoted_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function __construct(private \\App\\Services\\LeadHunterService $svc) {}\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_param_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(\\App\\Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_qualified_local_new_stamps_short_and_qualified(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_nullable_qualified_property_stamps_qualified(tmp_path: Path): + """`?\\A\\B` unwraps to one concrete type — the qualified form must survive + the unwrap, not just the short name.""" + facts = _facts(tmp_path, _class( + " private ?\\App\\Services\\LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "\\App\\Services\\LeadHunterService" + + +def test_namespace_relative_annotation_stamps_written_form(tmp_path: Path): + """`Services\\X` is qualified but RELATIVE to the current namespace. The + extractor stamps what was written; resolving it is the resolver's job.""" + facts = _facts(tmp_path, _class( + " public function handle(Services\\LeadHunterService $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact["receiver_type_qualified"] == "Services\\LeadHunterService" + + +# ── fact shape: unqualified annotations are unchanged ───────────────────────── + + +def test_unqualified_property_stamps_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " private LeadHunterService $svc;\n" + " public function index(): array { return $this->svc->search([]); }\n" + )) + + fact = _fact(facts, "this.svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +def test_unqualified_param_and_local_stamp_no_qualified_field(tmp_path: Path): + facts = _facts(tmp_path, _class( + " public function handle(LeadHunterService $svc): array {\n" + " $other = new AuditLog();\n" + " return $svc->search([]) + $other->search([]);\n" + " }\n" + )) + + param = _fact(facts, "svc") + assert param["receiver_type"] == "LeadHunterService" + assert param.get("receiver_type_qualified") is None + local = _fact(facts, "other") + assert local["receiver_type"] == "AuditLog" + assert local.get("receiver_type_qualified") is None + + +def test_union_typed_receiver_stamps_neither_field(tmp_path: Path): + """A multi-class annotation is a refusal (#9): it stamps no type, and the + qualified field must not resurrect one.""" + facts = _facts(tmp_path, _class( + " public function handle(\\App\\A|\\App\\B $svc): array {\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert "receiver_type" not in fact + assert "receiver_type_qualified" not in fact + + +def test_same_class_written_two_ways_keeps_short_name(tmp_path: Path): + """Two `new`s naming the same SHORT name through different written forms + used to bind that short name, and still do — the qualified evidence is + contradictory, so it is dropped rather than poisoning the binding.""" + facts = _facts(tmp_path, _class( + " public function index(): array {\n" + " $svc = new LeadHunterService();\n" + " $svc = new \\App\\Services\\LeadHunterService();\n" + " return $svc->search([]);\n" + " }\n" + )) + + fact = _fact(facts, "svc") + assert fact["receiver_type"] == "LeadHunterService" + assert fact.get("receiver_type_qualified") is None + + +# ── resolution parity through the public extract() seam ─────────────────────── + +_SERVICE = ( + " str: + return next( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ) + + +def test_qualified_annotation_still_resolves_by_short_name(tmp_path: Path): + """Parity: threading the qualified form changes no edge. The written FQN + names the in-corpus class here, and the resolution is exactly today's — + INFERRED 0.85 off the short name, decoy untouched.""" + calls, r = _extract(tmp_path, { + "app/Services/LeadHunterService.php": _SERVICE, + "app/Audit/AuditLog.php": _DECOY, + "app/Http/Controllers/LeadController.php": ( + "svc->search([]); }\n" + "}\n" + ), + }) + + index = _find(r, ".index()", "leadcontroller") + service_search = _find(r, ".search()", "leadhunterservice") + assert (index, service_search) in calls + assert (index, _find(r, ".search()", "auditlog")) not in calls + edge = calls[(index, service_search)] + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 diff --git a/tests/test_php_relative_scope_calls.py b/tests/test_php_relative_scope_calls.py new file mode 100644 index 000000000..eafc51114 --- /dev/null +++ b/tests/test_php_relative_scope_calls.py @@ -0,0 +1,124 @@ +"""PHP relative-scope calls must not name a callee (#39). + +``parent::setUp()`` / ``self::foo()`` / ``static::bar()`` are *relative* scopes: +which class they denote depends on inheritance context the raw-call facts do not +carry. The ``scoped_call_expression`` handler used to take the scope text as the +callee name, so ``parent::setUp()`` produced a raw call to a callee literally +named ``parent`` — which the cross-file pass then matched, by normalized label, +against any unrelated ``->parent()`` method in the corpus. + +Refusal over guessing: the relative scopes never reach the bare-name pass. +Since the 0.9.92 sync every scoped call is a member call (callee = the method, +receiver = the scope), and `_resolve_php_member_calls` binds ``self``/``static`` +to the enclosing class and ``parent`` to its single base, or to nothing. +Absolute scopes (``Helper::format()``) bind to the named class's method. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + """Extract ``files`` (name -> source) and return ({(src, tgt): edge}, result).""" + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + calls = { + (edge["source"], edge["target"]): edge + for edge in result["edges"] + if edge.get("relation") == "calls" + } + return calls, result + + +def _find(result: dict, label: str, id_contains: str) -> str: + nid = next( + ( + node["id"] + for node in result["nodes"] + if node.get("label") == label and id_contains in node["id"] + ), + None, + ) + assert nid is not None, f"no node {label}/{id_contains}" + return nid + + +def _relative_scope_corpus(scope: str, callee: str) -> dict[str, str]: + """A caller using ``::()`` plus an unrelated decoy method. + + The decoy is named after the SCOPE (``parent`` / ``self`` / ``static``) — + that is the label the buggy callee name matched against corpus-wide. + """ + return { + "app/A.php": ( + " Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _import_facts(result: dict) -> set[tuple[str | None, str | None, str | None]]: + """(target_fqn, alias, use_kind) for every `imports` edge in the graph.""" + facts = set() + for e in result["edges"]: + if e.get("relation") != "imports": + continue + md = e.get("metadata") or {} + facts.add((md.get("target_fqn"), md.get("alias"), md.get("use_kind"))) + return facts + + +def _labels(result: dict) -> set[str]: + return {n.get("label") for n in result["nodes"]} + + +def test_php_plain_use_captures_target_fqn(tmp_path: Path): + f = _write( + tmp_path / "app/Http/I.php", + " dict: + """A sourceless stub, id-sorted *ahead* of the sourced node (the repro shape). + + `omit_source_key` reproduces the attributeless node serve materializes for a + dangling edge endpoint (no `source_file` key at all, not an empty one). + """ + node = {"id": f"aaa_stub{index}", "label": LABEL, "community": 0} + if not omit_source_key: + node["source_file"] = "" + return node + + +def _sourced() -> dict: + return {"id": SOURCED_ID, "label": LABEL, "source_file": SOURCE_FILE, + "source_location": "L10", "community": 0} + + +def _load(nodes: list[dict], links: list[dict] | None = None): + return json_graph.node_link_graph( + {"directed": True, "multigraph": False, "graph": {}, + "nodes": nodes, "links": links or []}, + edges="links", + ) + + +def _endpoint(G, query: str = LABEL) -> str: + """Resolve `query` exactly as `shortest_path` resolves each of its endpoints.""" + scored = _score_nodes(G, [t.lower() for t in query.split()]) + assert scored, "precondition: the query must match something" + return _pick_scored_endpoint(G, scored, query) + + +# --- criterion 1: the reviewer's two-node repro ------------------------------ + + +def test_scored_endpoint_prefers_the_sourced_node(): + """Both nodes score 5619.08; the id sort used to hand back the stub.""" + G = _load([_stub(), _sourced()]) + scored = _score_nodes(G, ["foorepository"]) + assert {nid for _s, nid in scored} == {"aaa_stub0", SOURCED_ID} + assert scored[0][0] == pytest.approx(scored[1][0]), "the repro is a score tie" + assert _pick_scored_endpoint(G, scored, LABEL) == SOURCED_ID + + +@pytest.mark.parametrize("stub_count", [1, 2, 3, 18]) +def test_scored_endpoint_prefers_the_sourced_node_at_any_stub_count(stub_count): + """The rule is a presence test on `source_file`, never a count threshold.""" + G = _load([_stub(i) for i in range(stub_count)] + [_sourced()]) + assert _endpoint(G) == SOURCED_ID + + +def test_scored_endpoint_answer_does_not_depend_on_node_order(): + stubs = [_stub(i) for i in range(3)] + forward = _load(stubs + [_sourced()]) + reverse = _load([_sourced()] + list(reversed(stubs))) + assert _endpoint(forward) == _endpoint(reverse) == SOURCED_ID + + +def test_attributeless_dangling_endpoints_are_also_stubs(): + """A dangling edge endpoint has no `source_file` key at all — same rule.""" + G = _load([_stub(i, omit_source_key=True) for i in range(3)] + [_sourced()]) + assert _endpoint(G) == SOURCED_ID + + +# --- criterion 2: a stub with no sourced rival is still an answer ------------ + + +@pytest.mark.parametrize("omit_source_key", [False, True]) +def test_lone_stub_with_no_sourced_rival_still_resolves(omit_source_key): + """#49's carve-out: demoted against a real declaration, never deleted.""" + stub = _stub(0, omit_source_key=omit_source_key) + G = _load([stub]) + assert _endpoint(G) == stub["id"] + + +@pytest.mark.parametrize("stub_count", [2, 18]) +def test_all_sourceless_candidates_still_return_one(stub_count): + """Never return nothing where today something returns.""" + G = _load([_stub(i) for i in range(stub_count)]) + assert _endpoint(G) in {f"aaa_stub{i}" for i in range(stub_count)} + + +def test_query_over_only_stubs_still_traverses_them(): + G = _load( + [_stub(0), {"id": "beta", "label": "BetaService", "source_file": "app/Beta.php", + "community": 0}], + [{"source": "aaa_stub0", "target": "beta", "context": "call"}], + ) + out = _query_graph_text(G, LABEL) + assert "No matching nodes found." not in out + assert "BetaService" in out + + +# --- criterion 3: query / shortest_path agree with explain's _find_node ------ + + +@pytest.mark.parametrize("stub_count", [1, 2, 18]) +def test_scored_path_agrees_with_find_node_and_resolve_seed(stub_count): + G = _load([_stub(i) for i in range(stub_count)] + [_sourced()]) + assert _endpoint(G) == _find_node(G, LABEL)[0] == resolve_seed(G, LABEL) == SOURCED_ID + + +def _repro_graph_with_neighbors(): + """The repro pair, each node in its own component. + + `AlphaService` hangs off the sourced declaration, `BetaService` off the stub, + so which endpoint got picked is visible in the traversal output and in + whether `shortest_path` finds a path at all. Both edges point away from the + `FooRepository` node so the directed defaults of `query` and `shortest_path` + reach the neighbor. The two rivals necessarily share a label, so the endpoint + is identified by which neighbor the answer reaches, not by name. + """ + return _load( + [ + _stub(), + _sourced(), + {"id": "alpha", "label": "AlphaService", "source_file": "app/Alpha.php", + "source_location": "L4", "community": 0}, + {"id": "beta", "label": "BetaService", "source_file": "app/Beta.php", + "source_location": "L4", "community": 0}, + ], + [ + {"source": SOURCED_ID, "target": "alpha", "context": "call"}, + {"source": "aaa_stub0", "target": "beta", "context": "call"}, + ], + ) + + +def test_shortest_path_anchors_on_the_sourced_node(): + """Anchored on the stub, this query was a false "No path found".""" + G = _repro_graph_with_neighbors() + out = _shortest_path_text(G, {"source": LABEL, "target": "AlphaService"}) + assert "No path" not in out and "No directed path" not in out + assert "FooRepository --related--> AlphaService" in out + + +def test_query_traverses_the_sourced_nodes_neighborhood(): + G = _repro_graph_with_neighbors() + out = _query_graph_text(G, LABEL) + assert "AlphaService" in out + assert "BetaService" not in out + + +# --- the per-term seed winner needs the rule too ---------------------------- + + +def _decorated_pair(): + """A stub whose label normalizes *differently* from its sourced rival's. + + `_pick_seeds` dedupes seeds by normalized label, so on the same-label repro + above the stub's per-term entry is dropped before it can be seeded and the + combined-ranking half of the fix carries the whole outcome. A decorated + declaration breaks that cover: `handle` and `handle()` are distinct dedup + keys, so the stub keeps its own seat and `best_seed_by_term`'s own tie-break + is the only thing deciding which node fills it (the #49 suite pins the same + pair for `resolve_seed`'s bare-name pass). + """ + return _load([ + {"id": "aaa_stub_handle", "label": "handle", "source_file": "", "community": 0}, + {"id": "zzz_app_svc_handle", "label": "handle()", + "source_file": "app/Svc.php", "source_location": "L9", "community": 0}, + ]) + + +def test_per_term_seed_winner_prefers_the_sourced_node(): + """Pins the `best_seed_by_term` half of the fix, which the same-label repro + cannot reach — goes red if the singleton tie-break key drops its sourced + preference.""" + G = _decorated_pair() + qs = _score_query(G, ["handle"], collect_per_term_seeds=True) + assert qs.best_seed_by_term == {"handle": "zzz_app_svc_handle"} + + +def test_query_ranks_the_sourced_node_first_for_a_decorated_declaration(): + """The same pair through the query pipeline. Both labels survive the seed + dedupe here, so both get seeded — what the rule decides is the order, and the + top seed is what `_pick_seeds`' coverage check and the seed-first rendering + both key off. + """ + G = _decorated_pair() + out = _query_graph_text(G, "handle") + assert "Start: ['handle()', 'handle']" in out + assert out.index("NODE handle() [src=app/Svc.php") < out.index("NODE handle [src=") + + +# --- the pre-existing sourced-vs-sourced tie is untouched ------------------- + + +def test_two_sourced_rivals_keep_their_existing_tie_break(): + """Sourced-vs-sourced (#2032's symbol case) still resolves by the id sort.""" + G = _load([ + {"id": "chat_port", "label": "MetricsPort", + "source_file": "services/chat/ports/metrics.port.ts", "community": 0}, + {"id": "scrape_port", "label": "MetricsPort", + "source_file": "services/scraping/ports/metrics.port.ts", "community": 0}, + ]) + assert _endpoint(G, "MetricsPort") == "chat_port" diff --git a/tests/test_serve_seeding.py b/tests/test_serve_seeding.py new file mode 100644 index 000000000..aeb4d159d --- /dev/null +++ b/tests/test_serve_seeding.py @@ -0,0 +1,477 @@ +"""Seeding hygiene for natural-language impact questions (fork issue #37). + +C1 — relational-intent demotion: a word that names the *relation* being asked +about ("calls", "callers", "uses") no longer buys an unconditional seed through +the per-term guarantee (#1445). It still scores, still competes for seeds on +merit, and is never stopworded. + +Assertions read external behavior only — the `Start:` header, the rendered NODE +lines, and the seed lists the existing seeding tests already assert on — never +the vocabulary set itself, so tuning the vocabulary can't break these tests. +""" +from graphify.serve import _pick_seeds, _query_graph_text, _query_terms, _score_query + +from tests.seeding_fixtures import ( + CALLERS_DECOY, + CALLS_SYMBOL, + DECOY, + SERVICE, + USES_DECOY, + caller_labels, + label_of, + make_charge_fixture, + shown_nodes, + start_labels, +) + + +def test_who_calls_phrasing_does_not_seed_verb_prefix_decoy(): + """"Who calls ChargeCustomerService?" must not seed `callStoreWithAmount()`. + + The decoy loses everywhere on merit (it scores ~9x below the gap-window + cutoff); it only ever entered the seed list because "calls" held an + unconditional per-term seat. Seed-level only: the `call` context filter this + phrasing infers strands the class seed, which is #42's problem, not this one. + """ + G = make_charge_fixture() + seeds = start_labels( + _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + ) + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds, f"verb-prefix decoy still seeded: {seeds}" + + +def test_callers_of_phrasing_renders_all_callers_and_drops_junk_seed(): + """"callers of X" — the agent-noun phrasing — must render all three known + callers with no junk seed and no junk neighborhood in the shown output. + + Upstream 0.9.35 added "caller"/"callers" to `_CONTEXT_HINTS`' `call` entry + (adopted in the 0.9.37 sync), so this phrasing now infers the same `call` + filter "Who calls X?" does, and therefore inherits the same stranding on a + class seed — where before that hint entry it traversed unfiltered. The + answer is unchanged because C3 relaxes it back; what is new is the header's + relaxation note, pinned below so the adopted hint cannot silently regress + into the near-empty answer it causes without C3. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "callers of ChargeCustomerService", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert label_of(SERVICE) in seeds + assert label_of(CALLERS_DECOY) not in seeds, f"junk test-method seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + # The junk seed's whole neighborhood is what used to eat the budget. + assert label_of(DECOY) not in shown + assert label_of(CALLERS_DECOY) not in shown + context = context_segment(text) + assert "call (heuristic" in context, f"the adopted hint did not fire: {context!r}" + assert "relaxed" in context, f"header does not report the relaxation: {context!r}" + + +def test_all_relational_query_keeps_its_per_term_guarantee(): + """A query made only of relational words keeps the guarantee (mirroring the + all-stopword fallback in `_query_terms`): demoting every term would leave + nothing to guarantee, so the winner map is kept unfiltered. + + "uses"' winner scores ~25x below the gap-window cutoff here, so it is seeded + only if the guarantee survives — which is what makes this test load-bearing + rather than decorative. + """ + G = make_charge_fixture() + seeds = start_labels(_query_graph_text(G, "calls uses", mode="bfs", depth=2)) + assert label_of(DECOY) in seeds + assert label_of(USES_DECOY) in seeds, ( + f"all-relational query lost its per-term guarantee: {seeds}" + ) + + +def test_relational_word_with_exact_match_still_seeds_on_merit(): + """Demotion is not stopwording: a corpus symbol literally labelled `calls` + keeps its exact-match dominance and is seeded through the ordinary gap + window, while the unrelated verb-prefix decoy stays out.""" + G = make_charge_fixture(calls_symbol=True) + seeds = start_labels( + _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + ) + assert label_of(CALLS_SYMBOL) in seeds, ( + f"exact-match `calls` symbol lost its seed: {seeds}" + ) + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds + + +def test_scorer_and_picker_are_unchanged_for_direct_callers(): + """C1 is wired from the query pipeline only. Callers that drive the scorer + and the seed picker directly — `path`, `explain`, the legacy-equality + property tests, the benchmark's two arms — must still see the relational + term score and still receive its guaranteed seed.""" + G = make_charge_fixture() + terms = _query_terms("Who calls ChargeCustomerService?") + qs = _score_query(G, terms, collect_per_term_seeds=True) + + assert qs.best_seed_by_term.get("calls") == DECOY, ( + "scorer stopped recording the relational term's per-term winner" + ) + seeds = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + assert DECOY in seeds, "demotion leaked into _pick_seeds' semantics" + + +# ===== C2 — covered-term guarantee skip (#41) ================================ +# +# A query term that an already-picked seed's normalized label plainly matches — +# the scorer's own weakest tier, so "this seed would have matched the term in +# scoring" — is not *starved*, and starvation is the only thing the #1445 +# guarantee exists to prevent. It therefore claims no additional seed. The +# refined invariant: every term with any match is matched by at least one seed +# (previously: every term's own singleton winner gets a slot). +# +# Assertions here read external behavior only: the `Start:` header, the rendered +# NODE lines, the header's node count, and the seed lists the prior seeding +# tests already assert on. + +import networkx as nx # noqa: E402 + +from graphify.serve import _bfs, _demote_relational_intent_terms # noqa: E402 + +from tests.seeding_fixtures import DOC, HUB # noqa: E402 + +_GENERIC_NOUN_QUESTION = "what code uses ChargeCustomerService to charge a customer" +# The hub's 12 members sit one `references` hop past the service, so they enter a +# depth-2 traversal only when the hub itself is seeded. Depth 2 is also the depth +# the spec's measurements were taken at. +_GENERIC_NOUN_DEPTH = 2 +# Terms that exercise both sides of the refined invariant on this fixture: +# "customer" is covered by the `ChargeCustomerService` seed's label, "code" is +# covered by no seed at all (its winner is the `coder.md` prefix decoy — the +# documented residual, and here the load-bearing proof that recovery survives). +_COVERED_AND_STARVED_QUESTION = "ChargeCustomerService customer code" + + +def _nodes_found(text: str) -> int: + """The `N nodes found` count from a `_query_graph_text` header.""" + for part in text.split("\n", 1)[0].split(" | "): + if part.endswith(" nodes found"): + return int(part.split(" ", 1)[0]) + raise AssertionError(f"no node count in header: {text.splitlines()[:1]}") + + +def test_generic_noun_phrasing_seeds_no_hub_and_stays_bounded(): + """"what code uses X to charge a customer" must not seed the `Customer` hub. + + "customer" and "charge" are both substrings of the `ChargeCustomerService` + seed's label, so neither is starved and neither buys a seat. Measured on this + fixture: 22 nodes with the hub seeded (12 of them the hub's member fan-out) + against 10 without it — so the bound is asserted against the pre-C2 seed + list's own traversal rather than a hard-coded number. This phrasing triggers + no context filter, so the comparison traversal is unfiltered like the + pipeline's. + """ + G = make_charge_fixture() + text = _query_graph_text( + G, _GENERIC_NOUN_QUESTION, mode="bfs", depth=_GENERIC_NOUN_DEPTH + ) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert label_of(SERVICE) in seeds + assert label_of(HUB) not in seeds, f"generic-noun hub still seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + + hub_fanout = [lbl for lbl in shown if lbl.startswith(label_of(HUB) + ".")] + assert not hub_fanout, f"hub member fan-out flooded the answer: {hub_fanout}" + + qs = _score_query(G, _query_terms(_GENERIC_NOUN_QUESTION), collect_per_term_seeds=True) + pre_c2_seeds = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=_demote_relational_intent_terms(qs.best_seed_by_term) + ) + pre_c2_nodes, _edges = _bfs(G, pre_c2_seeds, _GENERIC_NOUN_DEPTH) + assert _nodes_found(text) < len(pre_c2_nodes), ( + f"traversal not bounded: {_nodes_found(text)} nodes from {seeds} is no smaller " + f"than the {len(pre_c2_nodes)} the pre-C2 seed list {pre_c2_seeds} reached" + ) + + +def test_covered_term_skips_guarantee_while_starved_term_is_still_recovered(): + """The picker seam, where the starvation-recovery and dedup prior art sits. + + Both halves of the refined invariant in one test: the covered term loses its + guaranteed seat, and the genuinely starved term keeps its — C2 provably + cannot reintroduce starvation. + """ + G = make_charge_fixture() + qs = _score_query( + G, _query_terms(_COVERED_AND_STARVED_QUESTION), collect_per_term_seeds=True + ) + seeds = _pick_seeds( + qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=True + ) + + assert SERVICE in seeds + assert HUB not in seeds, ( + "'customer' is a substring of the top-ranked seed's label, so it is not starved" + ) + assert DOC in seeds, ( + "'code' is matched by no picked seed's label — the #1445 guarantee must still fire" + ) + + +def test_only_the_top_ranked_seed_can_declare_a_term_covered(): + """A substring collision inside a *lower-ranked* seed must not starve a term. + + Coverage means "the query's dominant match already answers this term", which + is a claim only the top-ranked seed is entitled to make. Let any picked seed + make it and an unrelated one absorbs a term by coincidence: `ReportService` + contains the letters of "port", so a corpus symbol literally named `port` + loses the guaranteed seat that is the only way it can enter this seed list — + the #1597 concern (a corpus may legitimately name a symbol after a common + word) reappearing one layer down. Refinement of #37/C2, adopted from the + Graphify-Labs#2516 review. + + Ranks are supplied directly rather than scored, the same way + `test_coverage_is_judged_on_the_seed_label_never_its_node_id` does: the point + is a specific gap-window shape (a dominant match, an unrelated runner-up that + happens to contain the term, and the term's own winner below the cutoff), and + naming it beats coaxing a synthetic corpus into producing it. + """ + G = nx.Graph() + G.add_node("cfg", label="port", source_file="src/config/server.py") + G.add_node("report", label="ReportService", source_file="app/Reports/ReportService.php") + G.add_node("boot", label="ServerBootstrap", source_file="src/server_bootstrap.py") + + qs = _score_query(G, ["port"], collect_per_term_seeds=True) + # Premise: the term's winner is the exact-match node, by ~7300x — `port` is a + # real symbol here, not a coincidence, and `ReportService` is the coincidence. + assert qs.best_seed_by_term["port"] == "cfg" + + scored = [(1000.0, "boot"), (300.0, "report"), (1.0, "cfg")] + seeds = _pick_seeds( + scored, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=True + ) + + # Premise: both of the unrelated nodes clear the gap window, and the term's + # own winner does not — so the guarantee is its only way in. + assert seeds[0] == "boot", f"top-ranked seed is not the dominant match: {seeds}" + assert "report" in seeds + assert "cfg" in seeds, ( + "'port' was declared covered by `ReportService`, a lower-ranked seed that " + f"merely contains the letters — the term's real winner was starved: {seeds}" + ) + + +def test_covered_term_skip_is_off_unless_the_caller_opts_in(): + """Default-off: identical results for every caller that does not opt in. + + `path`, `explain`, the legacy-equality property tests and the benchmark's two + arms all reach `_pick_seeds` without the flag; passing it explicitly False + must be the same call. + """ + G = make_charge_fixture() + qs = _score_query( + G, _query_terms(_COVERED_AND_STARVED_QUESTION), collect_per_term_seeds=True + ) + legacy = _pick_seeds(qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term) + + assert legacy == _pick_seeds( + qs.ranked, G=G, best_seed_by_term=qs.best_seed_by_term, skip_covered_terms=False + ) + assert HUB in legacy, f"the covered-term skip leaked into the default picker: {legacy}" + assert DOC in legacy + + +def test_coverage_is_judged_on_the_seed_label_never_its_node_id(): + """A labelless seed must not declare a term covered through its node id. + + `norm_label == ""` ghost nodes are real (see `_fold_node_aliases` in + build.py: an alias-only node enters the graph with no label) and they can + still be seeds, because the source-file tier scores them. The dedup gate + falls back to the node id for such a node — it has to, since a labelless + node needs *something* unique to dedup on — but the coverage predicate must + not, or the ghost's path fragments silently cover unrelated terms and starve + their winners. `_score_nodes`' substring tier reads `norm_label` only, so a + seed with no label covers nothing. + + Still load-bearing under the top-ranked-seed-only rule: the ghost is forced + to be the *only* gap-window seed below, so it IS `seeds[0]` — the one seed + entitled to declare coverage. Narrowing which seed may cover does not narrow + what this test traps. + """ + ghost = "src/customer_utils.py::helper" + G = nx.Graph() + G.add_node(ghost, label="", source_file="src/customer_utils.py") + G.add_node("cm", label="CustomerModel", source_file="app/Models/CustomerModel.php") + + qs = _score_query(G, ["customer"], collect_per_term_seeds=True) + # Premise 1: the term's real winner is the labelled node. + assert qs.best_seed_by_term["customer"] == "cm" + # Premise 2: the labelless ghost still scores — via the source-file tier — + # so it is a legitimate seed candidate rather than a hypothetical. + assert dict((nid, s) for s, nid in qs.ranked)[ghost] > 0 + + # Force the ghost to be the only gap-window seed, the way an unrelated exact + # match does on a real corpus (the #1445 shape): `cm` can now enter only + # through the per-term guarantee. + seeds = _pick_seeds( + [(1000.0, ghost), (1.0, "cm")], + G=G, + best_seed_by_term=qs.best_seed_by_term, + skip_covered_terms=True, + ) + assert ghost in seeds + assert "cm" in seeds, ( + "'customer' was treated as covered by a seed with no label — coverage read " + f"the node id, so the term's winner was starved: {seeds}" + ) + + +# --------------------------------------------------------------------------- # +# C3 — heuristic-context-filter starvation fallback (#42) # +# # +# A class node has no call-context edges of its own: calls attach to its # +# methods, and the class->member edge carries `context=None`. So the `call` # +# filter that "Who calls X?" infers strands a perfectly-seeded class seed at # +# exactly one node. When an *inferred* filter discovers nothing beyond the # +# seeds, the query retraverses unfiltered and says so in the header; an # +# *explicit* filter is always honored. Assertions read the header and the # +# rendered NODE lines only. # +# --------------------------------------------------------------------------- # + +from tests.seeding_fixtures import CALLERS, SERVICE_METHOD # noqa: E402 + + +def context_segment(text: str) -> str: + """The `Context: ...` segment of a `_query_graph_text` header, or "" if the + query ran unfiltered. Read as a whole so the source and any relaxation note + are asserted where the caller actually sees them.""" + for part in text.split("\n", 1)[0].split(" | "): + if part.startswith("Context:"): + return part + return "" + + +def test_who_calls_phrasing_falls_back_when_heuristic_filter_strands_the_seed(): + """The measured phrasing A end-to-end: "Who calls ChargeCustomerService?" + + The inferred `call` filter leaves the class seed with nowhere to go, so the + traversal relaxes and every known caller renders — including the one + reachable only through its `references` edge (#38's extraction gap). The + header keeps the failure mode visible: the heuristic context is still + reported, annotated as relaxed, so a fallback answer never reads as a + filtered one. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "Who calls ChargeCustomerService?", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + context = context_segment(text) + + assert label_of(SERVICE) in seeds + assert label_of(DECOY) not in seeds, f"verb-prefix decoy seeded: {seeds}" + for caller in caller_labels(): + assert caller in shown, f"caller {caller!r} missing from shown output:\n{text}" + assert label_of(DECOY) not in shown, f"decoy neighborhood leaked in:\n{text}" + assert "heuristic" in context, f"header lost the inferred context: {context!r}" + assert "relaxed" in context, f"header does not report the relaxation: {context!r}" + + +def test_expanding_heuristic_filter_is_left_in_force(): + """A heuristic filter that does reach past the seeds is not second-guessed. + + Seeded on a *method* node — `BillingController.processPayment()`, whose + identifier the question names directly — the inferred `call` filter walks + two real call edges, so no fallback fires. What proves the filter is still + doing its job rather than having been quietly dropped is what is *missing*: + the `references`-only caller (#38's extraction gap) and the service class + itself, whose `method` edge carries no context. Relaxing would pull both in. + + The question says "invoked" rather than "calls": same inferred `call` + filter, but no fixture label matches it, so the seed set is exactly the one + identifier and the assertions below read only C3's behavior. + """ + G = make_charge_fixture() + text = _query_graph_text(G, "Who invoked BillingController?", mode="bfs", depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + context = context_segment(text) + + assert seeds == [label_of(CALLERS[0])], f"unexpected seed set: {seeds}" + assert "heuristic" in context, f"header lost the inferred context: {context!r}" + assert "relax" not in context, f"expanding filter was needlessly relaxed: {context!r}" + assert label_of(SERVICE_METHOD) in shown + assert label_of(CALLERS[1]) in shown + for filtered_out in (label_of(CALLERS[2]), label_of(SERVICE)): + assert filtered_out not in shown, ( + f"filter no longer in force — {filtered_out!r} is reachable only " + f"through a non-`call` edge:\n{text}" + ) + + +def test_explicit_context_filter_never_falls_back_even_when_stranded(): + """The identical stranding, with the filter passed explicitly: honored. + + An explicit instruction is never overridden, so the answer stays at the seed + alone and the header reports an unqualified explicit filter. + """ + G = make_charge_fixture() + text = _query_graph_text( + G, "Who calls ChargeCustomerService?", mode="bfs", depth=2, + context_filters=["call"], + ) + shown = shown_nodes(text) + context = context_segment(text) + + assert shown == [label_of(SERVICE)], f"explicit filter was relaxed:\n{text}" + assert "explicit" in context, f"header lost the explicit source: {context!r}" + assert "relax" not in context, f"explicit filter was annotated as relaxed: {context!r}" + + +def test_starvation_fallback_is_identical_in_both_traversal_modes(): + """Mode choice must not change filter behavior: DFS relaxes exactly where + BFS does, and reaches the same nodes.""" + G = make_charge_fixture() + question = "Who calls ChargeCustomerService?" + bfs = _query_graph_text(G, question, mode="bfs", depth=2) + dfs = _query_graph_text(G, question, mode="dfs", depth=2) + + for mode, text in (("BFS", bfs), ("DFS", dfs)): + context = context_segment(text) + assert "relaxed" in context, f"{mode} did not relax: {context!r}" + for caller in caller_labels(): + assert caller in shown_nodes(text), ( + f"{mode} missing caller {caller!r}:\n{text}" + ) + assert set(shown_nodes(bfs)) == set(shown_nodes(dfs)), ( + "traversal modes disagree under the fallback:\n" + f"BFS={shown_nodes(bfs)}\nDFS={shown_nodes(dfs)}" + ) + + +def test_single_node_expansion_is_not_starvation(): + """The threshold is *zero* expansion, not "few nodes" — one discovered node + is enough to leave the filter alone. + + Same single-seed scenario as the test above, with one local tweak: the other + caller's call edge into the service method is dropped, so the heuristic + `call` filter reaches exactly one node beyond the seed instead of two. + Pinning this boundary is what stops the threshold from drifting into a + tuning constant (`<= len(seeds) + 1` and friends): such an implementation + relaxes here — throwing away a filter that had in fact found its match, and + dragging in the class node and the hub — which this test rejects. Asserted + for both modes, since the threshold is shared. + """ + for mode in ("bfs", "dfs"): + G = make_charge_fixture() + G.remove_edge(CALLERS[1], SERVICE_METHOD) + text = _query_graph_text(G, "Who invoked BillingController?", mode=mode, depth=2) + seeds = start_labels(text) + shown = shown_nodes(text) + + assert set(shown) == set(seeds) | {label_of(SERVICE_METHOD)}, ( + f"{mode}: expected exactly one node beyond the seeds:\n{text}" + ) + assert "relax" not in context_segment(text), ( + f"{mode}: a filter that discovered a node was relaxed anyway — the " + f"threshold is no longer zero expansion:\n{text}" + ) diff --git a/tests/test_stub_shadow_ambiguity.py b/tests/test_stub_shadow_ambiguity.py new file mode 100644 index 000000000..a4df28206 --- /dev/null +++ b/tests/test_stub_shadow_ambiguity.py @@ -0,0 +1,244 @@ +"""Sourceless stubs must not shadow a real declaration, nor collapse into one +rival group (#49, RC3 of #46). + +`find_node_ambiguity` grouped the winning tier by `source_file`. Every sourceless +stub carries `source_file == ""`, so N stubs counted as a single file: no +ambiguity was reported and `explain` answered with `matches[0]` — the graph's +iteration order. The 18 exact-tier stubs shadowing `BalanceitemRepository` on the +pinned api corpus are the field case; the shapes below are the synthetic +equivalents (Q4 = sourced + N stubs, Q2 = sourced + 1 stub). +""" +from __future__ import annotations + +import json + +import pytest +from networkx.readwrite import json_graph + +import graphify.__main__ as mainmod +from graphify.affected import resolve_seed +from graphify.cli import EXIT_AMBIGUOUS +from graphify.serve import _find_node, find_node_ambiguity + + +LABEL = "FooRepository" +SOURCED_ID = "app_repo_foorepository_foorepository" +SOURCE_FILE = "app/Repo/FooRepository.php" + + +def _stub(index: int, *, omit_source_key: bool = False) -> dict: + """A sourceless stub: `_php_emit_base`'s bare shadow, or a per-file salted one. + + `omit_source_key` reproduces the attributeless node serve materializes for a + dangling edge endpoint (no `source_file` key at all, not an empty one). + """ + node = {"id": f"app_uses_consumer{index}_php_foorepository", "label": LABEL, + "community": 0} + if not omit_source_key: + node["source_file"] = "" + return node + + +def _sourced() -> dict: + return {"id": SOURCED_ID, "label": LABEL, "source_file": SOURCE_FILE, + "source_location": "L22", "community": 0} + + +def _graph_dict(nodes: list[dict]) -> dict: + return {"directed": False, "multigraph": False, "graph": {}, + "nodes": nodes, "links": []} + + +def _load(nodes: list[dict]): + return json_graph.node_link_graph( + {**_graph_dict(nodes), "directed": True}, edges="links") + + +def _write(tmp_path, nodes: list[dict], name: str = "graph.json"): + p = tmp_path / name + p.write_text(json.dumps(_graph_dict(nodes))) + return p + + +def _run_explain(monkeypatch, graph_path, label, capsys): + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", + ["graphify", "explain", label, "--graph", str(graph_path)]) + code = None + try: + mainmod.main() + except SystemExit as exc: + code = exc.code + return capsys.readouterr().out, code + + +# --- Q4: one sourced declaration + N sourceless stubs ------------------------ + + +@pytest.mark.parametrize("stub_count", [2, 3, 18]) +def test_sourced_node_wins_over_n_sourceless_stubs(stub_count): + """Criterion 1/4: never a silent arbitrary stub, at any stub count. + + The guard must not depend on stub counts staying high — post-#47/#48 graphs + have fewer stubs, and the residual ones must still be handled. + """ + stubs = [_stub(i) for i in range(stub_count)] + # Stubs first: matches[0] was a stub under graph-iteration order. + G = _load(stubs + [_sourced()]) + assert _find_node(G, LABEL)[0] == SOURCED_ID + assert find_node_ambiguity(G, LABEL) == [] + + +def test_q4_answer_does_not_depend_on_node_order(): + stubs = [_stub(i) for i in range(3)] + forward = _load(stubs + [_sourced()]) + reverse = _load([_sourced()] + list(reversed(stubs))) + assert _find_node(forward, LABEL)[0] == _find_node(reverse, LABEL)[0] == SOURCED_ID + + +def test_q4_explain_reports_the_sourced_node(monkeypatch, tmp_path, capsys): + p = _write(tmp_path, [_stub(i) for i in range(18)] + [_sourced()]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert f" ID: {SOURCED_ID}" in out + assert SOURCE_FILE in out + assert "Ambiguous" not in out + assert code != 1 + + +def test_attributeless_dangling_endpoints_are_also_stubs(): + """A dangling edge endpoint has no `source_file` key at all — same rule.""" + stubs = [_stub(i, omit_source_key=True) for i in range(3)] + G = _load(stubs + [_sourced()]) + assert _find_node(G, LABEL)[0] == SOURCED_ID + assert find_node_ambiguity(G, LABEL) == [] + + +# --- all-sourceless tier: no real node to prefer, so refuse ------------------ + + +@pytest.mark.parametrize("stub_count", [2, 18]) +def test_sourceless_rivals_alone_are_ambiguous_not_an_arbitrary_pick(stub_count): + """The bug proper: N stubs shared the `""` bucket and looked unambiguous.""" + G = _load([_stub(i) for i in range(stub_count)]) + assert len(find_node_ambiguity(G, LABEL)) == stub_count + + +def test_explain_refuses_when_only_sourceless_stubs_match(monkeypatch, tmp_path, capsys): + p = _write(tmp_path, [_stub(i) for i in range(3)]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert "Ambiguous" in out + assert code == EXIT_AMBIGUOUS + assert "Node: FooRepository\n ID:" not in out + + +# --- a stub with no sourced rival is still an answer ------------------------ + + +@pytest.mark.parametrize("omit_source_key", [False, True]) +def test_lone_stub_with_no_sourced_rival_still_resolves(omit_source_key): + """The rule demotes stubs against a real declaration — it does not delete them. + + `resolve_seed`'s exact-label pass no longer returns a lone *sourceless* match + outright (a decorated "name()" declaration is invisible to that pass but + visible to the bare-name pass below it, and serve's exact tier — matching both + forms — would prefer it). A stub with nothing to lose to must therefore still + come back from a later pass rather than fall through to None. + + This asserts that outcome, not the mechanism: for an undecorated label both + the bare-name pass and the `contains` pass below it return the stub, so either + alone would satisfy this. `test_bare_name_pass_is_what_recovers_a_lone_stub` + isolates the pass the fall-through actually leans on. + """ + stub = _stub(0, omit_source_key=omit_source_key) + G = _load([stub]) + assert _find_node(G, LABEL) == [stub["id"]] + assert find_node_ambiguity(G, LABEL) == [] + assert resolve_seed(G, LABEL) == stub["id"] + + +def test_bare_name_pass_is_what_recovers_a_lone_stub(): + """A decorated sourceless stub that the `contains` pass cannot rescue. + + `handle()` never enters the exact-label pass (its stored label keeps the + decoration, the query does not), and the `contains` pass ties it with the + `handleRequest()` sibling. Only the bare-name pass sees it alone — so this is + the assertion that goes red if that pass stops returning lone matches, which + is the fall-through the exact-label change depends on. + """ + G = _load([ + {"id": "stub_handle", "label": "handle()", "source_file": "", "community": 0}, + {"id": "app_svc_php_handlerequest", "label": "handleRequest()", + "source_file": "app/Svc.php", "source_location": "L14", "community": 0}, + ]) + assert resolve_seed(G, "handle") == "stub_handle" + + +def test_lone_stub_explains_rather_than_reporting_a_phantom_ambiguity( + monkeypatch, tmp_path, capsys +): + p = _write(tmp_path, [_stub(0)]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert f" ID: {_stub(0)['id']}" in out + assert "Ambiguous" not in out + assert code != 1 + + +# --- Q2: one sourced + one sourceless --------------------------------------- + + +def test_q2_shape_does_not_regress(): + """Criterion 2: resolve to the sourced node (never the stub, never silent).""" + G = _load([_stub(0), _sourced()]) + assert _find_node(G, LABEL)[0] == SOURCED_ID + assert find_node_ambiguity(G, LABEL) == [] + + +def test_q2_explain_reports_the_sourced_node(monkeypatch, tmp_path, capsys): + p = _write(tmp_path, [_stub(0), _sourced()]) + out, code = _run_explain(monkeypatch, p, LABEL, capsys) + assert f" ID: {SOURCED_ID}" in out + assert code != 1 + + +# --- explain / affected consistency (criterion 3) --------------------------- + + +@pytest.mark.parametrize("stub_count", [1, 2, 18]) +def test_explain_and_affected_agree_when_a_sourced_node_exists(stub_count): + """`resolve_seed` refused with "No unique node match" while `explain` + answered — that divergence is #46's symptom.""" + G = _load([_stub(i) for i in range(stub_count)] + [_sourced()]) + assert resolve_seed(G, LABEL) == _find_node(G, LABEL)[0] == SOURCED_ID + + +@pytest.mark.parametrize("stub_count", [2, 18]) +def test_explain_and_affected_both_refuse_when_every_rival_is_sourceless(stub_count): + G = _load([_stub(i) for i in range(stub_count)]) + assert resolve_seed(G, LABEL) is None + assert find_node_ambiguity(G, LABEL) # explain refuses too + + +def test_affected_prefers_sourced_node_for_a_callable_label(): + """`resolve_seed`'s bare-name pass (decorated "name()" labels) needs the + same rule as its exact-label pass.""" + nodes = [ + {"id": "stub_handle", "label": "handle", "source_file": "", "community": 0}, + {"id": "app_svc_php_handle", "label": "handle()", + "source_file": "app/Svc.php", "source_location": "L9", "community": 0}, + ] + G = _load(nodes) + assert resolve_seed(G, "handle") == "app_svc_php_handle" + + +# --- the pre-existing monorepo tie must still be reported ------------------- + + +def test_two_sourced_rivals_are_still_ambiguous(): + """Sourced-vs-sourced (#2032's symbol case) is untouched by the stub rule.""" + G = _load([ + {"id": "chat_port", "label": "MetricsPort", + "source_file": "services/chat/ports/metrics.port.ts", "community": 0}, + {"id": "scrape_port", "label": "MetricsPort", + "source_file": "services/scraping/ports/metrics.port.ts", "community": 0}, + ]) + assert len(find_node_ambiguity(G, "MetricsPort")) == 2 diff --git a/tests/test_watch.py b/tests/test_watch.py index bb4d12599..b2a1b904f 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -4527,6 +4527,257 @@ def test_incremental_indirect_call_parity_and_idempotency(tmp_path): assert sorted(_2438_indirects(_2406_graph(fresh))) == sorted(incremental) +# --- #11: an interface-typed receiver survives an incremental rebuild -------- +# The wrong edge #11 closed: an `App\Contracts\Notifier`-typed receiver binding +# to the unrelated `App\Support\Notifier` class because the interface's own file +# was unchanged and therefore never re-dispatched, leaving the stranger as the +# one visible definition. The facts the resolver needs about an undispatched +# file come back through the persisted graph — the markers on its file node, the +# same channel `_callable` uses (#2438). +# +# Post-#47/#53 the interface has a definition node and its own `notify()`, so +# the receiver BINDS THE CONTRACT rather than refusing; the stranger is still +# what must never be bound, and the rebuild must still agree with the full build +# (now via the `_php_class_fqns` marker, #23, which carries the declared FQN the +# `use`-claim guard compares against). + +_11_CALLER = ( + "notifier->notify('x'); }\n}\n" +) + + +def _11_seed(tmp_path, caller_extra=""): + """PHP corpus: a Notifier INTERFACE, an unrelated same-short-named CLASS, and + a Dispatcher whose receiver is typed as the interface. Full-rebuild it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + (corpus / "app" / "Contracts").mkdir(parents=True) + (corpus / "app" / "Support").mkdir(parents=True) + (corpus / "app" / "Http").mkdir(parents=True) + (corpus / "app" / "Contracts" / "Notifier.php").write_text( + "status->label(); $this->cache->flush(); }\n}\n" +) + + +def _12_seed(tmp_path, caller_extra=""): + """PHP corpus: a Status ENUM and a Cache TRAIT, each beside an unrelated + same-short-named CLASS, plus a Runner typed against both. Full-rebuild it.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + for sub in ("Enums", "Support", "Legacy", "Http"): + (corpus / "app" / sub).mkdir(parents=True) + (corpus / "app" / "Enums" / "Status.php").write_text( + ".php` label, which + cannot tell a declaration kind apart from the colliding class's file.""" + corpus = _12_seed(tmp_path) + + stamped = { + node.get("source_file"): node["_php_non_class_types"] + for node in _2406_graph(corpus).get("nodes", []) + if node.get("_php_non_class_types") + } + assert stamped == { + "app/Enums/Status.php": ["Status"], + "app/Support/Cache.php": ["Cache"], + }, "only the declaring files carry the names — the stranger classes carry none" + + # --- #2603: absolute .graphify_root must not re-anchor cwd-relative sources --- def test_subfolder_root_marker_preserves_unchanged_nodes(tmp_path, monkeypatch): diff --git a/uv.lock b/uv.lock index fd171a5a9..2762c4d77 100644 --- a/uv.lock +++ b/uv.lock @@ -6,13 +6,13 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -100,7 +100,7 @@ name = "autograd" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/1c/3c24ec03c8ba4decc742b1df5a10c52f98c84ca8797757f313e7bdcdf276/autograd-1.8.0.tar.gz", hash = "sha256:107374ded5b09fc8643ac925348c0369e7b0e73bbed9565ffd61b8fd04425683", size = 2562146, upload-time = "2025-05-05T12:49:02.502Z" } wheels = [ @@ -480,7 +480,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -551,17 +551,17 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } @@ -819,10 +819,10 @@ name = "ctranslate2" version = "4.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "setuptools", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml" }, + { name = "setuptools" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/cb/e0/b69c40c3d739b213a78d327071240590792071b4f890e34088b03b95bb1e/ctranslate2-4.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9017a355dd7c6d29dc3bca6e9fc74827306c61b702c66bb1f6b939655e7de3fa", size = 1255773, upload-time = "2026-02-04T06:11:04.769Z" }, @@ -932,7 +932,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -957,12 +957,12 @@ name = "faster-whisper" version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "av", marker = "python_full_version >= '3.11'" }, - { name = "ctranslate2", marker = "python_full_version >= '3.11'" }, - { name = "huggingface-hub", marker = "python_full_version >= '3.11'" }, - { name = "onnxruntime", marker = "python_full_version >= '3.11'" }, - { name = "tokenizers", marker = "python_full_version >= '3.11'" }, - { name = "tqdm", marker = "python_full_version >= '3.11'" }, + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, @@ -1065,10 +1065,10 @@ name = "gensim" version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "smart-open", marker = "python_full_version < '3.13'" }, + { name = "smart-open" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/80/fe9d2e1ace968041814dbcfce4e8499a643a36c41267fa4b6c4f54cce420/gensim-4.4.0.tar.gz", hash = "sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5", size = 23260095, upload-time = "2025-10-18T02:06:45.962Z" } wheels = [ @@ -1096,7 +1096,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.84" +version = "0.9.92" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1425,26 +1425,26 @@ name = "graspologic" version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anytree", marker = "python_full_version < '3.13'" }, - { name = "beartype", marker = "python_full_version < '3.13'" }, - { name = "future", marker = "python_full_version < '3.13'" }, - { name = "gensim", marker = "python_full_version < '3.13'" }, - { name = "graspologic-native", version = "1.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "hyppo", marker = "python_full_version < '3.13'" }, - { name = "joblib", marker = "python_full_version < '3.13'" }, - { name = "matplotlib", marker = "python_full_version < '3.13'" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "anytree" }, + { name = "beartype" }, + { name = "future" }, + { name = "gensim" }, + { name = "graspologic-native", version = "1.2.5", source = { registry = "https://pypi.org/simple" } }, + { name = "hyppo" }, + { name = "joblib" }, + { name = "matplotlib" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pot", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pot" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "seaborn", marker = "python_full_version < '3.13'" }, - { name = "statsmodels", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "umap-learn", marker = "python_full_version < '3.13'" }, + { name = "seaborn" }, + { name = "statsmodels" }, + { name = "typing-extensions" }, + { name = "umap-learn" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/bb/0fe2ef85ea775e7b8416b2cf90097aa4b5e0c9c2271d7fe6789bab27d0ca/graspologic-3.4.4.tar.gz", hash = "sha256:79878caf367da3e89046a4ec94291c5b1a5da569f19fdd879d8b45c3563d7110", size = 5134258, upload-time = "2025-09-08T21:44:01.969Z" } wheels = [ @@ -1457,10 +1457,10 @@ version = "1.2.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -1485,8 +1485,8 @@ resolution-markers = [ "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/25/607bcadd51954aa98892e44d1536560abb06a81ff48288982bd8303983ee/graspologic_native-1.3.1.tar.gz", hash = "sha256:fa6be9c292508b8a876d45ac98ee550534d9e33fcf7129a6df8a95573101be4c", size = 2655991, upload-time = "2026-06-14T01:01:51.23Z" } wheels = [ @@ -1582,15 +1582,15 @@ name = "huggingface-hub" version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock", marker = "python_full_version >= '3.11'" }, - { name = "fsspec", marker = "python_full_version >= '3.11'" }, - { name = "hf-xet", marker = "(python_full_version >= '3.11' and platform_machine == 'AMD64') or (python_full_version >= '3.11' and platform_machine == 'aarch64') or (python_full_version >= '3.11' and platform_machine == 'amd64') or (python_full_version >= '3.11' and platform_machine == 'arm64') or (python_full_version >= '3.11' and platform_machine == 'x86_64')" }, - { name = "httpx", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "tqdm", marker = "python_full_version >= '3.11'" }, - { name = "typer", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" } wheels = [ @@ -1615,18 +1615,18 @@ name = "hyppo" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "autograd", marker = "python_full_version < '3.13'" }, - { name = "future", marker = "python_full_version < '3.13'" }, - { name = "numba", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "autograd" }, + { name = "future" }, + { name = "numba" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "patsy", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "patsy" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "statsmodels", marker = "python_full_version < '3.13'" }, + { name = "statsmodels" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" } wheels = [ @@ -1656,7 +1656,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.11'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -2361,13 +2361,13 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } @@ -2395,8 +2395,8 @@ name = "numba" version = "0.65.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "llvmlite", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "llvmlite" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } wheels = [ @@ -2432,10 +2432,10 @@ version = "1.26.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -2559,11 +2559,11 @@ name = "onnxruntime" version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "flatbuffers" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "protobuf", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "protobuf" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" }, @@ -2649,10 +2649,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2711,16 +2711,16 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ @@ -2794,7 +2794,7 @@ name = "patsy" version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } wheels = [ @@ -2973,8 +2973,8 @@ name = "pot" version = "0.9.6.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/8b/5f939eaf1fbeb7ff914fe540d659486951a056e5537b8f454362045b6c72/pot-0.9.6.post1.tar.gz", hash = "sha256:9b6cc14a8daecfe1268268168cf46548f9130976b22b24a9e8ec62a734be6c43", size = 604243, upload-time = "2025-09-22T12:51:14.894Z" } @@ -3317,12 +3317,12 @@ name = "pynndescent" version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "joblib", marker = "python_full_version < '3.13'" }, - { name = "llvmlite", marker = "python_full_version < '3.13'" }, - { name = "numba", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } @@ -3997,10 +3997,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "joblib", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, + { name = "joblib" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -4042,17 +4042,17 @@ version = "1.8.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "threadpoolctl", marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "joblib" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -4102,7 +4102,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4162,17 +4162,17 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } @@ -4244,9 +4244,9 @@ name = "seaborn" version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "matplotlib", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "matplotlib" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } @@ -4286,7 +4286,7 @@ name = "smart-open" version = "7.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/65/3ada667d32675399001bf022ad3d9f3989b57101351ebc71d6fbe2384634/smart_open-7.6.1.tar.gz", hash = "sha256:4347996e7ba21db7cd1e059632e0b30395407e4f6c660d2ddffc8f2a9ae5f990", size = 54754, upload-time = "2026-05-09T06:23:37.06Z" } wheels = [ @@ -4351,12 +4351,12 @@ name = "statsmodels" version = "0.14.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "patsy", marker = "python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "patsy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } @@ -4477,7 +4477,7 @@ name = "tokenizers" version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.11'" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ @@ -5175,10 +5175,10 @@ name = "typer" version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc", marker = "python_full_version >= '3.11'" }, - { name = "click", marker = "python_full_version >= '3.11'" }, - { name = "rich", marker = "python_full_version >= '3.11'" }, - { name = "shellingham", marker = "python_full_version >= '3.11'" }, + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ @@ -5220,14 +5220,14 @@ name = "umap-learn" version = "0.5.12" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numba", marker = "python_full_version < '3.13'" }, - { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "pynndescent", marker = "python_full_version < '3.13'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numba" }, + { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pynndescent" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" }, - { name = "tqdm", marker = "python_full_version < '3.13'" }, + { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/ee/af4171241117f85c74b5ca6448ea1033cc28d599c13651d67289bacd4083/umap_learn-0.5.12.tar.gz", hash = "sha256:6aff02ecac5f2aad9f3c65ee518d7ae93e1a985ae38721fdcffceee4232c33c7", size = 96672, upload-time = "2026-04-08T20:03:54.012Z" } wheels = [ @@ -5248,8 +5248,8 @@ name = "uvicorn" version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, - { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "click" }, + { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" }