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:
co-authored by
Claude Opus 4.8
parent
eebc406773
commit
cf4b4ef85a
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user