mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
fix(build): make _semantic_id_remap idempotent to stop id accretion (#1917)
An id whose canonical stem contains its legacy stem as a prefix (parent dir name == file stem, e.g. .claude/CLAUDE.md) re-matched the legacy branch every build and grew another stem segment, defeating the same_topology/no_change short-circuits and churning graph.json + clustering on every zero-delta update. Skip an id that already carries its canonical stem (mirrors graph_has_legacy_ids); genuine legacy migration still applies. Also records the #1889/#1918 query-scoring perf work in the changelog. 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
c0875ccae9
commit
19c496dd63
@@ -4,6 +4,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## 0.9.17 (unreleased)
|
||||
|
||||
- Fix: `_semantic_id_remap` is now idempotent, so incremental rebuilds stop churning (#1917). When a file's canonical stem contained its own legacy stem as a prefix (parent dir name equals the file stem, e.g. `.claude/CLAUDE.md`, `docs/docs.md`), an already-migrated semantic node id re-matched the legacy branch and gained another stem segment on every build (`claude_x` -> `claude_claude_x` -> ...). Because `_origin` is persisted, every `graphify update` re-fed nodes through the remap, so the ids grew unboundedly and the `same_topology`/`same_graph`/`no_change` short-circuits never fired — rewriting `graph.json` and re-running clustering on every zero-delta update. The remap now skips an id that already carries its canonical stem (mirroring the `graph_has_legacy_ids` check), while a genuine one-time legacy migration still applies. (An already-corrupted graph needs one `graphify extract --force` to reset the grown ids.)
|
||||
- Perf: `graphify query` now scores the graph once per query instead of T+1 times for a T-term query (#1889 / #1918, thanks @Sirhan1). The per-term-guarantee (#1445) previously re-scored the whole graph once per token; `_score_query` now computes the combined ranking and each token's singleton winner in a single traversal, feeding `_pick_seeds` via `best_seed_by_term`. Behavior is preserved (verified byte-identical against the old per-term scoring across a differential fuzz); ~1.3-1.4x faster and independent of query length.
|
||||
|
||||
|
||||
- Fix: `--mode deep` is now effective over a warm cache instead of a silent no-op (#1894). The semantic cache is namespaced by mode (`semantic` vs `semantic-deep`) so a shallow-cached file no longer satisfies a deep run; `graphify extract` gains `--force` (and honors `GRAPHIFY_FORCE`) to bypass the incremental gate and the cache read; and a deep incremental run widens its dispatch to the full live doc set so the deep namespace actually gets populated. Cache prune/clear now sweep both namespaces so the deep cache can't accumulate orphans. (The skill-side flow that passes the mode through is a follow-up; the CLI is complete and backward compatible — the new `mode` argument defaults to the existing behavior.)
|
||||
- Fix: the semantic cache no longer persists dangling edges/hyperedges (#1916). When a node group was skipped on write (out-of-scope per the batch guard, or a ghost `source_file`), edges/hyperedges in the kept groups that referenced those never-written nodes were saved anyway and re-surfaced on every cache replay. Those references are now pruned at write time (gated on the scoping allowlist, so unscoped callers are unchanged), and `build_from_json` validates hyperedge members against the node set so a dangling hyperedge can't reach `graph.json` even from a live extraction.
|
||||
- Fix: `graphify update`/watch no longer produces a bloated graph by double-representing documents (#1915). `_rebuild_code` AST-quick-scanned Markdown/doc files and then preserved their existing semantic (LLM) nodes on top, so each doc appeared twice (a real corpus came out ~4x). A doc that already has semantic nodes in the graph is no longer AST-quick-scanned (its semantic nodes are the sole representation), while a doc with no semantic layer still gets the structural quick-scan; incremental rebuilds now preserve a doc's semantic nodes instead of evicting them, and previously-bloated graphs self-heal on the next full rebuild.
|
||||
|
||||
@@ -287,6 +287,16 @@ def _semantic_id_remap(nodes: list, root: str | None) -> dict:
|
||||
if not new_stem:
|
||||
continue
|
||||
norm_nid = _normalize_id(nid)
|
||||
# Idempotency guard (#1917): an id already carrying its canonical stem is
|
||||
# done — do not re-run the legacy branch on it. When the canonical stem
|
||||
# contains a shorter legacy stem as a prefix (parent dir name == file
|
||||
# stem, e.g. `.claude/CLAUDE.md` -> `claude_claude` over legacy `claude`),
|
||||
# an already-migrated id like `claude_claude_x` still matches the legacy
|
||||
# `claude_` prefix below and would gain another stem segment on every
|
||||
# build, defeating the same_topology/no_change short-circuits. Mirrors the
|
||||
# canonical check in graph_has_legacy_ids.
|
||||
if norm_nid == new_stem or norm_nid.startswith(new_stem + "_"):
|
||||
continue
|
||||
new_id: str | None = None
|
||||
for old_stem in _old_file_stems(rel):
|
||||
if old_stem == new_stem:
|
||||
|
||||
@@ -48,3 +48,36 @@ def test_normal_semantic_remap_still_works():
|
||||
remap = _semantic_id_remap(
|
||||
[{"id": "foo", "source_file": "src/foo.py", "_origin": "semantic"}], "/proj")
|
||||
assert isinstance(remap, dict)
|
||||
|
||||
|
||||
# --- #1917: _semantic_id_remap must be idempotent (no id accretion) ---
|
||||
|
||||
def test_semantic_id_remap_is_idempotent_when_stem_contains_legacy_stem():
|
||||
"""A file whose parent dir name equals its stem (.claude/CLAUDE.md ->
|
||||
canonical `claude_claude`, legacy `claude`) must not re-prefix an
|
||||
already-canonical id on every build (#1917). Without the guard, ids grow
|
||||
`claude_x` -> `claude_claude_x` -> `claude_claude_claude_x` ..., defeating
|
||||
the same_topology/no_change short-circuits."""
|
||||
nodes = [{"id": "claude_graphify_trigger",
|
||||
"source_file": ".claude/CLAUDE.md", "_origin": "semantic"}]
|
||||
first = _semantic_id_remap(nodes, ".")
|
||||
assert first == {"claude_graphify_trigger": "claude_claude_graphify_trigger"}
|
||||
# Feed the migrated ids back through: a second pass must be a fixed point.
|
||||
migrated = [{**n, "id": first.get(n["id"], n["id"])} for n in nodes]
|
||||
assert _semantic_id_remap(migrated, ".") == {}, "id re-prefixed on second build (#1917)"
|
||||
|
||||
|
||||
def test_semantic_id_remap_bare_file_node_is_idempotent():
|
||||
"""The bare file node id follows the same fixed-point rule."""
|
||||
nodes = [{"id": "claude", "source_file": ".claude/CLAUDE.md", "_origin": "semantic"}]
|
||||
first = _semantic_id_remap(nodes, ".")
|
||||
assert first == {"claude": "claude_claude"}
|
||||
migrated = [{"id": "claude_claude", "source_file": ".claude/CLAUDE.md", "_origin": "semantic"}]
|
||||
assert _semantic_id_remap(migrated, ".") == {}
|
||||
|
||||
|
||||
def test_semantic_id_remap_still_migrates_genuine_legacy_id():
|
||||
"""The idempotency guard must not block a real one-time legacy migration:
|
||||
a pre-scheme id under a normal path still remaps once to the canonical stem."""
|
||||
nodes = [{"id": "readme_booking", "source_file": "api/README.md", "_origin": "semantic"}]
|
||||
assert _semantic_id_remap(nodes, ".") == {"readme_booking": "api_readme_booking"}
|
||||
|
||||
Reference in New Issue
Block a user