From ad0c8c0a62a9be41d7cc16bc203d13ffefba6372 Mon Sep 17 00:00:00 2001 From: Safi Date: Sun, 31 May 2026 17:09:30 +0100 Subject: [PATCH] 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 _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 --- graphify/extract.py | 45 +++++++++++++++++++++++++++++++++ tests/test_file_node_id_spec.py | 39 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index 3d677226..939b168b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 _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) diff --git a/tests/test_file_node_id_spec.py b/tests/test_file_node_id_spec.py index 42505667..0f74ec11 100644 --- a/tests/test_file_node_id_spec.py +++ b/tests/test_file_node_id_spec.py @@ -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 (`_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."""