From cf4b4ef85a72c407b5e1cb5e0678faa0497a2747 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Fri, 3 Jul 2026 00:32:24 +0100 Subject: [PATCH] fix(build): don't crash when a node's source_file is the scan root (#1618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node whose source_file equals the absolute scan root (e.g. a project-level semantic concept the LLM attributed to the whole repo) relativized to Path('.'), and _semantic_id_remap fed that into _file_stem, whose path.with_suffix("") raises `ValueError: '.' has an empty name`. The crash landed in final graph assembly — AFTER all LLM extraction cost was spent — writing no graph.json at all, and leaving `cluster-only` to then report "no graph found". Two guards: _file_stem returns "" for a name-less path (protects every caller, not just this one), and both _semantic_id_remap passes skip a root-equal source_file explicitly (it has no per-file identity to remap — id left untouched). Reported with a minimal LLM-free repro by @sub4biz. Not a 0.9.5 regression: _semantic_id_remap/_file_stem are byte-identical to 0.9.4; the latent path was only hit when dedup produced a root-source_file node. 4 regression tests (dot-path stem, remap no-crash, build_from_json with a root-level concept node, normal remap unaffected). Full suite 2849. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + graphify/build.py | 7 ++++ graphify/extractors/base.py | 9 ++++- tests/test_semantic_id_remap_root.py | 50 ++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/test_semantic_id_remap_root.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9457048..2707be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Fix: `extract` no longer crashes during final graph assembly when a node's `source_file` equals the scan root (#1618, thanks @sub4biz). Such a node (e.g. a project-level semantic concept the LLM attributed to the whole repo) relativized to `Path('.')`, and `_file_stem`'s `path.with_suffix("")` raised `ValueError: '.' has an empty name` — crashing *after* all LLM extraction cost was spent and writing no `graph.json` at all. `_file_stem` now returns `""` for a name-less path, and `_semantic_id_remap` skips the root-equal node (it has no per-file identity to remap, so its id is left untouched). Not a 0.9.5 regression — the latent code was hit only when dedup happened to produce a root-`source_file` node. - Feat: C# receiver-typed member-call resolution (#1609, thanks @JensD-git). `recv.Method()` where `recv` is a typed field, property, parameter, or local now resolves to the receiver *type's* method. C# previously had no member-call resolver, so the bare method name matched any same-named method in the corpus — `_server.Save()` silently mis-bound to an unrelated `Cache.Save()` (a wrong edge, not just a missing one), leaving delegation-heavy call graphs blind across typed boundaries. The receiver is now typed from a per-file field/property/param/local table (incl. `var v = new T()`) and resolved with the single-definition god-node guard; `this.M()` binds to the enclosing class and `Type.M()` to the named type. An untypable receiver (e.g. `dynamic`) or a method absent on the type produces no edge — precision over recall, matching the Swift/C++/Python resolvers. - Fix: `graphify cluster-only` now writes `.graphify_analysis.json` alongside `graph.json` (#1617 / #1610, thanks @sanmaxdev). Without it, a re-cluster left a stale/absent sidecar and a later `export html` silently reported "Single community". The sidecar now carries communities/cohesion/gods/surprises/questions, matching the full extract path. - Fix: `.mts` / `.cts` (TypeScript module extensions) are now treated as TypeScript (#1607, thanks @ashmitg). They were missing from the code-extension set and the JS/TS language maps, so `.mts`/`.cts` sources were detected as non-code and silently skipped. diff --git a/graphify/build.py b/graphify/build.py index a29819d..279eccb 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -258,6 +258,11 @@ def _semantic_id_remap(nodes: list, root: str | None) -> dict: rel = Path(sf_norm) if rel.is_absolute(): continue # can't relativize (no/failed root) — leave id untouched + if not rel.name: + # source_file equals the scan root, so _norm_source_file relativized it + # to Path('.') — a project-level node with no per-file identity to remap. + # Leave its id untouched (and avoid _file_stem's empty-name crash, #1618). + continue new_stem = make_id(_file_stem(rel)) if not new_stem: continue @@ -305,6 +310,8 @@ def graph_has_legacy_ids(nodes: list, root: str | Path | None = None, sample: in rel = Path(_norm_source_file(str(sf), _r) or str(sf)) if rel.is_absolute(): continue + if not rel.name: + continue # source_file == scan root -> Path('.'), no file stem (#1618) new_stem = make_id(_file_stem(rel)) if not new_stem: continue diff --git a/graphify/extractors/base.py b/graphify/extractors/base.py index a414b54..fa2e5b5 100644 --- a/graphify/extractors/base.py +++ b/graphify/extractors/base.py @@ -51,7 +51,14 @@ def _file_stem(path: Path) -> str: Top-level files keep a bare stem (``setup.py`` -> ``setup``). When passed an absolute path the whole path is encoded; the extract() id-remap post-pass re-derives the canonical repo-relative form from ``source_file`` so the on-disk - location can't leak into the persisted IDs (#502).""" + location can't leak into the persisted IDs (#502). + + Returns "" for a path with no name (``Path('.')`` — a source_file that equals + the scan root, so it has no per-file stem). Guarding here keeps + ``path.with_suffix("")`` from raising ``ValueError: '.' has an empty name`` and + protects every caller, not just ``_semantic_id_remap`` (#1618).""" + if not path.name: + return "" return path.with_suffix("").as_posix() diff --git a/tests/test_semantic_id_remap_root.py b/tests/test_semantic_id_remap_root.py new file mode 100644 index 0000000..ab85bd6 --- /dev/null +++ b/tests/test_semantic_id_remap_root.py @@ -0,0 +1,50 @@ +"""A node whose source_file equals the scan root must not crash build (#1618). + +`_norm_source_file` relativizes an absolute source_file that equals the scan root +to `Path('.')`. `_semantic_id_remap` then fed that into `_file_stem`, whose +`path.with_suffix("")` raises `ValueError: '.' has an empty name` — crashing the +final graph assembly AFTER all LLM extraction cost was spent, writing no graph at +all. A project-level node (source_file == root) has no per-file identity to remap, +so its id is left untouched. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.build import _semantic_id_remap, build_from_json +from graphify.extractors.base import _file_stem + + +def test_file_stem_handles_dot_path(): + assert _file_stem(Path(".")) == "" # no raise + assert _file_stem(Path("src/foo.py")) == "src/foo" + + +def test_semantic_id_remap_root_equal_source_file_no_crash(): + root = "/some/project/root" + node = {"id": "some_concept", "source_file": root, "_origin": "semantic"} + remap = _semantic_id_remap([node], root) # must not raise + # a root-equal node has no file stem, so its id is left untouched (not remapped) + assert "some_concept" not in remap + + +def test_build_from_json_with_root_level_concept_node(): + root = "/proj" + combined = { + "nodes": [ + {"id": "proj_concept", "label": "Project", "file_type": "concept", + "source_file": root, "_origin": "semantic"}, + {"id": "src_foo", "label": "foo", "file_type": "code", + "source_file": "src/foo.py", "_origin": "ast"}, + ], + "edges": [], + } + G = build_from_json(combined, root=root) # previously crashed here + assert G.number_of_nodes() == 2 + + +def test_normal_semantic_remap_still_works(): + # regression guard: a real per-file node still gets remap consideration (#1504) + remap = _semantic_id_remap( + [{"id": "foo", "source_file": "src/foo.py", "_origin": "semantic"}], "/proj") + assert isinstance(remap, dict)