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:
- 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.
- 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.
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:
US 19 is met for ordinary typed receivers — review confirmed end-to-end with a real
graphify updatethat adding a method to a PHP controller produces a newINFERREDedge. 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):
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):and the resolver gathers them from
per_file(graphify/extract.py:3141-3145):per_filealigns 1:1 withpaths(per the comment atextract.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_nodesare merged intoresolution_nodes(extract.py:5634-5639) andresolution_context_edgesinto the resolver edge list (extract.py:5925). There is no channel forphp_interfaces. So when the interface file is unchanged and therefore not dispatched,interface_namesis 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: anApp\Contracts\Notifierinterface, an unrelatedApp\Support\Notifierclass, and aDispatcherwith aprivate Notifier $notifierreceiver. The secondextract()dispatches onlyDispatcher.php, supplying the unchanged corpus as resolution context, as watch/incremental does.Measured output:
So
Dispatcher::gogains acallsedge intoApp\Support\Notifier::notify— a class it has nothing to do with — purely because the interface file was not re-extracted.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 theextract()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 thephp_interfacesblock) and are deliberately not popped (extract.py:6059: "_callable/_callable_classare deliberately NOT popped (Graphify-Labs#2438): they …") precisely so they survive into the persisted resolution context. The resolver would then read interface names offresolution_nodesinstead ofper_file.Wrinkle the fixer must resolve for direction B: a PHP interface mints no definition node, so there is no natural
Notifiernode to stamp. Verified — extracting the interface file alone yields only: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'sNotifier.phplabel would be wrong: it holds only under one-interface-per-PSR-4-file convention.Acceptance criteria
callsedge (the probe's INCREMENTAL line matches its FULL line)extract()calls and cannot catch thisgraphify updateon a real corpus reproduces the refusal (US 19)feat/php-member-calls-1682: 3988 passed / 36 skipped)Blocked by
None. Independent of #7 — but if this ships unfixed, #7's changelog should name it as a known limitation.