Files
graphify/tests/test_extraction_spec_ids.py
T
safishamsi b46634ef7a fix(ids): node IDs include the full repo-relative path (#1504, #1509)
BREAKING node-ID format change. The stem that prefixes every node id was the
immediate parent dir + filename, so same-named files in different directories
collided into one last-writer-wins node and silently dropped graph content
(docs/v1/api/README.md and docs/v2/api/README.md both -> api_readme). The stem is
now the full repo-relative path (docs_v1_api_readme vs docs_v2_api_readme);
top-level files are unchanged (setup.py -> setup).

- extractors/base.py::_file_stem -> full path (as_posix; make_id collapses
  separators). The two hand-copied stems (symbol_resolution, mcp_ingest) now
  import the canonical one, so they can't drift again.
- llm.py system prompt + extraction-spec fragments aligned to the same rule,
  fixing the #1509 AST<->LLM divergence (prompt used zero parent dirs -> ghosts).
  Regenerated + blessed the per-host specs.
- build_from_json deterministically re-keys every non-AST node id from its
  source_file via the new stem (and registers old-stem aliases), so a cached or
  pre-migration semantic fragment carrying an old short id reconciles with the
  AST node instead of spawning a ghost. The semantic cache is unversioned, so
  this code-side re-key (not LLM prose) is what makes it survive the format change
  with no re-bill. AST nodes are already canonical and skipped.
- Migration: existing graphs migrate automatically on the next build/update (the
  re-key runs in build_from_json, including the whole graph fed back through
  build_merge); `graphify extract --force` for a clean rebuild. Neo4j persisted
  stores need a re-import; GraphML layouts go stale.

Full suite green (2505 passed); #1504 collision fixed and old-id fragments
re-keyed verified by smoke. Lands on v9 for review before a 0.9.0 release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:57:00 +01:00

97 lines
4.0 KiB
Python

"""Drift guard for the node-ID spec shown to LLM semantic subagents.
`tools/skillgen/fragments/references/shared/extraction-spec.md` (and the per-host
copies skillgen renders from it) tell the subagents how to build node IDs, with
worked examples like::
`src/auth/session.py` + `ValidateToken` → `auth_session_validatetoken`
Those examples are the LLM's ground truth, and they must reproduce exactly what
the AST extractor emits (`extract._file_stem` + `extract._make_id`) or the two
producers generate different IDs for the same symbol and the graph splits it into
disconnected ghost nodes — the bug class the prose itself warns about
(#811/#550/#1033/#1104).
The prose is hand-maintained, so it can silently drift from the code. This test
parses every example straight out of the shipped specs and asserts the real
functions reproduce each one. It fails if the spec examples are edited to a wrong
value, OR if the ID functions change so the documented examples no longer hold.
"""
import re
from pathlib import Path
import pytest
from graphify.extract import _file_stem, _make_id
REPO_ROOT = Path(__file__).resolve().parents[1]
# `path` + `entity` → `id` (arrow is U+2192). Backtick-delimited so prose around
# the examples never leaks in.
_EXAMPLE_RE = re.compile(r"`([^`]+)`\s*\+\s*`([^`]+)`\s*→\s*`([^`]+)`")
def _spec_files() -> list[Path]:
roots = [REPO_ROOT / "graphify" / "skills", REPO_ROOT / "tools" / "skillgen" / "fragments"]
files: list[Path] = []
for root in roots:
for p in root.rglob("extraction-spec.md"):
# build/ is a packaging artifact; expected/ is skillgen's own golden
# output and is already covered by `skillgen --check`.
if "/build/" in p.as_posix() or "/expected/" in p.as_posix():
continue
files.append(p)
return sorted(files)
def _examples() -> list[tuple[Path, str, str, str]]:
out: list[tuple[Path, str, str, str]] = []
for f in _spec_files():
text = f.read_text(encoding="utf-8")
for path, entity, expected in _EXAMPLE_RE.findall(text):
out.append((f, path, entity, expected))
return out
def _ast_symbol_id(path: str, entity: str) -> str:
"""Reproduce the symbol ID the AST extractor emits for a file + symbol, using
the real production helpers (not a re-implementation)."""
return _make_id(_file_stem(Path(path)), entity)
def test_spec_files_are_discoverable():
"""Guard the guard: if the spec moves or the example format changes so nothing
parses, fail loudly rather than passing vacuously."""
files = _spec_files()
assert files, "no extraction-spec.md files found — did the skills tree move?"
examples = _examples()
assert len(examples) >= 13, (
f"expected the documented node-ID examples across host specs, parsed only "
f"{len(examples)} — the example format may have changed"
)
@pytest.mark.parametrize(
"path,entity,expected",
[(p, e, x) for (_f, p, e, x) in _examples()],
ids=[f"{f.parent.parent.name}:{p}+{e}" for (f, p, e, x) in _examples()],
)
def test_spec_node_id_examples_match_ast_extractor(path, entity, expected):
got = _ast_symbol_id(path, entity)
assert got == expected, (
f"node-ID spec drift: spec says `{path}` + `{entity}` → `{expected}`, but "
f"extract._make_id(_file_stem(...), ...) produces `{got}`. Update the spec "
f"examples and the ID functions together."
)
def test_cautionary_wrong_forms_are_actually_wrong():
"""The canonical spec warns against the filename-only and full-path ID forms.
Lock those anti-examples to the code too, so the warning can't go stale."""
correct = _ast_symbol_id("src/auth/session.py", "ValidateToken")
assert correct == "src_auth_session_validatetoken"
# filename-only (drops every dir) and immediate-parent-only (drops outer dirs)
# are both wrong now that the stem is the full repo-relative path (#1504).
assert _make_id("session", "ValidateToken") != correct
assert _make_id("auth", "session", "ValidateToken") != correct