Repository navigation
Conversation
`graphify affected` could not name one of two same-named methods, and a failed lookup printed "No unique node match" on stdout with exit 0. On httpx, `Client.send` and `httpx/_client.py::Client.send` missed, and `send` resolved to a sourceless `Send` stub left by an annotation. Seed resolution now runs in tiers (exact id, qualified, exact label, bare name, source path, substring) and resolves only when a tier has exactly one candidate. `Class.method`, `path::Class.method`, `path::function` and `path::Class` are accepted. The class must own the member through a `method` or `contains` edge, and the path restricts the node's own source_file, exact spelling first. Source-backed nodes outrank sourceless stubs. A miss or a tie prints to stderr and exits 1, and a tie lists its candidates. affected_nodes, explain and path are unchanged. Related: Graphify-Labs#3485, Graphify-Labs#3913, Graphify-Labs#1669, Graphify-Labs#2706, Graphify-Labs#2707. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the pull request, @krishhgg. A maintainer will review it soon. Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions. A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic. |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Formal verification. 2 change(s) alter behavior, breaking input(s) attached. PR-changed functions: 3/4 verified (0 proven, 1 may-equivalent, 2 distinguished) · 1 not verified (1 vacuous).
Behavior changes: format\_affected changes behavior, here is the input that shows it.
The verifier found a concrete input on which format\_affected behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\]\), \_g\.add\_edges\_from\(\[\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","query":"'\\\\t\\\\n'"\}, the old code produced 'No unique node match for \\t\\n' but the new code produces raises SeedResolutionError. Paste that input straight into a regression test.
Behavior changes: resolve\_seed changes behavior, here is the input that shows it.
The verifier found a concrete input on which resolve\_seed behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\(1, \{\}\), \(2, \{\}\), \(3, \{\}\)\]\), \_g\.add\_edges\_from\(\[\(1, 2, \{\}\), \(1, 3, \{\}\), \(2, 3, \{\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","query":"'\(\)'","root":"\_\_import\_\_\('pathlib'\)\.Path\('a\.txt'\)"\}, the old code produced None but the new code produces raises KeyError. Paste that input straight into a regression test.
Not verified on this run: dispatch\_command (vacuous: never exercised).
Graphify review — findings
Extends affected seed resolution to accept Class.method and path::symbol queries alongside labels, node ids, and file paths, reading ownership from method/contains edges via _src/_tgt markers so undirected graphs keep the right direction. An ambiguous or unmatched seed now raises SeedResolutionError, listing up to 20 candidates instead of guessing; this covers several files that match a path only after case or Unicode normalization and nested Outer.Inner.run qualifiers, which are refused. Source-backed definitions win over sourceless annotation stubs, and owner lookups on undirected graphs come from a single indexed edge pass rather than a full scan per member.
Worth a look
- format_affected now raises instead of returning a 'No unique node match' string —
graphify/affected.py:628· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 484 functions depend on the 214 functions this change touches.
Health — this change adds coupling hotspots:
- new:
main()— 104 callers, 3 callees - new:
dispatch_command()— 2 callers, 128 callees - new:
_refresh_stale_skills()— 25 callers, 4 callees - new:
format_affected()— 8 callers, 10 callees - new:
resolve_seed_candidates()— 6 callers, 7 callees - new:
_run_cli()— 6 callers, 7 callees - new:
_stale_graph_sources()— 7 callers, 6 callees - new:
_run_hook_guard()— 4 callers, 10 callees - …and 2 more — each is listed as a finding
Verification — 484 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 484 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
37 of 334 test file(s) selected (11%) via static blast radius.
tests/test_affected_cli.py— impact, changed-testtests/test_affected_member_seed.py— impacttests/test_agents_platform.py— impacttests/test_codebuddy.py— impacttests/test_corrupt_graph_json.py— impacttests/test_dedup_shrink_refuses_force_write.py— impacttests/test_devin.py— impacttests/test_explain_cli.py— impacttests/test_extract_cli.py— impacttests/test_global_add_tag_inference.py— impacttests/test_god_nodes_cli.py— impacttests/test_hollow_chunks_arm_shrink_guard.py— impacttests/test_hook_guard_token_match.py— impacttests/test_hook_out_of_project_paths.py— impacttests/test_hook_strict.py— impacttests/test_incomplete_build_guard.py— impacttests/test_indirect_call_nested_closure_shadow.py— impacttests/test_indirect_dispatch.py— impacttests/test_indirect_dispatch_assign_return.py— impacttests/test_indirect_dispatch_getattr.py— impacttests/test_install.py— impacttests/test_install_references.py— impacttests/test_install_version_warning.py— impacttests/test_js_dynamic_import_affected.py— impacttests/test_js_dynamic_imports.py— impacttests/test_merge_chunks_validation.py— impacttests/test_multigraph_diagnostics.py— impacttests/test_no_dedup_flag.py— impacttests/test_partial_cache.py— impacttests/test_path_cli.py— impacttests/test_query_cli.py— impacttests/test_query_induced_edges.py— impacttests/test_scala_self_type.py— impacttests/test_skill_auto_refresh.py— impacttests/test_skill_version_warning.py— impacttests/test_stale_prune.py— impacttests/test_unverified_semantic_shrink.py— impact
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
BENCHMARKS.md§ INFERRED cross-file edge precision (FastAPI demo corpus) (lines 152-166): references changed symbolsedgeCHANGELOG.md§ 0.9.77 (2026-10-05) (lines 5-24): references changed symbols__init__,edge,membersCHANGELOG.md§ 0.9.76 (2026-10-04) (lines 25-43): references changed symbolsedge,membersCHANGELOG.md§ 0.9.75 (2026-10-04) (lines 44-64): references changed symbolsmembersCHANGELOG.md§ 0.9.74 (2026-10-02) (lines 65-80): references changed symbolsedgeCHANGELOG.md§ 0.9.73 (2026-09-30) (lines 81-96): references changed symbolsmembersCHANGELOG.md§ 0.9.66 (2026-09-22) (lines 167-181): references changed symbolsedgeCHANGELOG.md§ 0.9.65 (2026-09-20) (lines 182-194): references changed symbolsmembersCHANGELOG.md§ 0.9.63 (2026-09-16) (lines 207-214): references changed symbolsedgeCHANGELOG.md§ 0.9.58 (2026-09-10) (lines 257-274): references changed symbols__init__
…and 10 more.
Formal verification
Behavior changes: format\_affected changes behavior, here is the input that shows it.
The verifier found a concrete input on which format\_affected behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\]\), \_g\.add\_edges\_from\(\[\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","query":"'\\\\t\\\\n'"\}, the old code produced 'No unique node match for \\t\\n' but the new code produces raises SeedResolutionError. Paste that input straight into a regression test.
Behavior changes: resolve\_seed changes behavior, here is the input that shows it.
The verifier found a concrete input on which resolve\_seed behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\(1, \{\}\), \(2, \{\}\), \(3, \{\}\)\]\), \_g\.add\_edges\_from\(\[\(1, 2, \{\}\), \(1, 3, \{\}\), \(2, 3, \{\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","query":"'\(\)'","root":"\_\_import\_\_\('pathlib'\)\.Path\('a\.txt'\)"\}, the old code produced None but the new code produces raises KeyError. Paste that input straight into a regression test.
No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).
The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 40 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
· 1 grounded finding(s) anchored inline below; 9 more finding(s) on lines outside this diff (see the check run).
| yield exact_source_matches | ||
|
|
||
|
|
||
| def resolve_seed_candidates( |
There was a problem hiding this comment.
resolve_seed_candidates()
fans out to 7 callees (efferent coupling); 6 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
On the advisory: The coupling note on |
What does this PR do?
graphify affectedhad no way to name one of two same-named methods, and it reported a failed lookup as success. On httpx,ClientandAsyncClientboth define.send()inhttpx/_client.py:affected "Client.send",affected "httpx/_client.py::Client.send"andaffected ".send()"printNo unique node match for ...on stdout and exit 0. A script reads that as "nothing depends on this".affected sendresolves to a sourceless stubSend, left by asend: Sendannotation in tests/conftest.py, and lists its 10 annotation users. Three real definitions are namedsend.The change is in seed resolution in
graphify/affected.pyand in theaffectedbranch ofgraphify/cli.py. The traversal,affected_nodes, is unchanged.Qualified seeds.
Class.method,path::Class.method,path::functionandpath::Classnow resolve._as_repo_relativenormalization as a file-path seed and restricts the node's ownsource_file. The exact spelling wins. A case- or Unicode-insensitive match is used only when no file has the exact spelling, and when it matches several files the result is a tie.methodorcontainsedge. Direction is read from the_src/_tgtmarkers the way serve.py reads them. Label text alone never picks the owner.# Client.sendcannot stand in for the method. A miss inside a path scope is final.Outer.Inner.runis refused with a hint to useInner.run,path::Inner.runor a node id.Fail closed. Resolution runs in tiers: exact id, qualified, exact label, bare name, source path, substring. A tier resolves the seed only when it has exactly one candidate. A miss prints
No unique node match for Xto stderr and exits 1. A tie prints each candidate's label,file:lineand node id to stderr and exits 1. After a name tier ties, the substring tier no longer runs.Real definitions beat stubs. In each tier, source-backed nodes outrank sourceless ones. A method label such as
.send()also answers tosend. Other labels keep their leading dot, so a.configfile does not tie withConfig.format_affectedraises a newSeedResolutionErrorinstead of returning the miss text, and the CLI turns it into exit 1.resolve_seedkeeps itsstr | Nonereturn.explainandpathresolve throughserve._find_node_tiersand are unchanged.Behavior changes users will see
I resolved every node label under 80 characters, plus its bare forms, with v8 and with this branch (httpx: 3,235 seeds, Flask: 3,134).
get,post,put, ...) and 16 Flask names.get()andhttpx/_api.py::getstill resolve to the function.affected copyused to resolve to the stdlibcopystub and now resolves toAppContext.copy. Flask'sbefore_app_requestnow resolves toBlueprint.before_app_request, which has no inbound edges; v8's stub carried one decorator use that the extractor had bound to it.Owner.memberandpath::Owner.member. None resolved to the wrong node. The 168 misses are httpx Markdown headings whose own labels contain a dot.Limitations
Owner.member. Use its node id.::left half with no separator, no matching file and whitespace in it is treated as prose, as on v8. Adding./makes it a path.InvoicescopesInvoice::Lineto itself; v8 resolved that query by substring.::) keeps v8's case-insensitive comparison.affected_nodeson an undirected graph still reads direction from edge iteration order, as on v8. The CLI always loads a directed graph, so only library callers are affected.explain "Client.send"on httpx returns a rationale node, andpath "no_such_symbol_xyz" httpx_models_responseroutes from a docs node and exits 0.Related: #3485 (
path::Symbolfor explain), #3913 and #3935 (path endpoint refusal), #1669 (member seeding), #2309 (_src/_tgtmarkers), #2706 and #2707 (path-form seeds).Type of change
Verification & Invariants
Invariant:
affectedwalks from exactly one node, and only from a node the query names unambiguously. A query that names no node, or several, exits nonzero with the reason on stderr. The resolver never picks one of several candidates, and a sourceless stub never answers for a name that source-backed definitions carry. Apath::query whose left half has a separator or names a file never resolves outside that file.Persisted state: none.
affectedreads graph.json and writes nothing, and extraction is untouched. Fixture graphs built by v8 and by this branch have identical node and edge sets, and an incremental update matched a clean rebuild.How was this tested?
Linux only. Windows was not tested. The path half of a
path::seed goes through the same_as_repo_relativehelper as a file-path seed, but I did not run it on Windows.Edge-case fixtures through the CLI (v8 vs this PR)
Qualified resolution on graphs with many same-named owners stays linear: 2,000 classes named
ClientresolveClient.sendin 0.03 s, and a 16,000-edge undirected graph resolves in 0.12 s. Two tests cap the ownership lookups and whole-graph edge scans.Graphify-specific checklist
uv run python -m tools.skillgen --bless) when changing their source fragments. (Not applicable: no skill fragments changed.)