Skip to content

fix(analyze): make the cross-repo/directory surprise bonus match its reason - #3934

Closed
neo1777 wants to merge 1 commit into
Graphify-Labs:v8from
neo1777:fix/surprise-top-level-dir
Closed

neo1777 wants to merge 1 commit into
Graphify-Labs:v8from
neo1777:fix/surprise-top-level-dir

Conversation

@neo1777

@neo1777 neo1777 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

surprising_connections gives an edge +2 and the reason "connects across different repos/directories" when its two endpoints have different top-level directories. The helper that computes the directory, _top_level_dir(), returns the whole path when there is no /:

return path.split("/")[0] if "/" in path else path

So two files at the scan root always have "different top-level directories". I noticed it on a flat folder of Markdown notes, where every surprise carried that reason. Any surprising edge between two root-level files gets it, e.g. between a function in main.py and one in utils.py, unless the existing suppression of INFERRED calls/uses edges applies. Minimal reproducer, AST only, on 0.9.71:

mkdir flat && cd flat
printf 'class Store:\n    def save(self, item):\n        return item\n' > store.py
printf 'from store import Store\n\ndef handle(item):\n    return Store().save(item)\n' > service.py
printf 'from service import handle\n\ndef endpoint(req):\n    return handle(req)\n' > api.py
cd .. && graphify extract flat --code-only --out flat
python -c "import json; [print(s['source_files'], '->', s['why']) for s in json.load(open('flat/graphify-out/.graphify_analysis.json'))['surprises']]"
# v8 (d6eaa8a)
['service.py', 'store.py'] -> connects across different repos/directories; bridges separate communities
['api.py', 'service.py'] -> connects across different repos/directories
# this branch
['service.py', 'store.py'] -> bridges separate communities
['api.py', 'service.py'] -> cross-file semantic connection

(cross-file semantic connection is the existing fallback text when no bonus applies, analyze.py#L345.)

The change, all in analyze.py:

  1. A path without / maps to "." (the scan root). I use "." rather than "" on purpose. _norm_source_file (build.py#L375-L399) leaves a file outside the scan root absolute, and its first component is "". A root file and an out-of-root file must stay distinct, as they are today.
  2. Backslashes and a leading ./ are normalized inside the helper. build_from_json already converts backslashes (it keeps ./), but serve.py's _load_graph() calls node_link_graph directly (serve.py#L73). Without this, pkg\a.py and lib\b.py would both become "." after change 1.
  3. The comparison key becomes (node["repo"], top-level dir) (analyze.py#L266-L269). merge-graphs and global add keep source_file repo-relative and tag each node with repo (build.py#L2391), so two repos can share directory names. The tag is set in prefix_graph_for_global(). Without this, change 1 would drop the bonus on a real cross-repo edge between two root-level files, which today gets it only because the filenames differ.

Type of change

  • Bug fix

Verification & Invariants

Invariant: the bonus, and the reason text, apply only when the two endpoints really are in different top-level directories, or in different repos.

What changes in behaviour:

  • Single-repo graphs. Edges between root-level files no longer get +2 and the false reason. Nodes have no repo, and a plain dir/file path gives the same top-level directory as before. The only other differences are two rare path forms:

    • backslash paths, which on v8 compared as whole strings (build_from_json converts them anyway; _load_graph() doesn't);
    • ./dir/file paths, which on v8 all mapped to ".". extract doesn't produce them.

    Both now resolve to their real top-level directory.

  • Merged graphs. A cross-repo edge now gets the bonus whenever the two repos differ. On v8 it depended on directory names: src/x.py (repo A) → src/y.py (repo B) didn't get it, while src/x.py → lib/y.py did. Edges between two root-level files of the same repo stop getting it, as in single-repo graphs. This changes the surprise ranking of merged graphs. I think it's the behaviour the reason text describes, but it's your call.

  • Read CONTRIBUTING.md.

  • Reproduced the issue and identified the invariant.

  • Smallest fix I could find that doesn't regress merged graphs (one helper, one comparison).

  • Regression tests in tests/test_analyze.py:

    • the flat case, which fails on v8;
    • merged graphs, and backslash and ./ paths, which also fail on v8;
    • two pins that pass before and after: a root file vs a subdirectory file, and a root file vs an out-of-root absolute path.
  • Description kept in sync with the implementation.

  • Limitations: a ./-prefixed path is treated like its unprefixed form. external nodes of merged graphs have no repo (same as today).

No existing issue: I found this while testing on a flat corpus. I searched the title, body and comments of all issues and PRs, open and closed (for _top_level_dir, surprise score, cross-repo, flat directory and root-level files), and the code of every PR branch that touches _top_level_dir; none changes its behaviour.

How was this tested?

uv run pytest tests/test_analyze.py -q                        # 55 passed
uv run pytest tests/test_*merge*.py tests/test_*global*.py tests/test_serve*.py tests/test_report*.py -q   # 282 passed, 2 skipped
uv run pytest tests/ -q                                       # 6014 passed, 101 skipped, 23 failed (see below)
uv run ruff check .                                           # all checks passed
uv run pyright graphify/analyze.py                            # same 2 errors as on v8, none new

The full-suite failures are the Erlang, R, Solidity, VB.NET and ollama tests: I ran uv sync without --all-extras, so openai and the optional grammars are missing. They fail identically on a clean v8 checkout. The new tests also pass under PYTHONHASHSEED 0, 1, 2 and 42.

The branch is based on v8 at 1cd9a36 (0.9.72); the reproducer above ran on 0.9.71 (d6eaa8a), and analyze.py is unchanged between the two.

Environment: Python 3.12.3, uv 0.11.23, Linux Mint 22.3; graphifyy 0.9.71 from PyPI for the reproducer (Leiden via graspologic-native 1.3.1, and the same output with the Louvain fallback).

Graphify-specific checklist

  • Generated skill artifacts: not touched.
  • Deterministic: pure string handling, no ambient state.
  • No unsafe interpolation.
  • No API keys or local graph data included.
  • AI authorship disclosed in the commit (Co-Authored-By).

…reason

_top_level_dir() returned the whole path when it had no "/", so two
files at the scan root (a flat folder of notes, or README.md and
setup.py) got different "top-level dirs". Every such cross-file edge
(other than suppressed INFERRED calls/uses) received +2 and the reason
"connects across different repos/directories" in surprising_connections,
which is false.

- A path without "/" now maps to the scan root ("."). "." rather than ""
  keeps root files distinct from an absolute path outside the scan root,
  whose first component is "".
- Backslashes and a leading "./" are normalized first: build_from_json
  converts backslashes but keeps "./", and serve.py's _load_graph() and
  API callers can skip build_from_json altogether.
- The comparison key is (node "repo", top-level dir). merge-graphs and
  global add keep source_file repo-relative and tag nodes with "repo",
  so two repos can share directory names; single-repo graphs have no
  "repo" and are unaffected. On merged graphs a cross-repo edge now gets
  the bonus whenever the repos differ; before, it depended on whether
  the top-level directory names happened to differ.

Tests: the flat case (fails on d6eaa8a), merged graphs, backslash and
"./" paths, plus pins for what must not change (root vs subdirectory,
root vs out-of-root absolute path).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@neo1777
neo1777 requested a review from safishamsi as a code owner September 29, 2026 21:47
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @neo1777. 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.

safishamsi added a commit that referenced this pull request Sep 30, 2026
Solidity free functions (#3906), VB.NET qualified calls (#3909), Astro
frontmatter-only AST pass (#3902), surprise bonus/reason alignment (#3934),
exclude-hubs stranded-neighbour (#3933), graph-DB push index (#3957),
label retry keep-named (#3956), stale-hook status (#3951), virtual-workspace
Cargo.toml skip (#3930), and symlinked-instructions install/uninstall
handling (#3950/#3953).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@safishamsi

Copy link
Copy Markdown
Member

Shipped in v0.9.73 (now on PyPI) via an authorship-preserving cherry-pick, so your commit keeps contributor-graph credit. Thanks @neo1777!

Release: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.73

@safishamsi safishamsi closed this Sep 30, 2026
@neo1777
neo1777 deleted the fix/surprise-top-level-dir branch October 1, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants