fix(build): don't crash when a node's source_file is the scan root (#1618)

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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-03 00:32:24 +01:00
co-authored by Claude Opus 4.8
parent eebc406773
commit cf4b4ef85a
4 changed files with 66 additions and 1 deletions
+1
View File
@@ -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.
+7
View File
@@ -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
+8 -1
View File
@@ -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()
+50
View File
@@ -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)