relativize symbol node IDs for root-level files to match spec (fixes #1096)

The #1033 remap relativized file node IDs but symbol IDs still embedded the
absolute parent-dir stem, so a root-level file's symbols became
<rootdir>_main_run while the file node was correctly main and the skill.md
spec (and semantic subagent) wants main_run -- splitting every top-level
file's symbols into AST/semantic ghost pairs.

Extend the remap chokepoint to also canonicalize the symbol stem prefix
(old _file_node_id(path) -> new _file_node_id(rel)), gated by source_file so
two files sharing a prefix cannot cross-contaminate. raw_calls.caller_nid is
rewritten too, since the cross-file call pass consumes it after the remap and
would otherwise dangle. No-op for nested files (immediate parent identical in
absolute and relative form).

The originally-filed root cause (facts-collector path form) was a stale-cache
red herring; the real bug is the symbol-level continuation of #1033.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-31 17:09:30 +01:00
parent 88a8e3bc91
commit ad0c8c0a62
2 changed files with 84 additions and 0 deletions
+45
View File
@@ -10404,6 +10404,14 @@ def extract(
# subagents generate (#1033). Resolve before relativizing so paths passed in
# relative form still anchor to the (resolved) root.
id_remap: dict[str, str] = {}
# Symbol node IDs embed the file stem as a prefix (_file_node_id of the path
# the extractor saw). For a root-level file that stem picks up the absolute
# parent directory name, so a symbol becomes <rootdir>_main_run while the
# file node is correctly relativized to main and the skill.md spec wants
# main_run -- splitting the symbol into AST/semantic ghosts (#1096). Relativize
# the symbol prefix the same way, gated by source_file so two files sharing a
# prefix can't cross-contaminate. Keyed by resolved path -> (old_pref, new_pref).
prefix_remap: dict[Path, tuple[str, str]] = {}
for path in paths:
old_id = _make_id(str(path))
try:
@@ -10416,6 +10424,9 @@ def extract(
new_id = _file_node_id(rel)
if old_id != new_id:
id_remap[old_id] = new_id
old_pref = _file_node_id(path)
if old_pref != new_id:
prefix_remap[path.resolve()] = (old_pref, new_id)
if id_remap:
for n in all_nodes:
if n.get("id") in id_remap:
@@ -10425,6 +10436,40 @@ def extract(
e["source"] = id_remap[e["source"]]
if e.get("target") in id_remap:
e["target"] = id_remap[e["target"]]
if prefix_remap:
sym_remap: dict[str, str] = {}
for n in all_nodes:
sf = n.get("source_file")
if not sf:
continue
try:
entry = prefix_remap.get(Path(sf).resolve())
except Exception:
continue
if entry is None:
continue
old_pref, new_pref = entry
nid = n.get("id", "")
if nid.startswith(old_pref + "_"):
new_nid = new_pref + nid[len(old_pref):]
if new_nid != nid:
sym_remap[nid] = new_nid
if sym_remap:
for n in all_nodes:
if n.get("id") in sym_remap:
n["id"] = sym_remap[n["id"]]
for e in all_edges:
if e.get("source") in sym_remap:
e["source"] = sym_remap[e["source"]]
if e.get("target") in sym_remap:
e["target"] = sym_remap[e["target"]]
# raw_calls carry caller_nid (a symbol id) consumed by the cross-file
# call pass below, after this remap — rewrite it too or those edges
# would dangle on their (stale) source.
for rc in all_raw_calls:
cn = rc.get("caller_nid")
if cn in sym_remap:
rc["caller_nid"] = sym_remap[cn]
_merge_swift_extensions(per_file, all_nodes, all_edges)
_disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root)
+39
View File
@@ -54,6 +54,45 @@ def test_top_level_file_node_id_is_bare_stem(tmp_path):
assert "setup_py" not in ids
def test_top_level_file_SYMBOL_ids_use_bare_stem(tmp_path):
"""A SYMBOL in a root-level file must use the bare-stem prefix (`setup_configure`),
not pick up the project-root directory name (`<rootdir>_setup_configure`). The
semantic subagent emits the bare-stem form per skill.md, so an absolute-parent
stem here splits the symbol into two ghost nodes (#1096). Pass ABSOLUTE paths,
as the CLI does, to exercise the root-relative remap."""
f = tmp_path / "main.py"
f.write_text("def run():\n return 1\n")
extraction = extract([f.resolve()], cache_root=tmp_path)
ids = {n["id"] for n in extraction["nodes"]}
assert "main_run" in ids, f"expected bare-stem symbol 'main_run', got {sorted(ids)}"
# The root directory name must NOT appear in any symbol id.
rootname = tmp_path.name.lower().replace("-", "_")
assert not any(rootname in i for i in ids), (
f"root dir name leaked into ids: {sorted(ids)}"
)
# contains edge file -> symbol must connect with the canonical ids.
contains = [e for e in extraction["edges"]
if e["relation"] == "contains" and e["target"] == "main_run"]
assert contains and contains[0]["source"] == "main"
def test_nested_file_symbol_ids_unchanged(tmp_path):
"""Regression guard: nested files (immediate parent identical in abs/rel form)
must be completely unaffected by the symbol remap."""
sub = tmp_path / "sub"
sub.mkdir()
f = sub / "mod.py"
f.write_text("def work():\n return 2\n")
extraction = extract([f.resolve()], cache_root=tmp_path)
ids = {n["id"] for n in extraction["nodes"]}
assert "sub_mod" in ids
assert "sub_mod_work" in ids
def test_symbol_and_file_ids_share_the_same_stem(tmp_path):
"""Symbol ids already use {parent}_{stem}_{name}; the file node must share
that stem prefix so 'contains' edges connect file -> symbol."""