175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""Forget parsed files from the graph while keeping every derived layer sane.
|
|
|
|
Dropping a file's own nodes and edges is not enough to match the graph a full
|
|
rebuild without that file would produce:
|
|
|
|
* surviving files that referenced it keep dangling, still-qualified edges
|
|
(a call resolved to ``other.py::helper`` stays pointing at a node that no
|
|
longer exists instead of falling back to the bare ``helper``);
|
|
* the derived layers — execution flows, communities, the FTS index, and
|
|
embeddings — continue to reference the deleted nodes.
|
|
|
|
``forget_files`` therefore removes the files, re-parses the surviving referrers
|
|
so their cross-file edges are re-derived exactly as a build would, re-runs the
|
|
repository-wide Python import resolver and shared post-processing pipeline
|
|
(which fully recomputes flows, communities, signatures, and FTS and re-resolves
|
|
bare endpoints), and purges embedding vectors whose node is gone. The result is
|
|
equivalent to building the graph without the forgotten files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .graph import GraphStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Keep IN-clause windows comfortably under SQLite's default 999-variable limit.
|
|
_SQL_PARAM_CHUNK = 400
|
|
|
|
|
|
def _referrer_files(
|
|
store: GraphStore,
|
|
deleted_qualified_names: set[str],
|
|
forgotten: set[str],
|
|
) -> list[str]:
|
|
"""Return surviving files whose edges point at any forgotten node.
|
|
|
|
Those edges are precisely the ones a rebuild would re-derive (usually
|
|
dropping back to a bare endpoint), so the files owning them must be
|
|
re-parsed for parity.
|
|
"""
|
|
if not deleted_qualified_names:
|
|
return []
|
|
conn = store._conn
|
|
referrers: set[str] = set()
|
|
names = list(deleted_qualified_names)
|
|
for start in range(0, len(names), _SQL_PARAM_CHUNK):
|
|
window = names[start:start + _SQL_PARAM_CHUNK]
|
|
placeholders = ",".join("?" for _ in window)
|
|
rows = conn.execute(
|
|
f"SELECT DISTINCT file_path FROM edges "
|
|
f"WHERE target_qualified IN ({placeholders}) "
|
|
f"OR source_qualified IN ({placeholders})",
|
|
window + window,
|
|
).fetchall()
|
|
referrers.update(row["file_path"] for row in rows)
|
|
return sorted(referrers - forgotten)
|
|
|
|
|
|
def _purge_orphan_embeddings(store: GraphStore) -> int:
|
|
"""Delete embedding vectors whose graph node no longer exists.
|
|
|
|
Mirrors :meth:`embeddings.EmbeddingStore.purge_orphans` but runs on the
|
|
graph's own connection so we never open a second writer. A graph without
|
|
an embeddings table is a no-op.
|
|
"""
|
|
conn = store._conn
|
|
has_table = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'embeddings'"
|
|
).fetchone()
|
|
if has_table is None:
|
|
return 0
|
|
cursor = conn.execute(
|
|
"DELETE FROM embeddings WHERE NOT EXISTS ("
|
|
"SELECT 1 FROM nodes WHERE nodes.qualified_name = embeddings.qualified_name"
|
|
")"
|
|
)
|
|
return max(cursor.rowcount, 0)
|
|
|
|
|
|
def forget_files(
|
|
store: GraphStore,
|
|
repo_root: Path,
|
|
targets: list[str],
|
|
) -> dict[str, Any]:
|
|
"""Remove ``targets`` from the graph and repair every derived layer.
|
|
|
|
Args:
|
|
store: An open graph store.
|
|
repo_root: Repository root, used to re-parse surviving referrers.
|
|
targets: Absolute file paths (as stored in the graph) to forget.
|
|
|
|
Returns:
|
|
A summary dict with the forgotten files, the referrer files that were
|
|
re-parsed, and the number of orphaned embedding vectors purged.
|
|
"""
|
|
from .parser import CodeParser
|
|
from .postprocessing import run_post_processing
|
|
from .python_resolver import resolve_python_imports
|
|
|
|
forgotten = set(targets)
|
|
|
|
# 1. Snapshot the qualified names about to disappear so we can find the
|
|
# surviving files that reference them (before we delete anything).
|
|
deleted_qualified_names: set[str] = set()
|
|
for file_path in targets:
|
|
for node in store.get_nodes_by_file(file_path):
|
|
deleted_qualified_names.add(node.qualified_name)
|
|
|
|
referrers = _referrer_files(store, deleted_qualified_names, forgotten)
|
|
|
|
# 2. Drop the forgotten files' own nodes and edges.
|
|
for file_path in targets:
|
|
store.remove_file_data(file_path)
|
|
# Persist deletions before store_file_nodes_edges() opens its own
|
|
# explicit transaction (BEGIN IMMEDIATE) during the re-parse below.
|
|
store.commit()
|
|
|
|
# 3. Re-parse the surviving referrers so their cross-file edges are
|
|
# re-derived exactly as a build would: edges that had resolved into a
|
|
# forgotten file fall back to bare and are re-resolved against the
|
|
# smaller graph, while edges into other survivors are preserved. The
|
|
# forgotten files are hidden from import resolution so a still-on-disk
|
|
# file is not silently re-resolved (forget removes it from the graph,
|
|
# not from the working tree).
|
|
parser = CodeParser(repo_root)
|
|
parser.exclude_files(forgotten)
|
|
reparsed: list[str] = []
|
|
for file_path in referrers:
|
|
abs_path = Path(file_path)
|
|
if not abs_path.is_file():
|
|
# Referrer is gone from disk; nothing to re-parse. Its stale edges
|
|
# are cleaned up by post-processing's bare re-resolution below.
|
|
continue
|
|
if parser.detect_language(abs_path) is None:
|
|
continue
|
|
try:
|
|
source = abs_path.read_bytes()
|
|
fhash = hashlib.sha256(source).hexdigest()
|
|
nodes, edges = parser.parse_bytes(abs_path, source)
|
|
store.store_file_nodes_edges(str(abs_path), nodes, edges, fhash)
|
|
reparsed.append(file_path)
|
|
except (OSError, PermissionError) as exc:
|
|
logger.warning("Could not re-parse referrer %s: %s", file_path, exc)
|
|
except Exception as exc: # noqa: BLE001 - a parser failure is non-fatal
|
|
logger.warning("Error re-parsing referrer %s: %s", file_path, exc)
|
|
|
|
# 4. Re-run repository-wide Python import resolution. A forgotten file can
|
|
# turn an ambiguous module suffix into a unique survivor even when the
|
|
# import edge did not directly target the forgotten node, so referrer
|
|
# re-parsing alone cannot discover this transition.
|
|
try:
|
|
resolve_python_imports(store)
|
|
except Exception as exc: # noqa: BLE001 - resolver failure is non-fatal
|
|
logger.warning("Python import resolver failed after forget: %s", exc)
|
|
|
|
# 5. Re-run the shared post-processing pipeline. store_flows and
|
|
# store_communities clear their tables first, so flows and communities
|
|
# are fully recomputed; signatures and FTS are rebuilt; and any edge
|
|
# left bare by the re-parse is re-resolved.
|
|
run_post_processing(store)
|
|
|
|
# 6. Drop embedding vectors that now reference a deleted node.
|
|
purged = _purge_orphan_embeddings(store)
|
|
|
|
return {
|
|
"forgotten": sorted(forgotten),
|
|
"reparsed": reparsed,
|
|
"embeddings_purged": purged,
|
|
}
|