diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cd98d3..bd6306ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/graphify/build.py b/graphify/build.py index b05c8397..caeaefdf 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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: diff --git a/tests/test_semantic_id_remap_root.py b/tests/test_semantic_id_remap_root.py index ab85bd6d..3a5fee62 100644 --- a/tests/test_semantic_id_remap_root.py +++ b/tests/test_semantic_id_remap_root.py @@ -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"}