diff --git a/CHANGELOG.md b/CHANGELOG.md index 14349bc..800ed31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.42 (unreleased) +- Fix: a cross-file INFERRED `uses` edge now binds to the symbol whose body actually references the imported name (a module-level function is a valid source; a co-located class that never touches the import gets no edge), instead of fanning out from the import line to every class in the importing file (#2652, thanks @ousamabenyounes). A reference at module top level, with no enclosing symbol, emits no edge. - Feature: OCaml `.ml`/`.mli` extraction via tree-sitter-ocaml (optional `[ocaml]` extra). Extracts modules, top-level and module-level values/functions, types and their variant constructors, `open` imports, and function calls; qualified calls (`Geo.area`) resolve to the value, and cross-file `open`/call targets collapse onto the unique real definition via the corpus stub rewire. - Fix: a JS/TS `for...of` / `for...in` loop binding is now shadowed, so passing it as a call argument no longer fabricates an `indirect_call` edge to an unrelated same-named callable (#2685, thanks @ousamabenyounes); completes the loop/closure/catch shadow family (#2568/#2569/#2517). - Fix: graph provenance (`built_at_commit`) is stamped from the analysed repository rather than the shell's working directory, so `graphify extract` run from elsewhere records the target's commit, not the caller's (#2534 family; #2699, thanks @C0KERNEL). diff --git a/tests/test_extract.py b/tests/test_extract.py index e514205..16f2319 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -3494,3 +3494,41 @@ def test_inferred_uses_edge_follows_an_import_alias(tmp_path): result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) assert ("api_handler", "helpers_helper") in _inferred_uses(result) + + +def test_inferred_uses_edge_emitted_once_per_referencing_symbol(tmp_path): + """Each symbol that references the import gets its own edge, and only those + symbols do — guards against the old fan-out (every class in the file) and + against collapsing distinct sources into one (#2652).""" + (tmp_path / "helpers.py").write_text("class Helper:\n pass\n", encoding="utf-8") + (tmp_path / "api.py").write_text( + "from helpers import Helper\n\n\n" + "def a(x):\n return Helper()\n\n\n" + "def b(x):\n return Helper()\n\n\n" + "def c(x):\n return x\n", # references nothing -> no edge + encoding="utf-8", + ) + + result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) + uses = _inferred_uses(result) + + assert ("api_a", "helpers_helper") in uses + assert ("api_b", "helpers_helper") in uses + assert ("api_c", "helpers_helper") not in uses + + +def test_inferred_uses_edge_dropped_for_module_top_level_reference(tmp_path): + """A reference at true module top level has no enclosing symbol to anchor on, + so no INFERRED `uses` edge is emitted (rather than falling back to the file + node) — the deliberate drop documented for #2652.""" + (tmp_path / "helpers.py").write_text("class Helper:\n pass\n", encoding="utf-8") + (tmp_path / "api.py").write_text( + "from helpers import Helper\n\n\n" + "SENTINEL = Helper()\n", # top-level, outside any def/class + encoding="utf-8", + ) + + result = extract([tmp_path / "api.py", tmp_path / "helpers.py"], cache_root=tmp_path) + uses = _inferred_uses(result) + + assert not any(tgt == "helpers_helper" for _, tgt in uses)