Skip to content

PHP interface refusal does not survive incremental rebuilds, minting a wrong edge (user stories 10 + 19) #11

Description

@filipechagas

Parent

Spec: #1 (upstream: Graphify-Labs#1682). Split out of #7 per review — this is a behavioural defect, not release documentation. Originally recorded in #5's closing comment.

Problem

Interface refusal (#5) does not survive an incremental rebuild. On a full build an interface-typed receiver is correctly refused; on an incremental rebuild where the interface's own file is unchanged, the refusal silently stops applying and the receiver binds to an unrelated same-short-named class.

This breaks two user stories from spec #1 simultaneously:

  1. As a graph consumer, I want no calls edge when the receiver's type is an interface, so that the graph never guesses which implementation is bound.
  1. As a user of graphify update (watch/incremental), I want the new resolution to run on incremental rebuilds too, so that the graph does not drift after edits.

US 19 is met for ordinary typed receivers — review confirmed end-to-end with a real graphify update that adding a method to a PHP controller produces a new INFERRED edge. The hole is specific to the interface-name channel, and its effect is worse than a missing edge: it mints a wrong one.

Context from #5

Recorded when #5 landed (4f17dd8):

Two items handed to #7: (1) measured hole — interface refusal does not survive incremental rebuilds (interface names have no channel into the persisted resolution context; repro at /tmp/probe_php_iface_incremental.py)

Mechanism

Interface names travel from extraction to the resolver only through per_file. The engine stamps them on the per-file result (graphify/extractors/engine.py:5180-5184):

if php_interface_names:
    # Interfaces mint no definition node, so the resolver cannot tell one
    # from a same-named class without this (#1682). Sorted for a stable
    # AST-cache payload.
    result["php_interfaces"] = sorted(php_interface_names)

and the resolver gathers them from per_file (graphify/extract.py:3141-3145):

interface_names = {
    key(name)
    for result in per_file
    for name in result.get("php_interfaces", [])
}

per_file aligns 1:1 with paths (per the comment at extract.py:2141-2143) — i.e. only the files actually dispatched for extraction this run.

The incremental widening path carries two other channels and no third: resolution_context_nodes are merged into resolution_nodes (extract.py:5634-5639) and resolution_context_edges into the resolver edge list (extract.py:5925). There is no channel for php_interfaces. So when the interface file is unchanged and therefore not dispatched, interface_names is empty, the refusal arm never fires, and the lone same-short-named class satisfies the single-definition guard.

extract()'s own docstring (extract.py:4906-4924) enumerates exactly what the context widens — the direct-call label/file indexes, the indirect_call callable guard, and the member-call resolvers — and PHP interface names are absent from that list.

Measured repro

/tmp/probe_php_iface_incremental.py (also reproduced below so it survives /tmp). Corpus is the Laravel Contracts collision: an App\Contracts\Notifier interface, an unrelated App\Support\Notifier class, and a Dispatcher with a private Notifier $notifier receiver. The second extract() dispatches only Dispatcher.php, supplying the unchanged corpus as resolution context, as watch/incremental does.

Measured output:

FULL build  -> notify calls edges: []
INCREMENTAL -> notify calls edges: [('..._app_http_dispatcher_dispatcher_go',
                                     '..._app_support_notifier_notifier_notify')]

So Dispatcher::go gains a calls edge into App\Support\Notifier::notify — a class it has nothing to do with — purely because the interface file was not re-extracted.

import pathlib
import tempfile

from graphify.extract import extract

d = pathlib.Path(tempfile.mkdtemp())
corpus = {
    "app/Contracts/Notifier.php": (
        "<?php\nnamespace App\\Contracts;\n"
        "interface Notifier {\n    public function notify(string $m): void;\n}\n"
    ),
    "app/Support/Notifier.php": (
        "<?php\nnamespace App\\Support;\n"
        "class Notifier {\n    public function notify(string $m): void {}\n}\n"
    ),
    "app/Http/Dispatcher.php": (
        "<?php\nnamespace App\\Http;\nuse App\\Contracts\\Notifier;\n"
        "class Dispatcher {\n    private Notifier $notifier;\n"
        "    public function go(): void { $this->notifier->notify('x'); }\n}\n"
    ),
}
paths = {}
for name, body in corpus.items():
    p = d / name
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(body)
    paths[name] = p


def notify_edges(result):
    return [
        (e["source"], e["target"]) for e in result["edges"]
        if e["relation"] == "calls" and "notify" in e["target"].lower()
    ]


full = extract(list(paths.values()), cache_root=d / "out1")
print("FULL build  -> notify calls edges:", notify_edges(full))

# Incremental: only Dispatcher.php is dispatched; everything else is context.
changed = paths["app/Http/Dispatcher.php"]
ctx_nodes = [n for n in full["nodes"] if "dispatcher" not in n["id"]]
ctx_edges = [
    e for e in full["edges"]
    if e["relation"] in ("contains", "method") and "dispatcher" not in e["source"]
]
inc = extract(
    [changed],
    cache_root=d / "out2",
    resolution_context_nodes=ctx_nodes,
    resolution_context_edges=ctx_edges,
)
print("INCREMENTAL -> notify calls edges:", notify_edges(inc))

Candidate fix directions

Both were recorded by the #5 implementer; neither has been chosen.

A. Persist PHP interface names alongside the resolution context. Give interface names their own channel through the incremental path, parallel to resolution_context_nodes/resolution_context_edges, so watch/incremental can hand back the unchanged corpus's interface names. Most direct, but widens the extract() signature — a third context parameter for one language.

B. Stamp a marker on a node the incremental path already carries. Prior art is immediately adjacent and deliberate: _callable / _callable_class (Graphify-Labs#2438) are stamped on nodes (engine.py:5177, ten lines above the php_interfaces block) and are deliberately not popped (extract.py:6059: "_callable / _callable_class are deliberately NOT popped (Graphify-Labs#2438): they …") precisely so they survive into the persisted resolution context. The resolver would then read interface names off resolution_nodes instead of per_file.

Wrinkle the fixer must resolve for direction B: a PHP interface mints no definition node, so there is no natural Notifier node to stamp. Verified — extracting the interface file alone yields only:

node: ext_notifier_php                     label='Notifier.php'
node: tmp_..._notifier_notify              label='notify()'

A file node and the interface's method node, but nothing representing the interface type. So B needs a host chosen deliberately — e.g. an attribute listing the file's interface names on the file node — rather than a per-type boolean like _callable_class. Relying on the file node's Notifier.php label would be wrong: it holds only under one-interface-per-PSR-4-file convention.

Acceptance criteria

Blocked by

None. Independent of #7 — but if this ships unfixed, #7's changelog should name it as a known limitation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions