Skip to content

fix(python): avoid false edges for pytest decorators - #2739

Closed
aayush598 wants to merge 1 commit into
Graphify-Labs:v8from
aayush598:fix/2732-decorator-noise
Closed

fix(python): avoid false edges for pytest decorators#2739
aayush598 wants to merge 1 commit into
Graphify-Labs:v8from
aayush598:fix/2732-decorator-noise

Conversation

@aayush598

Copy link
Copy Markdown

Problem

Python decorator extraction currently resolves decorator names using the bare symbol name. This causes third-party pytest decorators such as @pytest.fixture and @pytest.mark.parametrize to be treated as references to same-named functions in the corpus.

When a project contains a local fixture() or parametrize() function, repeated pytest decorators can therefore collapse onto that local node and create spurious references edges, producing misleading high-degree/"God Node" results.

Fixes #2732.

Fix

  • Track pytest bindings at module scope in source order.
  • Recognize import pytest and aliased imports such as import pytest as pt.
  • Recognize decorator-capable direct imports such as from pytest import fixture and from pytest import mark.
  • Suppress known pytest decorators only when the decorator is actually bound to pytest at that location.
  • Preserve normal decorator edges for corpus-owned decorators such as bare @fixture.
  • Respect later rebinding and del statements so shadowed pytest names are not incorrectly suppressed.
  • Handle pytest.mark.* decorators without fabricating nodes for marker names.
  • Keep the existing bare-name decorator noise handling unchanged.

Tests

Adds regression coverage for:

  • @pytest.fixture
  • @pytest.mark.parametrize
  • aliased pytest imports
  • direct pytest decorator imports and aliases
  • local decorator shadowing
  • source-order rebinding
  • with/for/walrus rebinding
  • del unbinding
  • nested functions and class methods
  • stacked pytest and custom decorators
  • unimported @pytest.fixture remaining a normal corpus reference

Also updates the changelog for the fix.

Result

Pytest decorators are treated as external test-framework vocabulary when their binding proves they refer to pytest, preventing false cross-file decorator edges and spurious God Nodes while preserving legitimate project-owned decorators.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

This PR adds pytest-decorator filtering to the Python extractor to prevent false decorator edges. It introduces an ordered module-level binding tracker (_python_pytest_bindings / _PythonBindingEvent) and an _is_pytest_decorator_noise check that classifies decorators like @pytest.fixture, @pytest.mark.*, and rebound @fixture names by qualified path or import binding in force at the decorator's location, rather than by bare tail name. It wires this check into the generic decorator extraction branch, adds supporting constants and a changelog entry, and includes a corresponding test.

No blocking issues surfaced. 4 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 818 functions depend on the 423 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _extract_generic() — 18 callers, 24 callees
  • worse: walk() — 1 callers, 56 callees

Verification — 818 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: 759 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_python\_decorator\_name.

The verifier did not have enough to check \_python\_decorator\_name, 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: not verifiable: all 182 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

· 2 more finding(s) on lines outside this diff (see the check run).

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Suppresses false decorator edges for pytest decorators (@pytest.fixture, @pytest.mark.*, @fixture imported from pytest) that the unique-function rewire would otherwise collapse onto same-named local definitions in the corpus. Matches these by qualified path and import-scope binding rather than bare tail name — via _python_module_pytest_bindings, which records ordered module-level binding events replayed per decorator site through _python_binding_at, so a def fixture/del pytest before a decorator (or a rebinding after an earlier genuine @fixture) resolves to the binding actually in force. A wildcard from pytest import * records no events and is conservatively not treated as pytest vocabulary.

Worth a look

  • Decorator tail extraction preserves whitespace after dotted attributesgraphify/extractors/engine.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • pytest_symbol binding for mark treated as decorator, but @mark bare use returns True incorrectlygraphify/extractors/engine.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 907 functions depend on the 466 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 27 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 27 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 61 callees
  • …and 8 more — each is listed as a finding

Verification — 907 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: 847 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

268 of 268 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/test_affected_cli.py — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.py — full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — impact, full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — impact, full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — impact, full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • tests/test_csharp_enum_members.py — full-run-safety
  • tests/test_csharp_field_generic_args.py — full-run-safety
  • … and 218 more

non-code file(s) changed (CHANGELOG.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (CHANGELOG.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

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.

Formal verification

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_python\_decorator\_name.

The verifier did not have enough to check \_python\_decorator\_name, 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: not verifiable: all 28 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

· 16 more finding(s) on lines outside this diff (see the check run).

)

Python decorator extraction resolves decorators by their bare tail symbol,
so third-party pytest decorators (@pytest.fixture, @pytest.mark.parametrize)
collapse onto same-named functions in the corpus when one exists, minting
spurious reference edges and misleading high-degree nodes.

Track pytest bindings at module scope in source order and classify a
decorator as pytest vocabulary only when the binding in force at its source
location proves it: via the pytest module root (import pytest, import pytest
as pt), a decorator-capable direct import (from pytest import fixture), or
the mark root (from pytest import mark) used qualified (@mark.x / @m.x).
Rebindings (def/class/assignment/for/walrus/with) and del statements shadow
the name and restore normal edges. A wildcard from pytest import * records
nothing and is conservatively treated as non-pytest.

Corpus-owned decorators (bare @fixture, @mark) keep their edges.

Co-authored-by: graphify-labs bot review feedback (Graphify-Labs#2739 findings).
@aayush598
aayush598 force-pushed the fix/2732-decorator-noise branch from 39bef83 to 71bb212 Compare September 11, 2026 20:59
@aayush598

Copy link
Copy Markdown
Author

Thanks for the review. Both "Worth a look" findings are addressed; the branch was also rebased onto current v8 and squashed into a single clean commit (71bb212, was d2af536 + an update-merge).

1. "Decorator tail extraction preserves whitespace after dotted attributes" — not reproducible; no code change.
_python_decorator_path / _python_decorator_name read the tree-sitter node extent only. An attribute node's extent ends exactly at its last identifier — trailing whitespace (or a comment) after it is a sibling node, never included. Verified byte-level: @pytest.fixture → attribute extent is exactly pytest.fixture (ends at the final e); @app.route( "/x" )call's function field is app.route (parens/space outside). No path by which trailing whitespace can reach deco_path, so classification never sees it. If you'd like, I can add a unit test asserting the trimmed path as proof in CI.

2. "pytest_symbol binding for mark treated as decorator … bare @mark returns True" — valid; fixed.
Root cause: from pytest import mark recorded the same pytest_symbol kind as the decorator roots (fixture/hookimpl/hookspec), and the classifier short-circuited that kind to True — so a bare @mark was incorrectly suppressed even though pytest's mark (MarkGenerator) is not callable.

Now mark is recorded as a distinct pytest_mark kind. The classifier treats it as pytest vocabulary only when used qualified — @mark.x, including the aliased form @m.x from from pytest import mark as m — while a bare @mark keeps its decorator edge.

Regression tests added:

  • test_pytest_mark_bare_is_not_a_decorator — bare @mark edge preserved.
  • test_pytest_mark_alias_qualified_is_suppressed@m.parametrize still suppressed.

Prior behavior is unchanged: 31/31 decorator tests pass and the full suite shows no new failures (the profile of pre-existing environmental failures is identical).

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Suppresses false decorator edges from pytest's ambient decorators (@pytest.fixture, @pytest.mark.*, @pytest.hookimpl/hookspec, and from pytest import-rebound forms) so the unique-function rewire no longer collapses them onto same-named local definitions in a corpus. Matches pytest decorators by qualified path and by an ordered, source-position–aware replay of module-level bindings (_python_module_pytest_bindings / _python_binding_at) rather than by bare tail symbol, so a corpus's own @fixture keeps its edge, and a def/class/assignment/del that rebinds or unbinds pytest before a decorator site is honored. Only module-level bindings are tracked and a wildcard from pytest import * records nothing, so those cases are conservatively left un-suppressed.

Worth a look

  • Non-pytest imports do not shadow prior pytest decorator bindingsgraphify/extractors/engine.py:6468 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Decorator names include whitespace for valid spaced attribute syntaxgraphify/extractors/engine.py:6595 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 909 functions depend on the 468 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 27 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 27 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 61 callees
  • …and 8 more — each is listed as a finding

Verification — 909 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: 849 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

268 of 268 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/test_affected_cli.py — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.py — full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — impact, full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — impact, full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — impact, full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • tests/test_csharp_enum_members.py — full-run-safety
  • tests/test_csharp_field_generic_args.py — full-run-safety
  • … and 218 more

non-code file(s) changed (CHANGELOG.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (CHANGELOG.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests

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.

Formal verification

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_python\_decorator\_name.

The verifier did not have enough to check \_python\_decorator\_name, 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: not verifiable: all 28 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

· 16 more finding(s) on lines outside this diff (see the check run).

@aayush598 aayush598 closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant