codepulse-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2655 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from sqlalchemy import inspect as sa_inspect
|
|
13
|
+
|
|
14
|
+
from codepulse.embeddings.chunker import CodeChunk, SemanticChunker
|
|
15
|
+
from codepulse.embeddings.embedder import CodeEmbedder, EmbeddingUnavailableError
|
|
16
|
+
from codepulse.git_diff import GitDiffError, detect_file_changes, get_head_commit_sha
|
|
17
|
+
from codepulse.indexer.ignore import RepositoryIgnoreRules
|
|
18
|
+
from codepulse.ingestion_safety import IngestionSafetyError
|
|
19
|
+
from codepulse.jobs import reap_stale_jobs
|
|
20
|
+
from codepulse.models import CallGraphEdge, FileParseResult, Symbol, SymbolType, compute_content_hash
|
|
21
|
+
from codepulse.parser.factory import get_parser_for_file
|
|
22
|
+
from codepulse.parser.graph import CallGraphBuilder
|
|
23
|
+
from codepulse.parser.module_graph import ModuleGraphBuilder
|
|
24
|
+
from codepulse.repository_identity import canonical_repository_url, find_repository
|
|
25
|
+
from codepulse.search.bm25 import BM25Index
|
|
26
|
+
from codepulse.search.ranker import HybridRanker, SearchResultItem
|
|
27
|
+
from codepulse.search.vector import VectorIndex
|
|
28
|
+
from codepulse.storage.db import (
|
|
29
|
+
CallEdge,
|
|
30
|
+
EmbeddingModel,
|
|
31
|
+
FileModel,
|
|
32
|
+
ModuleEdge,
|
|
33
|
+
RepositoryModel,
|
|
34
|
+
RepositorySnapshotModel,
|
|
35
|
+
StorageManager,
|
|
36
|
+
SymbolModel,
|
|
37
|
+
)
|
|
38
|
+
from codepulse.symbol_identity import chunk_doc_id, symbol_doc_id
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IndexingPersistenceError(RuntimeError):
|
|
44
|
+
"""Saving an index run to the database failed, so the run did not happen as far as the database is concerned."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class IncrementalPlan:
|
|
49
|
+
"""
|
|
50
|
+
Phase 3.1/3.2 -- the outcome of `IndexingEngine._plan_incremental_run`.
|
|
51
|
+
|
|
52
|
+
`is_incremental=False` means `index_directory()` must process every
|
|
53
|
+
discovered file exactly as it always has (this milestone's baseline,
|
|
54
|
+
unchanged, safety-net behavior) -- `changed_relpaths` is then
|
|
55
|
+
meaningless and always `None`. `is_incremental=True` means only the
|
|
56
|
+
files in `changed_relpaths` (added + modified + a rename's NEW path;
|
|
57
|
+
possibly an empty set if nothing changed since the last indexed
|
|
58
|
+
commit) need to be parsed, `deleted_relpaths` names files whose
|
|
59
|
+
stale rows/index-entries must be removed, and `renamed` (Phase 3.2)
|
|
60
|
+
names `(old_path, new_path)` pairs whose EXISTING `File`/`Symbol`/
|
|
61
|
+
`Embedding` rows must be re-keyed to the new path in place --
|
|
62
|
+
NEITHER purged (they are not gone) NOR treated as a fresh add
|
|
63
|
+
(their identity/ids must be preserved) the way `changed_relpaths`/
|
|
64
|
+
`deleted_relpaths` are.
|
|
65
|
+
|
|
66
|
+
`reason` is a real, human-readable explanation of which of §9's own
|
|
67
|
+
"when a full re-index is still required" conditions applied (or,
|
|
68
|
+
for an incremental run, a short summary of what changed) -- surfaced
|
|
69
|
+
in `index_directory()`'s own return dict so a caller/test can see
|
|
70
|
+
*why* a given run took the path it did, rather than needing to
|
|
71
|
+
reverse-engineer it from side effects.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
is_incremental: bool
|
|
75
|
+
changed_relpaths: set[str] | None
|
|
76
|
+
deleted_relpaths: list[str] = field(default_factory=list)
|
|
77
|
+
renamed: list[tuple[str, str]] = field(default_factory=list)
|
|
78
|
+
reason: str = ""
|
|
79
|
+
|
|
80
|
+
# Files/folders to ignore during indexing
|
|
81
|
+
# How many relpaths `_purge_stale_entries` binds into one `IN (...)` lookup.
|
|
82
|
+
_PURGE_LOOKUP_BATCH = 500
|
|
83
|
+
|
|
84
|
+
IGNORE_DIRS = {
|
|
85
|
+
".git",
|
|
86
|
+
".venv",
|
|
87
|
+
"venv",
|
|
88
|
+
"node_modules",
|
|
89
|
+
"__pycache__",
|
|
90
|
+
".pytest_cache",
|
|
91
|
+
".idea",
|
|
92
|
+
".vscode",
|
|
93
|
+
"dist",
|
|
94
|
+
"build",
|
|
95
|
+
"target",
|
|
96
|
+
}
|
|
97
|
+
IGNORE_EXTENSIONS = {
|
|
98
|
+
".pyc",
|
|
99
|
+
".pyo",
|
|
100
|
+
".pyd",
|
|
101
|
+
".so",
|
|
102
|
+
".dll",
|
|
103
|
+
".dylib",
|
|
104
|
+
".exe",
|
|
105
|
+
".png",
|
|
106
|
+
".jpg",
|
|
107
|
+
".jpeg",
|
|
108
|
+
".gif",
|
|
109
|
+
".svg",
|
|
110
|
+
".ico",
|
|
111
|
+
".lock",
|
|
112
|
+
".bin",
|
|
113
|
+
".db",
|
|
114
|
+
".sqlite",
|
|
115
|
+
".sqlite3",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class IndexingEngine:
|
|
120
|
+
"""
|
|
121
|
+
Orchestrates repository indexing, AST parsing, call graph construction,
|
|
122
|
+
embedding generation, BM25 indexing, and database storage.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
storage: StorageManager | None = None,
|
|
128
|
+
embedder: CodeEmbedder | None = None,
|
|
129
|
+
chunker: SemanticChunker | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.storage = storage or StorageManager()
|
|
132
|
+
self.embedder = embedder or CodeEmbedder()
|
|
133
|
+
self.chunker = chunker or SemanticChunker()
|
|
134
|
+
|
|
135
|
+
# In-memory search engines (per repository / session)
|
|
136
|
+
self.bm25_index = BM25Index()
|
|
137
|
+
self.vector_index = VectorIndex(dimension=self.embedder.dimension)
|
|
138
|
+
self.ranker = HybridRanker()
|
|
139
|
+
# `call_graph` mirrors the most recently indexed repository's graph
|
|
140
|
+
# (pre-Phase-0.6 behavior, preserved for existing callers -- cli.py's
|
|
141
|
+
# cmd_impact and api/app.py's analyze_impact are single/last-repo-
|
|
142
|
+
# oriented and out of this milestone's scope). `call_graphs` is the
|
|
143
|
+
# Phase 0.6 addition: one CallGraphBuilder per repository, keyed by
|
|
144
|
+
# RepositoryModel.id, so multiple indexed repositories' graphs can
|
|
145
|
+
# coexist without one overwriting another -- needed so hybrid
|
|
146
|
+
# search's graph-derived score can be correctly repository-scoped.
|
|
147
|
+
self.call_graph: CallGraphBuilder | None = None
|
|
148
|
+
self.call_graphs: dict[int, CallGraphBuilder] = {}
|
|
149
|
+
self.parse_results: dict[str, FileParseResult] = {}
|
|
150
|
+
# Repository ids whose `call_graphs` entry was restored from the
|
|
151
|
+
# durable `call_edges` rows at startup rather than built from this
|
|
152
|
+
# process's own parse results. Such a graph answers queries
|
|
153
|
+
# correctly but has no parse cache behind it, so it can't be the
|
|
154
|
+
# base of an incremental update (see `_plan_incremental_run`).
|
|
155
|
+
self._restored_call_graph_repo_ids: set[int] = set()
|
|
156
|
+
# Module-structure graphs restored from the durable `module_edges`
|
|
157
|
+
# rows at startup (repo_id -> graph). Consulted by
|
|
158
|
+
# `analyze_module_structure` only when this process has no parse
|
|
159
|
+
# results for that repository; dropped as soon as the repository is
|
|
160
|
+
# indexed here (the parse cache is then authoritative).
|
|
161
|
+
self._restored_module_graphs: dict[int, ModuleGraphBuilder] = {}
|
|
162
|
+
self._warned_module_edges_missing = False
|
|
163
|
+
self.symbols_by_id: dict[str, Symbol] = {}
|
|
164
|
+
# Phase 0.6: doc_id -> owning RepositoryModel.id, built alongside
|
|
165
|
+
# symbols_by_id, so hybrid search's graph-score step can skip
|
|
166
|
+
# symbols belonging to a different repository than the one a scoped
|
|
167
|
+
# search is asking about.
|
|
168
|
+
self.symbol_repo_map: dict[str, int] = {}
|
|
169
|
+
|
|
170
|
+
# Phase 0.5: load-on-startup hydration. Restores the in-memory
|
|
171
|
+
# vector index from durably persisted embeddings so semantic search
|
|
172
|
+
# is functional immediately after a fresh process/engine starts,
|
|
173
|
+
# without requiring a full re-index. This only reads already-computed
|
|
174
|
+
# vectors from the database -- it never calls the embedding model,
|
|
175
|
+
# so it's cheap even before any model weights are loaded. A failure
|
|
176
|
+
# here (e.g. a corrupt row) is logged and does not prevent the
|
|
177
|
+
# engine from starting; it simply leaves that data un-hydrated.
|
|
178
|
+
try:
|
|
179
|
+
self.hydrate_all()
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.warning("Startup embedding hydration failed (continuing without it): %s", e)
|
|
182
|
+
|
|
183
|
+
# Backend-hardening addition (not a roadmap sprint item): reclaims
|
|
184
|
+
# any IndexJob left `queued`/`running` by a previous process that
|
|
185
|
+
# crashed/restarted before it could finish -- see
|
|
186
|
+
# `codepulse.jobs.reap_stale_jobs`'s own docstring for why a
|
|
187
|
+
# startup-time reconciliation (not a heartbeat/timeout) is the
|
|
188
|
+
# correct mechanism here. Same fail-open philosophy as hydration
|
|
189
|
+
# above: a failure here is logged, not fatal to engine startup.
|
|
190
|
+
try:
|
|
191
|
+
reaped = reap_stale_jobs(self.storage)
|
|
192
|
+
if reaped:
|
|
193
|
+
logger.warning("Reaped %d stale index job(s) at startup: %s", len(reaped), reaped)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.warning("Startup job reaping failed (continuing without it): %s", e)
|
|
196
|
+
|
|
197
|
+
def _resolve_repo_id(self, repo_name: str, repo_url: str) -> int:
|
|
198
|
+
"""
|
|
199
|
+
Phase 0.6: resolves (or creates) the RepositoryModel row for
|
|
200
|
+
`repo_url` and returns its id, BEFORE parsing/chunking/embedding
|
|
201
|
+
begins. Needed so in-memory BM25/vector documents can be tagged with
|
|
202
|
+
the correct, real `repo_id` at the moment they're added -- reusing
|
|
203
|
+
the exact natural key (`url`) and upsert semantics established in
|
|
204
|
+
Phase 0.3, not a second identity system.
|
|
205
|
+
|
|
206
|
+
`_persist_to_db` performs its own (idempotent) upsert of the same
|
|
207
|
+
row later with final file/symbol counts -- that's unaffected by
|
|
208
|
+
this early lookup, which only needs the stable id and creates the
|
|
209
|
+
row with zero counts if it doesn't exist yet (harmless: a repo row
|
|
210
|
+
with file_count=0 momentarily, corrected the moment `_persist_to_db`
|
|
211
|
+
runs later in the same call).
|
|
212
|
+
"""
|
|
213
|
+
session = self.storage.get_session()
|
|
214
|
+
try:
|
|
215
|
+
repo_record = find_repository(session, repo_url)
|
|
216
|
+
if repo_record is None:
|
|
217
|
+
repo_record = RepositoryModel(
|
|
218
|
+
name=repo_name, url=canonical_repository_url(repo_url), file_count=0, symbol_count=0
|
|
219
|
+
)
|
|
220
|
+
session.add(repo_record)
|
|
221
|
+
session.commit()
|
|
222
|
+
return repo_record.id
|
|
223
|
+
finally:
|
|
224
|
+
session.close()
|
|
225
|
+
|
|
226
|
+
def _plan_incremental_run(self, repo_path: str, repo_id: int) -> IncrementalPlan:
|
|
227
|
+
"""
|
|
228
|
+
Phase 3.1 (roadmap §37 item 3.1; §9 Incremental Indexing Design's
|
|
229
|
+
own "when a full re-index is still required" list, checked here
|
|
230
|
+
in the same order §9 states it): decides whether
|
|
231
|
+
`index_directory()` can process only the files git says changed
|
|
232
|
+
since the last successful snapshot, or must fall back to
|
|
233
|
+
processing every discovered file exactly as it always has.
|
|
234
|
+
|
|
235
|
+
Every fallback condition below returns `is_incremental=False`
|
|
236
|
+
with a real, disclosed `reason` -- never a silent guess.
|
|
237
|
+
|
|
238
|
+
Also forces a full run whenever this engine instance has no
|
|
239
|
+
in-memory call graph cached yet for this `repo_id` (e.g. right
|
|
240
|
+
after a process restart, per `hydrate_repository`'s own existing,
|
|
241
|
+
documented contract that the call graph is not durable and
|
|
242
|
+
requires a fresh `index_directory()` call to restore). Without
|
|
243
|
+
this guard, an incremental run in a fresh process would silently
|
|
244
|
+
rebuild `self.call_graphs[repo_id]` from only the changed files
|
|
245
|
+
(this engine's `self.parse_results` cache used to keep the call
|
|
246
|
+
graph complete across incremental runs -- see `index_directory`'s
|
|
247
|
+
call-graph-building step -- is itself in-memory and lost on
|
|
248
|
+
restart), regressing call-graph completeness relative to every
|
|
249
|
+
pre-Phase-3.1 (always-full) run. This is disclosed, not silently
|
|
250
|
+
patched over: full call-graph completeness for incremental runs
|
|
251
|
+
is a best-effort, single-process-lifetime enhancement, not a
|
|
252
|
+
durable guarantee. Phase 3.5's own "local BFS incremental graph
|
|
253
|
+
update" (implemented -- see `_persist_call_graph_edges`) fixes
|
|
254
|
+
the DURABLE `CallEdge` table's update strategy, not this: the
|
|
255
|
+
in-memory graph cache's restart-survivability remains exactly
|
|
256
|
+
this same best-effort, single-process-lifetime guarantee,
|
|
257
|
+
unchanged by that milestone -- this guard is still needed and
|
|
258
|
+
still correct after it.
|
|
259
|
+
"""
|
|
260
|
+
from codepulse.config import get_settings
|
|
261
|
+
|
|
262
|
+
settings = get_settings()
|
|
263
|
+
if not settings.incremental_indexing_enabled:
|
|
264
|
+
return IncrementalPlan(
|
|
265
|
+
is_incremental=False, changed_relpaths=None,
|
|
266
|
+
reason="incremental indexing disabled via config",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
current_commit = get_head_commit_sha(repo_path)
|
|
270
|
+
if current_commit is None:
|
|
271
|
+
return IncrementalPlan(
|
|
272
|
+
is_incremental=False, changed_relpaths=None,
|
|
273
|
+
reason="not a git repository (or a git repository with no commits yet)",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
session = self.storage.get_session()
|
|
277
|
+
try:
|
|
278
|
+
last_snapshot = (
|
|
279
|
+
session.query(RepositorySnapshotModel)
|
|
280
|
+
.filter(RepositorySnapshotModel.repo_id == repo_id)
|
|
281
|
+
.filter(RepositorySnapshotModel.commit_sha.isnot(None))
|
|
282
|
+
.order_by(RepositorySnapshotModel.id.desc())
|
|
283
|
+
.first()
|
|
284
|
+
)
|
|
285
|
+
repo_row = session.query(RepositoryModel).filter(RepositoryModel.id == repo_id).first()
|
|
286
|
+
prior_file_count = repo_row.file_count if repo_row is not None else 0
|
|
287
|
+
finally:
|
|
288
|
+
session.close()
|
|
289
|
+
|
|
290
|
+
if last_snapshot is None:
|
|
291
|
+
return IncrementalPlan(
|
|
292
|
+
is_incremental=False, changed_relpaths=None,
|
|
293
|
+
reason="no prior successful git-backed snapshot exists for this repository",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Checked only once we know a real prior snapshot exists (a first
|
|
297
|
+
# run never reaches here) -- see this method's own docstring for
|
|
298
|
+
# why a cold `self.call_graphs` cache (e.g. after a process
|
|
299
|
+
# restart) must also force a full run.
|
|
300
|
+
if repo_id not in self.call_graphs or repo_id in self._restored_call_graph_repo_ids:
|
|
301
|
+
# A graph restored from stored edges at startup counts as cold
|
|
302
|
+
# here: an incremental run merges THIS process's cached parse
|
|
303
|
+
# results for unchanged files, and a restored graph has none,
|
|
304
|
+
# so building on it would silently drop their edges.
|
|
305
|
+
return IncrementalPlan(
|
|
306
|
+
is_incremental=False, changed_relpaths=None,
|
|
307
|
+
reason=(
|
|
308
|
+
"no in-memory call graph cached yet for this repository in this "
|
|
309
|
+
"process (e.g. after a restart; stored call edges are restored "
|
|
310
|
+
"for queries, but an incremental update also needs this "
|
|
311
|
+
"process's parse cache) -- a full re-index is needed to "
|
|
312
|
+
"fully restore it"
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if last_snapshot.embedding_model != self.embedder.model_name or last_snapshot.embedding_dim != self.embedder.dimension:
|
|
317
|
+
return IncrementalPlan(
|
|
318
|
+
is_incremental=False, changed_relpaths=None,
|
|
319
|
+
reason=(
|
|
320
|
+
f"embedding model/dimension changed since the last snapshot "
|
|
321
|
+
f"({last_snapshot.embedding_model}/{last_snapshot.embedding_dim} -> "
|
|
322
|
+
f"{self.embedder.model_name}/{self.embedder.dimension})"
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if last_snapshot.commit_sha == current_commit:
|
|
327
|
+
# Nothing changed at all -- still a real, valid incremental
|
|
328
|
+
# outcome (an empty changeset), not a failure or a reason to
|
|
329
|
+
# fall back to a full walk.
|
|
330
|
+
return IncrementalPlan(
|
|
331
|
+
is_incremental=True, changed_relpaths=set(),
|
|
332
|
+
reason=f"no changes since the last indexed commit {current_commit[:8]}",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
changes = detect_file_changes(repo_path, since_commit=last_snapshot.commit_sha)
|
|
337
|
+
except GitDiffError as e:
|
|
338
|
+
return IncrementalPlan(
|
|
339
|
+
is_incremental=False, changed_relpaths=None,
|
|
340
|
+
reason=f"git-diff change detection failed, falling back to a full re-index: {e}",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if prior_file_count > 0:
|
|
344
|
+
changed_fraction = changes.total_changed / prior_file_count
|
|
345
|
+
if changed_fraction > settings.incremental_max_changed_file_fraction:
|
|
346
|
+
return IncrementalPlan(
|
|
347
|
+
is_incremental=False, changed_relpaths=None,
|
|
348
|
+
reason=(
|
|
349
|
+
f"{changes.total_changed} of {prior_file_count} previously-indexed files changed "
|
|
350
|
+
f"({changed_fraction:.0%}), exceeding the {settings.incremental_max_changed_file_fraction:.0%} "
|
|
351
|
+
f"incremental threshold -- a full re-index is cheaper"
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return IncrementalPlan(
|
|
356
|
+
is_incremental=True,
|
|
357
|
+
changed_relpaths=set(changes.changed_or_added),
|
|
358
|
+
deleted_relpaths=changes.deleted,
|
|
359
|
+
renamed=changes.renamed,
|
|
360
|
+
reason=(
|
|
361
|
+
f"incremental run since commit {last_snapshot.commit_sha[:8]}: "
|
|
362
|
+
f"{len(changes.added)} added, {len(changes.modified)} modified, "
|
|
363
|
+
f"{len(changes.deleted)} deleted, {len(changes.renamed)} renamed"
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _doc_ids_for_file(
|
|
369
|
+
session, repo_id: int, fm: FileModel
|
|
370
|
+
) -> tuple[list[str], list[str], list[tuple[str, str]]]:
|
|
371
|
+
"""
|
|
372
|
+
Phase 3.1/3.2 -- computes the exact `BM25Index`/`VectorIndex`
|
|
373
|
+
`doc_id` strings for every symbol currently associated with the
|
|
374
|
+
given `FileModel` row, from the *durable* `Symbol`/`Embedding`
|
|
375
|
+
rows (not from potentially-incomplete in-memory state). Shared
|
|
376
|
+
by `_purge_stale_entries` (which then deletes the rows too) and
|
|
377
|
+
`_rekey_renamed_files` (which does NOT delete the rows -- it
|
|
378
|
+
re-keys them to a new path instead), since both need the exact
|
|
379
|
+
same doc_id-reconstruction logic, computed against the file's
|
|
380
|
+
CURRENT (pre-change) `path`.
|
|
381
|
+
|
|
382
|
+
Also returns each symbol's `(file_path, symbol_name)` key
|
|
383
|
+
(Phase 3.5: fed into the call-graph incremental-update BFS --
|
|
384
|
+
see `_persist_call_graph_edges`'s own docstring for why the
|
|
385
|
+
caller needs to know exactly which symbol identities are about
|
|
386
|
+
to stop existing under their current path).
|
|
387
|
+
"""
|
|
388
|
+
bm25_doc_ids: list[str] = []
|
|
389
|
+
vector_doc_ids: list[str] = []
|
|
390
|
+
symbol_keys: list[tuple[str, str]] = []
|
|
391
|
+
symbol_rows = session.query(SymbolModel).filter(SymbolModel.file_id == fm.id).all()
|
|
392
|
+
for sm in symbol_rows:
|
|
393
|
+
bm25_doc_ids.append(symbol_doc_id(repo_id, fm.path, sm.name, sm.start_line))
|
|
394
|
+
symbol_keys.append((fm.path, sm.name))
|
|
395
|
+
embedding_rows = session.query(EmbeddingModel).filter(EmbeddingModel.symbol_id == sm.id).all()
|
|
396
|
+
for em in embedding_rows:
|
|
397
|
+
vector_doc_ids.append(chunk_doc_id(repo_id, fm.path, sm.name, sm.start_line, em.chunk_index))
|
|
398
|
+
return bm25_doc_ids, vector_doc_ids, symbol_keys
|
|
399
|
+
|
|
400
|
+
def _purge_stale_entries(self, repo_id: int, relpaths: list[str]) -> set[tuple[str, str]]:
|
|
401
|
+
"""
|
|
402
|
+
Phase 3.1 -- removes the stale `File`/`Symbol` (cascading to
|
|
403
|
+
`Embedding`, per the existing schema's `ON DELETE CASCADE`) rows
|
|
404
|
+
for the given relpaths, plus their lexical (`BM25Index`) and
|
|
405
|
+
semantic (`VectorIndex`) index entries and this engine's
|
|
406
|
+
in-memory caches (`symbols_by_id`, `symbol_repo_map`,
|
|
407
|
+
`parse_results`) -- identified from the *durable* rows about to
|
|
408
|
+
be deleted (not from potentially-incomplete in-memory state), so
|
|
409
|
+
this is correct even if the file was indexed in a previous
|
|
410
|
+
process lifetime.
|
|
411
|
+
|
|
412
|
+
This serves TWO distinct Phase 3.1 purposes, both requiring the
|
|
413
|
+
exact same operation:
|
|
414
|
+
|
|
415
|
+
1. Permanently-deleted files (`IncrementalPlan.deleted_relpaths`)
|
|
416
|
+
-- called once, and nothing re-adds these rows/entries back.
|
|
417
|
+
|
|
418
|
+
2. Modified files (`IncrementalPlan.changed_relpaths`), called
|
|
419
|
+
BEFORE they are re-parsed -- a modified symbol's `doc_id` is
|
|
420
|
+
`symbol_doc_id(repo, file, name, start_line)`, which is
|
|
421
|
+
stable across a content-only change that leaves the symbol's
|
|
422
|
+
lines alone (a change that shifts them yields a new id, and
|
|
423
|
+
the old one is purged here from the durable row), so without
|
|
424
|
+
removing the OLD entries first, re-adding the freshly
|
|
425
|
+
re-parsed symbols under the same doc_id strings would silently accumulate
|
|
426
|
+
duplicate lexical/vector entries. This is the same class of
|
|
427
|
+
problem Phase 2.1's `remove_repo` (whole-repo granularity)
|
|
428
|
+
and this milestone's own `remove_docs` (fine-grained) exist
|
|
429
|
+
to solve -- applied here specifically to the modified-file
|
|
430
|
+
case.
|
|
431
|
+
|
|
432
|
+
A relpath with no existing rows (a never-before-indexed path, the
|
|
433
|
+
common case for newly-*added* files) is a safe no-op, matching
|
|
434
|
+
`remove_repo`/`remove_docs`'s own no-op-safety precedent.
|
|
435
|
+
|
|
436
|
+
Returns the `(file_path, symbol_name)` key for every symbol that
|
|
437
|
+
was deleted (Phase 3.5: fed into `_persist_call_graph_edges`'s
|
|
438
|
+
incremental BFS -- a deleted symbol's inbound call-graph edges,
|
|
439
|
+
recorded from unrelated, unchanged files, must be found and
|
|
440
|
+
corrected too, not left pointing at a symbol that no longer
|
|
441
|
+
exists).
|
|
442
|
+
"""
|
|
443
|
+
if not relpaths:
|
|
444
|
+
return set()
|
|
445
|
+
|
|
446
|
+
session = self.storage.get_session()
|
|
447
|
+
try:
|
|
448
|
+
# Looked up in batches: a single `IN (...)` binding every path
|
|
449
|
+
# overflows SQLite's variable limit when a large directory
|
|
450
|
+
# becomes ignored or is deleted (tens of thousands of paths).
|
|
451
|
+
file_rows: list[FileModel] = []
|
|
452
|
+
for start in range(0, len(relpaths), _PURGE_LOOKUP_BATCH):
|
|
453
|
+
batch = relpaths[start:start + _PURGE_LOOKUP_BATCH]
|
|
454
|
+
file_rows.extend(
|
|
455
|
+
session.query(FileModel)
|
|
456
|
+
.filter(FileModel.repo_id == repo_id)
|
|
457
|
+
.filter(FileModel.path.in_(batch))
|
|
458
|
+
.all()
|
|
459
|
+
)
|
|
460
|
+
bm25_doc_ids: list[str] = []
|
|
461
|
+
vector_doc_ids: list[str] = []
|
|
462
|
+
removed_symbol_keys: set[tuple[str, str]] = set()
|
|
463
|
+
for fm in file_rows:
|
|
464
|
+
fm_bm25_ids, fm_vector_ids, fm_symbol_keys = self._doc_ids_for_file(session, repo_id, fm)
|
|
465
|
+
bm25_doc_ids.extend(fm_bm25_ids)
|
|
466
|
+
vector_doc_ids.extend(fm_vector_ids)
|
|
467
|
+
removed_symbol_keys.update(fm_symbol_keys)
|
|
468
|
+
|
|
469
|
+
for fm in file_rows:
|
|
470
|
+
session.delete(fm)
|
|
471
|
+
session.commit()
|
|
472
|
+
finally:
|
|
473
|
+
session.close()
|
|
474
|
+
|
|
475
|
+
if bm25_doc_ids:
|
|
476
|
+
self.bm25_index.remove_docs(bm25_doc_ids)
|
|
477
|
+
self.bm25_index.build()
|
|
478
|
+
if vector_doc_ids:
|
|
479
|
+
self.vector_index.remove_docs(vector_doc_ids)
|
|
480
|
+
for doc_id in bm25_doc_ids:
|
|
481
|
+
self._forget_symbol(doc_id)
|
|
482
|
+
for relpath in relpaths:
|
|
483
|
+
self.parse_results.pop(f"{repo_id}::{relpath}", None)
|
|
484
|
+
|
|
485
|
+
return removed_symbol_keys
|
|
486
|
+
|
|
487
|
+
def _purge_files_missing_from_full_run(
|
|
488
|
+
self, repo_id: int, parsed_relpaths: set[str], failed_relpaths: set[str]
|
|
489
|
+
) -> set[tuple[str, str]]:
|
|
490
|
+
"""
|
|
491
|
+
Purges (via `_purge_stale_entries`, so database rows, the keyword and
|
|
492
|
+
vector indexes, the symbol maps and the parse cache all go together)
|
|
493
|
+
every stored file of this repository that a FULL run did not parse:
|
|
494
|
+
deleted, newly ignored, no longer supported, or now over the size cap.
|
|
495
|
+
|
|
496
|
+
Two deliberate exceptions:
|
|
497
|
+
- `failed_relpaths`: a file whose parse raised this run keeps its
|
|
498
|
+
previous rows; the next successful parse replaces them.
|
|
499
|
+
- A run that parsed NO files at all purges nothing. An empty or
|
|
500
|
+
unmounted directory (or a checkout in progress) must never be
|
|
501
|
+
read as "every file was deleted" and wipe the index; the cost is
|
|
502
|
+
that a repository genuinely emptied of source files keeps its
|
|
503
|
+
old rows, which is the behavior from before this purge existed.
|
|
504
|
+
|
|
505
|
+
Returns the `(file, symbol)` keys removed, like `_purge_stale_entries`.
|
|
506
|
+
"""
|
|
507
|
+
if not parsed_relpaths:
|
|
508
|
+
session = self.storage.get_session()
|
|
509
|
+
try:
|
|
510
|
+
stored = session.query(FileModel.id).filter(FileModel.repo_id == repo_id).count()
|
|
511
|
+
finally:
|
|
512
|
+
session.close()
|
|
513
|
+
if stored:
|
|
514
|
+
logger.warning(
|
|
515
|
+
"Full run for repo_id=%s parsed no files but %d are stored: not purging "
|
|
516
|
+
"(an empty or unavailable directory must not wipe the index)",
|
|
517
|
+
repo_id, stored,
|
|
518
|
+
)
|
|
519
|
+
return set()
|
|
520
|
+
|
|
521
|
+
session = self.storage.get_session()
|
|
522
|
+
try:
|
|
523
|
+
stored_paths = {path for (path,) in session.query(FileModel.path).filter(FileModel.repo_id == repo_id)}
|
|
524
|
+
finally:
|
|
525
|
+
session.close()
|
|
526
|
+
stale = sorted(stored_paths - parsed_relpaths - failed_relpaths)
|
|
527
|
+
if not stale:
|
|
528
|
+
return set()
|
|
529
|
+
logger.info("Full run for repo_id=%s: purging %d stored file(s) no longer indexable", repo_id, len(stale))
|
|
530
|
+
return self._purge_stale_entries(repo_id, stale)
|
|
531
|
+
|
|
532
|
+
def _rekey_renamed_files(self, repo_id: int, renamed_pairs: list[tuple[str, str]]) -> set[tuple[str, str]]:
|
|
533
|
+
"""
|
|
534
|
+
Phase 3.2 (roadmap §37 item 3.2: "Rename/move detection + symbol
|
|
535
|
+
re-keying"; §9: "re-key existing Symbol/Embedding rows to the
|
|
536
|
+
new path instead of delete+recreate -- this preserves symbol
|
|
537
|
+
IDs"). For each `(old_path, new_path)` pair git identified as a
|
|
538
|
+
rename/move:
|
|
539
|
+
|
|
540
|
+
1. Finds the EXISTING `FileModel` row at `(repo_id, old_path)`.
|
|
541
|
+
2. Removes its symbols' CURRENT (old-path-encoded) `BM25Index`/
|
|
542
|
+
`VectorIndex` doc_ids -- these strings embed the file path,
|
|
543
|
+
so they go stale the moment the path changes, even though
|
|
544
|
+
nothing about the underlying `Symbol`/`Embedding` DB rows
|
|
545
|
+
needs to change. Does NOT delete the DB rows themselves --
|
|
546
|
+
unlike `_purge_stale_entries`, which this deliberately does
|
|
547
|
+
NOT call, since those rows are being preserved, not removed.
|
|
548
|
+
3. Updates that row's `path` column to `new_path` IN PLACE (a
|
|
549
|
+
plain `UPDATE`, not delete+insert) -- this is the crux of
|
|
550
|
+
"re-keying": `file_id` (and therefore every associated
|
|
551
|
+
`Symbol.id`/`Embedding.id`, via the existing, unchanged
|
|
552
|
+
`file_id` foreign key) is preserved exactly.
|
|
553
|
+
|
|
554
|
+
`new_path` is expected to be included in this run's
|
|
555
|
+
`changed_relpaths` (by `_plan_incremental_run`, via
|
|
556
|
+
`ChangeSet.changed_or_added`) so it gets parsed normally by the
|
|
557
|
+
ordinary "Parse files" step immediately after this method runs
|
|
558
|
+
-- when `_persist_to_db`'s File-upsert-by-`(repo_id, path)`
|
|
559
|
+
lookup runs for `new_path`, it will find this ALREADY-re-keyed
|
|
560
|
+
row and correctly treat it as an existing file being updated in
|
|
561
|
+
place (matched-by-natural-key, per the existing Phase 0.4
|
|
562
|
+
`Symbol` upsert), not a brand new one -- for a pure rename (byte-
|
|
563
|
+
identical content), every symbol's `(name, start_line)` is
|
|
564
|
+
unchanged, so every existing `Symbol` row is reused untouched,
|
|
565
|
+
fully satisfying "preserves symbol IDs" for that common case.
|
|
566
|
+
|
|
567
|
+
Scope boundary, explicitly NOT solved here (a deliberate,
|
|
568
|
+
disclosed choice for this milestone -- see CODEPULSE_PROGRESS.md):
|
|
569
|
+
for a rename that ALSO edits content such that a symbol's own
|
|
570
|
+
`start_line` shifts, that one symbol will not be matched by the
|
|
571
|
+
existing `(file_id, name, start_line)` key and will be treated
|
|
572
|
+
as a fresh symbol (a new id) the same way an ordinary modified
|
|
573
|
+
file already behaves today -- a pre-existing Phase 0.4 limitation
|
|
574
|
+
(no `(name, type)`-based, start_line-independent matching exists
|
|
575
|
+
anywhere in this codebase yet), not something this milestone
|
|
576
|
+
newly introduces or is required to fix.
|
|
577
|
+
|
|
578
|
+
Returns the OLD `(old_path, symbol_name)` key for every symbol
|
|
579
|
+
in every renamed file (Phase 3.5: fed into
|
|
580
|
+
`_persist_call_graph_edges`'s incremental BFS -- a symbol whose
|
|
581
|
+
FILE moved is, from the perspective of any OTHER, unrelated
|
|
582
|
+
caller's already-persisted `CallEdge` row, indistinguishable
|
|
583
|
+
from one that simply disappeared: that row's `target_file`
|
|
584
|
+
still says the OLD path, and must be found and corrected, not
|
|
585
|
+
left pointing at a path that no longer holds that symbol).
|
|
586
|
+
|
|
587
|
+
Conservative fallback (matching the roadmap's own risk-register
|
|
588
|
+
mitigation: "fallback to delete+recreate rather than a risky
|
|
589
|
+
guess"): if no existing row is found at `old_path` (e.g. it was
|
|
590
|
+
never actually indexed before), re-keying is skipped for that
|
|
591
|
+
pair with a logged warning -- `new_path` still gets indexed
|
|
592
|
+
normally as a fresh file via the ordinary parse loop, since it
|
|
593
|
+
is already part of `changed_relpaths` regardless.
|
|
594
|
+
"""
|
|
595
|
+
if not renamed_pairs:
|
|
596
|
+
return set()
|
|
597
|
+
|
|
598
|
+
removed_symbol_keys: set[tuple[str, str]] = set()
|
|
599
|
+
for old_path, new_path in renamed_pairs:
|
|
600
|
+
session = self.storage.get_session()
|
|
601
|
+
try:
|
|
602
|
+
fm = (
|
|
603
|
+
session.query(FileModel)
|
|
604
|
+
.filter(FileModel.repo_id == repo_id)
|
|
605
|
+
.filter(FileModel.path == old_path)
|
|
606
|
+
.first()
|
|
607
|
+
)
|
|
608
|
+
if fm is None:
|
|
609
|
+
logger.warning(
|
|
610
|
+
"Rename detected (%s -> %s) but no existing File row found "
|
|
611
|
+
"for repo_id=%s at the old path -- skipping re-key; %s will "
|
|
612
|
+
"be indexed as a fresh file instead.",
|
|
613
|
+
old_path, new_path, repo_id, new_path,
|
|
614
|
+
)
|
|
615
|
+
continue
|
|
616
|
+
|
|
617
|
+
bm25_doc_ids, vector_doc_ids, symbol_keys = self._doc_ids_for_file(session, repo_id, fm)
|
|
618
|
+
removed_symbol_keys.update(symbol_keys)
|
|
619
|
+
fm.path = new_path
|
|
620
|
+
session.commit()
|
|
621
|
+
finally:
|
|
622
|
+
session.close()
|
|
623
|
+
|
|
624
|
+
if bm25_doc_ids:
|
|
625
|
+
self.bm25_index.remove_docs(bm25_doc_ids)
|
|
626
|
+
self.bm25_index.build()
|
|
627
|
+
if vector_doc_ids:
|
|
628
|
+
self.vector_index.remove_docs(vector_doc_ids)
|
|
629
|
+
for doc_id in bm25_doc_ids:
|
|
630
|
+
self._forget_symbol(doc_id)
|
|
631
|
+
self.parse_results.pop(f"{repo_id}::{old_path}", None)
|
|
632
|
+
|
|
633
|
+
return removed_symbol_keys
|
|
634
|
+
|
|
635
|
+
def _diff_and_purge_file_symbols(
|
|
636
|
+
self, repo_id: int, rel_path: str, new_symbols: list[Symbol]
|
|
637
|
+
) -> tuple[set[int], set[tuple[str, str]]]:
|
|
638
|
+
"""
|
|
639
|
+
Phase 3.3 (roadmap §37 item 3.3: "Symbol-level body_hash
|
|
640
|
+
comparison to skip unchanged embeddings"; §9: "within a changed
|
|
641
|
+
file, compare `body_hash` per extracted symbol against the
|
|
642
|
+
previous snapshot's symbols... Unchanged symbols skip
|
|
643
|
+
re-embedding and re-chunking entirely -- this is the single
|
|
644
|
+
biggest cost saver on incremental runs").
|
|
645
|
+
|
|
646
|
+
Compares `new_symbols` (this run's freshly parsed symbols for
|
|
647
|
+
`rel_path`) against the EXISTING, durable `SymbolModel` rows for
|
|
648
|
+
that same file (if any), and:
|
|
649
|
+
|
|
650
|
+
1. Returns `{id(sym) for sym in new_symbols if unchanged}` --
|
|
651
|
+
Python object identity is a safe, cheap key here because
|
|
652
|
+
`new_symbols` is a single parse pass's freshly constructed
|
|
653
|
+
list, used only within the same `index_directory()` call
|
|
654
|
+
that produced it.
|
|
655
|
+
2. For every existing row that DISAPPEARED entirely (no
|
|
656
|
+
matching new symbol at all -- deleted, or renamed to a
|
|
657
|
+
different name WITHIN the file) -- deletes its
|
|
658
|
+
`SymbolModel` row (cascading to `Embedding`, per the
|
|
659
|
+
existing schema) and purges its lexical/vector doc_ids.
|
|
660
|
+
This closes a real, previously-open, previously-disclosed
|
|
661
|
+
gap as a necessary side effect of building this comparison
|
|
662
|
+
at all (`_persist_to_db`'s own docstring: "this mirrors
|
|
663
|
+
Phase 0.4's decision not to delete stale Symbol rows...
|
|
664
|
+
real stale-data cleanup is Phase 3").
|
|
665
|
+
3. For every symbol that MATCHED an existing row but has a
|
|
666
|
+
DIFFERENT `body_hash` -- purges its lexical/vector doc_ids
|
|
667
|
+
(it will be re-chunked/re-embedded and re-added by the
|
|
668
|
+
normal pipeline immediately after this call returns,
|
|
669
|
+
under the same doc_id string).
|
|
670
|
+
4. For every symbol that matched AND has the SAME `body_hash`
|
|
671
|
+
-- does nothing at all: its doc_id is deliberately left
|
|
672
|
+
untouched (not purged, not scheduled for re-add), since
|
|
673
|
+
its existing lexical/vector entries and DB rows are
|
|
674
|
+
already correct.
|
|
675
|
+
|
|
676
|
+
Matching key -- a deliberate, disclosed scope choice (mirroring
|
|
677
|
+
the Phase 3.2 precedent, see CODEPULSE_PROGRESS.md): `(name,
|
|
678
|
+
start_line)` plus a `type` equality check -- the EXACT SAME
|
|
679
|
+
natural key `_persist_to_db`'s own Symbol upsert already uses,
|
|
680
|
+
NOT a broader `(name, type)`-only key. §9's own text describes
|
|
681
|
+
matching by "(name, type) first, falling back to signature
|
|
682
|
+
similarity if the name changed" -- a fuller, start_line-
|
|
683
|
+
independent matcher (needed to correctly recognize an unchanged
|
|
684
|
+
symbol whose `start_line` merely shifted because unrelated code
|
|
685
|
+
above it in the file changed) is deliberately NOT built here.
|
|
686
|
+
Using the SAME key `_persist_to_db` uses guarantees the two can
|
|
687
|
+
never disagree about which row is "the same symbol": a symbol
|
|
688
|
+
whose `start_line` shifts simply isn't eligible for this
|
|
689
|
+
optimization and falls through to a full re-chunk/re-embed with
|
|
690
|
+
a fresh id -- exactly today's pre-existing (Phase 0.4) behavior
|
|
691
|
+
for that case, not a regression. Building the fuller matcher is
|
|
692
|
+
a disclosed, deferred enhancement, not silently done.
|
|
693
|
+
|
|
694
|
+
Deliberately NOT called for a file that was renamed this run
|
|
695
|
+
(see `index_directory`'s own caller) -- a rename changes every
|
|
696
|
+
one of its symbols' doc_id strings (which embed the file path)
|
|
697
|
+
regardless of body content, so "leave unchanged symbols' doc_ids
|
|
698
|
+
alone" does not apply there; `_rekey_renamed_files` already
|
|
699
|
+
handles that case's own (whole-file) doc_id purge correctly.
|
|
700
|
+
|
|
701
|
+
Also returns the `(rel_path, symbol_name)` key for every symbol
|
|
702
|
+
that DISAPPEARED (case 2 above) -- Phase 3.5: fed into
|
|
703
|
+
`_persist_call_graph_edges`'s incremental BFS, the same reason
|
|
704
|
+
`_purge_stale_entries`/`_rekey_renamed_files` return theirs. A
|
|
705
|
+
symbol whose `body_hash` merely changed (case 3) does NOT need
|
|
706
|
+
its key returned here -- its `(file_path, name)` identity is
|
|
707
|
+
unchanged, so it's already covered by the caller's own "every
|
|
708
|
+
symbol in `all_symbols` not in `unchanged_ids`" pass.
|
|
709
|
+
"""
|
|
710
|
+
unchanged_ids: set[int] = set()
|
|
711
|
+
disappeared_keys: set[tuple[str, str]] = set()
|
|
712
|
+
bm25_doc_ids_to_purge: list[str] = []
|
|
713
|
+
vector_doc_ids_to_purge: list[str] = []
|
|
714
|
+
|
|
715
|
+
session = self.storage.get_session()
|
|
716
|
+
try:
|
|
717
|
+
fm = (
|
|
718
|
+
session.query(FileModel)
|
|
719
|
+
.filter(FileModel.repo_id == repo_id)
|
|
720
|
+
.filter(FileModel.path == rel_path)
|
|
721
|
+
.first()
|
|
722
|
+
)
|
|
723
|
+
if fm is None:
|
|
724
|
+
return unchanged_ids, disappeared_keys # brand new file -- nothing to compare against
|
|
725
|
+
|
|
726
|
+
existing_by_key: dict[tuple[str, int], SymbolModel] = {
|
|
727
|
+
(sm.name, sm.start_line): sm
|
|
728
|
+
for sm in session.query(SymbolModel).filter(SymbolModel.file_id == fm.id).all()
|
|
729
|
+
}
|
|
730
|
+
matched_existing_ids: set[int] = set()
|
|
731
|
+
|
|
732
|
+
for sym in new_symbols:
|
|
733
|
+
sym_type_value = sym.type.value if hasattr(sym.type, "value") else str(sym.type)
|
|
734
|
+
existing_sm = existing_by_key.get((sym.name, sym.start_line))
|
|
735
|
+
if existing_sm is None or existing_sm.type != sym_type_value:
|
|
736
|
+
continue # a genuinely new symbol -- nothing to purge, nothing to skip
|
|
737
|
+
matched_existing_ids.add(existing_sm.id)
|
|
738
|
+
if existing_sm.body_hash == sym.body_hash:
|
|
739
|
+
unchanged_ids.add(id(sym))
|
|
740
|
+
else:
|
|
741
|
+
bm25_doc_ids_to_purge.append(symbol_doc_id(repo_id, rel_path, sym.name, sym.start_line))
|
|
742
|
+
embedding_rows = (
|
|
743
|
+
session.query(EmbeddingModel)
|
|
744
|
+
.filter(EmbeddingModel.symbol_id == existing_sm.id)
|
|
745
|
+
.all()
|
|
746
|
+
)
|
|
747
|
+
for em in embedding_rows:
|
|
748
|
+
vector_doc_ids_to_purge.append(
|
|
749
|
+
chunk_doc_id(repo_id, rel_path, sym.name, sym.start_line, em.chunk_index)
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
disappeared = [sm for sm in existing_by_key.values() if sm.id not in matched_existing_ids]
|
|
753
|
+
for sm in disappeared:
|
|
754
|
+
bm25_doc_ids_to_purge.append(symbol_doc_id(repo_id, rel_path, sm.name, sm.start_line))
|
|
755
|
+
disappeared_keys.add((rel_path, sm.name))
|
|
756
|
+
embedding_rows = session.query(EmbeddingModel).filter(EmbeddingModel.symbol_id == sm.id).all()
|
|
757
|
+
for em in embedding_rows:
|
|
758
|
+
vector_doc_ids_to_purge.append(chunk_doc_id(repo_id, rel_path, sm.name, sm.start_line, em.chunk_index))
|
|
759
|
+
session.delete(sm)
|
|
760
|
+
|
|
761
|
+
if disappeared:
|
|
762
|
+
session.commit()
|
|
763
|
+
finally:
|
|
764
|
+
session.close()
|
|
765
|
+
|
|
766
|
+
if bm25_doc_ids_to_purge:
|
|
767
|
+
self.bm25_index.remove_docs(bm25_doc_ids_to_purge)
|
|
768
|
+
self.bm25_index.build()
|
|
769
|
+
if vector_doc_ids_to_purge:
|
|
770
|
+
self.vector_index.remove_docs(vector_doc_ids_to_purge)
|
|
771
|
+
for doc_id in bm25_doc_ids_to_purge:
|
|
772
|
+
self._forget_symbol(doc_id)
|
|
773
|
+
|
|
774
|
+
return unchanged_ids, disappeared_keys
|
|
775
|
+
|
|
776
|
+
def _record_snapshot(self, repo_id: int, repo_path: str) -> None:
|
|
777
|
+
"""
|
|
778
|
+
Phase 3.1 -- records one `RepositorySnapshotModel` row for this
|
|
779
|
+
successful `index_directory()` run (full or incremental alike),
|
|
780
|
+
so the *next* call's `_plan_incremental_run` has a real prior
|
|
781
|
+
commit/embedding-configuration to compare against. Failure to
|
|
782
|
+
record a snapshot is logged, not raised -- a missing snapshot
|
|
783
|
+
row only ever causes the *next* run to safely fall back to a
|
|
784
|
+
full re-index (§9's own "no prior successful snapshot" case),
|
|
785
|
+
never data loss or a fabricated result for *this* run.
|
|
786
|
+
"""
|
|
787
|
+
try:
|
|
788
|
+
commit_sha = get_head_commit_sha(repo_path)
|
|
789
|
+
session = self.storage.get_session()
|
|
790
|
+
try:
|
|
791
|
+
session.add(
|
|
792
|
+
RepositorySnapshotModel(
|
|
793
|
+
repo_id=repo_id,
|
|
794
|
+
commit_sha=commit_sha,
|
|
795
|
+
embedding_model=self.embedder.model_name,
|
|
796
|
+
embedding_dim=self.embedder.dimension,
|
|
797
|
+
)
|
|
798
|
+
)
|
|
799
|
+
session.commit()
|
|
800
|
+
finally:
|
|
801
|
+
session.close()
|
|
802
|
+
except Exception as e:
|
|
803
|
+
logger.warning("Failed to record repository snapshot for repo_id=%s: %s", repo_id, e)
|
|
804
|
+
|
|
805
|
+
def index_directory(
|
|
806
|
+
self,
|
|
807
|
+
repo_path: str,
|
|
808
|
+
repo_name: str = "local_repo",
|
|
809
|
+
repo_url: str = "",
|
|
810
|
+
progress_callback: Callable[[str, int, int], None] | None = None,
|
|
811
|
+
) -> dict[str, Any]:
|
|
812
|
+
"""
|
|
813
|
+
Indexes all parseable files within a repository directory.
|
|
814
|
+
"""
|
|
815
|
+
repo_path = os.path.abspath(repo_path)
|
|
816
|
+
# One identity per repository however its path is spelled (FUTURE_CHANGES item 2).
|
|
817
|
+
resolved_repo_url = canonical_repository_url(repo_url or repo_path)
|
|
818
|
+
|
|
819
|
+
# Phase 0.6: resolve the real repo_id BEFORE parsing/chunking, so
|
|
820
|
+
# every in-memory BM25/vector document created below can be tagged
|
|
821
|
+
# with it (required for repository-scoped search -- see
|
|
822
|
+
# _resolve_repo_id's docstring for why this can't wait until the
|
|
823
|
+
# final DB persist step, which happens after these in-memory
|
|
824
|
+
# structures are already populated).
|
|
825
|
+
repo_id = self._resolve_repo_id(repo_name, resolved_repo_url)
|
|
826
|
+
|
|
827
|
+
# Phase 3.1: decide, before walking the directory, whether this
|
|
828
|
+
# run can be limited to git-diff-detected changed/added files
|
|
829
|
+
# (with deleted files cleaned up separately) or must process
|
|
830
|
+
# every discovered file exactly as every pre-Phase-3.1 run did.
|
|
831
|
+
# See `_plan_incremental_run`'s own docstring for the full
|
|
832
|
+
# decision logic (every §9 fallback condition, in order).
|
|
833
|
+
incremental_plan = self._plan_incremental_run(repo_path, repo_id)
|
|
834
|
+
|
|
835
|
+
# Phase 0.8: load this repository's .gitignore/.codepulseignore
|
|
836
|
+
# rules ONCE, before walking the tree, and reuse them for every
|
|
837
|
+
# discovered path -- see codepulse.indexer.ignore for the exact
|
|
838
|
+
# semantics (additive combination, root-only, malformed-file
|
|
839
|
+
# handling). This is layered on top of, not a replacement for, the
|
|
840
|
+
# existing hardcoded IGNORE_DIRS/IGNORE_EXTENSIONS filtering below.
|
|
841
|
+
ignore_rules = RepositoryIgnoreRules(repo_path)
|
|
842
|
+
|
|
843
|
+
all_files: list[str] = []
|
|
844
|
+
|
|
845
|
+
# 1. Discover all source code files
|
|
846
|
+
for root, dirs, files in os.walk(repo_path):
|
|
847
|
+
kept_dirs = []
|
|
848
|
+
for d in dirs:
|
|
849
|
+
if d in IGNORE_DIRS:
|
|
850
|
+
continue
|
|
851
|
+
rel_dir = os.path.relpath(os.path.join(root, d), repo_path).replace("\\", "/")
|
|
852
|
+
if ignore_rules.is_ignored(rel_dir, is_dir=True):
|
|
853
|
+
# Prune here (not just skip later) so nothing beneath
|
|
854
|
+
# an ignored directory is walked, read, or parsed at all.
|
|
855
|
+
continue
|
|
856
|
+
kept_dirs.append(d)
|
|
857
|
+
dirs[:] = kept_dirs
|
|
858
|
+
|
|
859
|
+
for f in files:
|
|
860
|
+
_, ext = os.path.splitext(f)
|
|
861
|
+
if ext.lower() in IGNORE_EXTENSIONS:
|
|
862
|
+
continue
|
|
863
|
+
full_path = os.path.join(root, f)
|
|
864
|
+
rel_path = os.path.relpath(full_path, repo_path).replace("\\", "/")
|
|
865
|
+
if ignore_rules.is_ignored(rel_path, is_dir=False):
|
|
866
|
+
continue
|
|
867
|
+
all_files.append(full_path)
|
|
868
|
+
|
|
869
|
+
total_files = len(all_files)
|
|
870
|
+
logger.info("Found %d files to examine in '%s'", total_files, repo_path)
|
|
871
|
+
|
|
872
|
+
# Phase 1.5 / roadmap §15: "max files per repo" resource limit --
|
|
873
|
+
# checked here (after discovery, before parsing/embedding, the
|
|
874
|
+
# expensive part) rather than via a second pre-flight walk at the
|
|
875
|
+
# API boundary, to avoid duplicating this exact discovery loop
|
|
876
|
+
# (see codepulse/ingestion_safety.py's module docstring for why).
|
|
877
|
+
# Raising here surfaces as a real, non-fabricated "failed" job
|
|
878
|
+
# status (codepulse.jobs.mark_failed, called from the caller's own
|
|
879
|
+
# except block) rather than a fabricated success.
|
|
880
|
+
from codepulse.config import get_settings
|
|
881
|
+
|
|
882
|
+
_ingestion_settings = get_settings()
|
|
883
|
+
max_files = _ingestion_settings.ingestion_max_files_per_repo
|
|
884
|
+
max_file_size = _ingestion_settings.ingestion_max_file_size_bytes
|
|
885
|
+
if total_files > max_files:
|
|
886
|
+
raise IngestionSafetyError(
|
|
887
|
+
f"Repository at '{repo_path}' has {total_files} discoverable "
|
|
888
|
+
f"files, exceeding the configured ingestion_max_files_per_repo "
|
|
889
|
+
f"limit of {max_files}"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
# Phase 3.1: when incremental, restrict the (expensive) parse/
|
|
893
|
+
# chunk/embed loop below to just the files git-diff says were
|
|
894
|
+
# added, modified, or renamed/moved (a rename's NEW path) --
|
|
895
|
+
# unchanged files are neither re-read nor re-parsed, which is the
|
|
896
|
+
# whole cost-saving point of §9. A full run (`is_incremental=
|
|
897
|
+
# False`) leaves `all_files` untouched and skips this block
|
|
898
|
+
# entirely, exactly matching every pre-Phase-3.1 run's behavior.
|
|
899
|
+
renamed_new_paths: set[str] = set()
|
|
900
|
+
# Phase 3.5: `(file_path, symbol_name)` identity of every symbol
|
|
901
|
+
# that stopped existing under that identity this run (deleted,
|
|
902
|
+
# renamed-within-a-file, or moved to a new path) -- accumulated
|
|
903
|
+
# here and fed into `_persist_call_graph_edges`'s incremental
|
|
904
|
+
# BFS (see that method's own docstring). Populated below by
|
|
905
|
+
# `_rekey_renamed_files`, `_purge_stale_entries`, and (per-file,
|
|
906
|
+
# inside the parse loop) `_diff_and_purge_file_symbols`.
|
|
907
|
+
removed_symbol_keys: set[tuple[str, str]] = set()
|
|
908
|
+
if incremental_plan.is_incremental and incremental_plan.changed_relpaths is not None:
|
|
909
|
+
changed_set = incremental_plan.changed_relpaths
|
|
910
|
+
all_files = [
|
|
911
|
+
fp
|
|
912
|
+
for fp in all_files
|
|
913
|
+
if os.path.relpath(fp, repo_path).replace("\\", "/") in changed_set
|
|
914
|
+
]
|
|
915
|
+
total_files = len(all_files)
|
|
916
|
+
logger.info(
|
|
917
|
+
"Incremental run for repo_id=%s: %s -- parsing %d changed/added/renamed file(s)",
|
|
918
|
+
repo_id,
|
|
919
|
+
incremental_plan.reason,
|
|
920
|
+
total_files,
|
|
921
|
+
)
|
|
922
|
+
# Phase 3.2: re-key renamed files' EXISTING File/Symbol/
|
|
923
|
+
# Embedding rows to their new path FIRST.
|
|
924
|
+
removed_symbol_keys |= self._rekey_renamed_files(repo_id, incremental_plan.renamed)
|
|
925
|
+
renamed_new_paths = {new for _, new in incremental_plan.renamed}
|
|
926
|
+
# Phase 3.3: deleted files are still handled here, file-
|
|
927
|
+
# granularity, exactly as Phase 3.1 established (nothing
|
|
928
|
+
# left to compare a deleted file's symbols against, and
|
|
929
|
+
# nothing will re-parse it). MODIFIED files (and a rename's
|
|
930
|
+
# new path) are deliberately EXCLUDED from this file-level
|
|
931
|
+
# purge as of Phase 3.3 -- purging an entire modified file's
|
|
932
|
+
# doc_ids up front, as Phase 3.1 originally did, would have
|
|
933
|
+
# thrown away exactly the still-correct, still-valid entries
|
|
934
|
+
# for its UNCHANGED symbols before the new, symbol-level
|
|
935
|
+
# `_diff_and_purge_file_symbols` (called per-file inside the
|
|
936
|
+
# parse loop below) ever gets a chance to decide they didn't
|
|
937
|
+
# need touching at all. Added files never needed this
|
|
938
|
+
# (nothing exists yet to purge) either way.
|
|
939
|
+
stale_paths = sorted(set(incremental_plan.deleted_relpaths))
|
|
940
|
+
removed_symbol_keys |= self._purge_stale_entries(repo_id, stale_paths)
|
|
941
|
+
elif incremental_plan.reason:
|
|
942
|
+
logger.info("Full re-index for repo_id=%s: %s", repo_id, incremental_plan.reason)
|
|
943
|
+
|
|
944
|
+
# Files whose parse RAISED this run: their previous rows are kept (see
|
|
945
|
+
# the full-run purge below) -- a transient failure must not delete
|
|
946
|
+
# previously good data.
|
|
947
|
+
failed_relpaths: set[str] = set()
|
|
948
|
+
parsed_files: list[FileParseResult] = []
|
|
949
|
+
all_symbols: list[Symbol] = []
|
|
950
|
+
all_chunks: list[CodeChunk] = []
|
|
951
|
+
# Maps id(chunk) -> the Symbol object it was generated from. Needed
|
|
952
|
+
# because a chunk's own start_line/end_line describe the chunk's
|
|
953
|
+
# line range (which differs from the symbol's for body_chunk pieces),
|
|
954
|
+
# not the symbol's identity -- this is how embeddings get correctly
|
|
955
|
+
# linked back to a specific Symbol row during persistence (Phase 0.5).
|
|
956
|
+
chunk_symbol_map: dict[int, Symbol] = {}
|
|
957
|
+
# Phase 3.3: `id(sym)` of every freshly parsed symbol determined
|
|
958
|
+
# to be byte-identical (same `body_hash`) to its existing,
|
|
959
|
+
# durable row -- populated per-file by `_diff_and_purge_file_symbols`
|
|
960
|
+
# below. Accumulated across every file parsed this run so both
|
|
961
|
+
# the chunking step (immediately below) and the BM25 population
|
|
962
|
+
# step (later in this method) can skip the same symbols.
|
|
963
|
+
unchanged_symbol_ids: set[int] = set()
|
|
964
|
+
|
|
965
|
+
# 2. Parse files with AST parsers
|
|
966
|
+
for idx, file_path in enumerate(all_files, start=1):
|
|
967
|
+
rel_path = os.path.relpath(file_path, repo_path).replace("\\", "/")
|
|
968
|
+
if progress_callback:
|
|
969
|
+
progress_callback(rel_path, idx, total_files)
|
|
970
|
+
|
|
971
|
+
parser = get_parser_for_file(file_path)
|
|
972
|
+
if not parser:
|
|
973
|
+
continue
|
|
974
|
+
|
|
975
|
+
# Phase 1.5 / roadmap §15: "max file size parsed" resource
|
|
976
|
+
# limit. A single oversized file is skipped (logged, not
|
|
977
|
+
# fatal to the run) -- the same per-file error-tolerance
|
|
978
|
+
# already established for malformed/unparseable files just
|
|
979
|
+
# below, not a new failure mode.
|
|
980
|
+
try:
|
|
981
|
+
file_size = os.path.getsize(file_path)
|
|
982
|
+
except OSError:
|
|
983
|
+
file_size = 0
|
|
984
|
+
if file_size > max_file_size:
|
|
985
|
+
logger.warning(
|
|
986
|
+
"Skipping oversized file '%s' (%d bytes > limit %d)",
|
|
987
|
+
rel_path,
|
|
988
|
+
file_size,
|
|
989
|
+
max_file_size,
|
|
990
|
+
)
|
|
991
|
+
continue
|
|
992
|
+
|
|
993
|
+
try:
|
|
994
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
995
|
+
source_code = fh.read()
|
|
996
|
+
|
|
997
|
+
parse_res = parser.parse_file(rel_path, source_code)
|
|
998
|
+
# Phase 3.1: repo-qualified key (previously bare `rel_path`,
|
|
999
|
+
# which this dict's only prior use -- write-only, never
|
|
1000
|
+
# read -- never exercised the cross-repo collision risk
|
|
1001
|
+
# that a bare key would create once it became load-bearing
|
|
1002
|
+
# for call-graph-completeness below).
|
|
1003
|
+
self.parse_results[f"{repo_id}::{rel_path}"] = parse_res
|
|
1004
|
+
parsed_files.append(parse_res)
|
|
1005
|
+
|
|
1006
|
+
# Phase 3.3: compare this file's freshly parsed symbols
|
|
1007
|
+
# against its existing, durable rows (if any) and purge
|
|
1008
|
+
# exactly the doc_ids that need it -- skipped for a
|
|
1009
|
+
# rename's new path, since `_rekey_renamed_files` already
|
|
1010
|
+
# purged that file's doc_ids in full (every symbol's
|
|
1011
|
+
# doc_id embeds the file path, so it changes regardless
|
|
1012
|
+
# of body content -- see that method's own docstring).
|
|
1013
|
+
# A brand-new file (never indexed before) or a full,
|
|
1014
|
+
# non-incremental run both safely no-op here (nothing to
|
|
1015
|
+
# compare against / not called at all, respectively).
|
|
1016
|
+
if incremental_plan.is_incremental and rel_path not in renamed_new_paths:
|
|
1017
|
+
file_unchanged_ids, file_disappeared_keys = self._diff_and_purge_file_symbols(
|
|
1018
|
+
repo_id, rel_path, parse_res.symbols
|
|
1019
|
+
)
|
|
1020
|
+
unchanged_symbol_ids |= file_unchanged_ids
|
|
1021
|
+
removed_symbol_keys |= file_disappeared_keys
|
|
1022
|
+
|
|
1023
|
+
for sym in parse_res.symbols:
|
|
1024
|
+
all_symbols.append(sym)
|
|
1025
|
+
if id(sym) in unchanged_symbol_ids:
|
|
1026
|
+
# Phase 3.3 -- the single biggest cost saver §9
|
|
1027
|
+
# describes: this symbol's body is byte-identical
|
|
1028
|
+
# to what's already durably stored and correctly
|
|
1029
|
+
# indexed, so it is neither re-chunked nor
|
|
1030
|
+
# re-submitted for embedding at all.
|
|
1031
|
+
continue
|
|
1032
|
+
# Generate semantic chunks
|
|
1033
|
+
chunks = self.chunker.chunk_symbol(sym)
|
|
1034
|
+
for c in chunks:
|
|
1035
|
+
chunk_symbol_map[id(c)] = sym
|
|
1036
|
+
all_chunks.extend(chunks)
|
|
1037
|
+
|
|
1038
|
+
except Exception as e:
|
|
1039
|
+
failed_relpaths.add(rel_path)
|
|
1040
|
+
logger.warning("Error parsing file '%s': %s", rel_path, e)
|
|
1041
|
+
|
|
1042
|
+
# A FULL run's parsed set is the repository's complete current
|
|
1043
|
+
# indexable content, so any stored file outside it is stale. Until
|
|
1044
|
+
# 2026-10-12 only the incremental path purged (and a full run is what
|
|
1045
|
+
# non-git repositories, first runs and every post-restart run get),
|
|
1046
|
+
# so deleted/ignored files lingered in the database, the keyword
|
|
1047
|
+
# index after a restart and the restored symbol map.
|
|
1048
|
+
if not incremental_plan.is_incremental:
|
|
1049
|
+
removed_symbol_keys |= self._purge_files_missing_from_full_run(
|
|
1050
|
+
repo_id, {pf.file_path for pf in parsed_files}, failed_relpaths
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
logger.info(
|
|
1054
|
+
"Parsed %d files. Extracted %d symbols and %d semantic chunks.",
|
|
1055
|
+
len(parsed_files),
|
|
1056
|
+
len(all_symbols),
|
|
1057
|
+
len(all_chunks),
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
# 3. Build Call Graph
|
|
1061
|
+
# Phase 0.6: stored per-repo (call_graphs) so a previously indexed
|
|
1062
|
+
# repository's graph isn't discarded/overwritten by this run --
|
|
1063
|
+
# `call_graph` is kept in sync as a "most recently indexed" alias
|
|
1064
|
+
# for existing callers that are out of this milestone's scope.
|
|
1065
|
+
#
|
|
1066
|
+
# Phase 3.1: on an incremental run, `parsed_files` above only
|
|
1067
|
+
# covers this run's changed/added files -- building the call
|
|
1068
|
+
# graph from that alone would silently regress completeness
|
|
1069
|
+
# (edges from every unchanged file would vanish from
|
|
1070
|
+
# `self.call_graphs[repo_id]`). This IN-MEMORY graph is still a
|
|
1071
|
+
# full rebuild from merged parse data every run (cheap -- no
|
|
1072
|
+
# embedding involved), NOT the "recompute only what changed"
|
|
1073
|
+
# BFS Phase 3.5 implements -- that operates one layer down, at
|
|
1074
|
+
# the DURABLE `CallEdge` persistence step (`_persist_call_graph_
|
|
1075
|
+
# edges`), deciding which DB rows to touch from THIS complete,
|
|
1076
|
+
# freshly (re)built in-memory graph. Merge in this engine
|
|
1077
|
+
# instance's own cached `FileParseResult`s (populated by
|
|
1078
|
+
# every parse, this run and prior ones, keyed by
|
|
1079
|
+
# "{repo_id}::{rel_path}") for every currently-existing,
|
|
1080
|
+
# unchanged file. `_plan_incremental_run`'s own
|
|
1081
|
+
# "no in-memory call graph cached yet" guard is what keeps this
|
|
1082
|
+
# safe after a process restart (that case always forces a full
|
|
1083
|
+
# run instead, so this merge is never reached with a cold cache).
|
|
1084
|
+
if incremental_plan.is_incremental:
|
|
1085
|
+
cg_parsed_files = list(parsed_files)
|
|
1086
|
+
seen_relpaths = {pf.file_path for pf in parsed_files}
|
|
1087
|
+
prefix = f"{repo_id}::"
|
|
1088
|
+
for key, cached in self.parse_results.items():
|
|
1089
|
+
if not key.startswith(prefix):
|
|
1090
|
+
continue
|
|
1091
|
+
cached_relpath = key[len(prefix):]
|
|
1092
|
+
if cached_relpath in seen_relpaths or cached_relpath in incremental_plan.deleted_relpaths:
|
|
1093
|
+
continue
|
|
1094
|
+
cg_parsed_files.append(cached)
|
|
1095
|
+
else:
|
|
1096
|
+
cg_parsed_files = parsed_files
|
|
1097
|
+
repo_call_graph = CallGraphBuilder(cg_parsed_files)
|
|
1098
|
+
self.call_graphs[repo_id] = repo_call_graph
|
|
1099
|
+
self.call_graph = repo_call_graph
|
|
1100
|
+
self._restored_call_graph_repo_ids.discard(repo_id)
|
|
1101
|
+
# Every parsed file and the resolved file-to-file import edges, from
|
|
1102
|
+
# the SAME complete (merged, on an incremental run) parse set the
|
|
1103
|
+
# call graph was just built from, persisted below so module
|
|
1104
|
+
# structure survives a restart. Clustering is computed on demand.
|
|
1105
|
+
module_snapshot = ModuleGraphBuilder.snapshot(cg_parsed_files)
|
|
1106
|
+
self._restored_module_graphs.pop(repo_id, None)
|
|
1107
|
+
|
|
1108
|
+
# 4. Generate Embeddings & Populate Vector Index
|
|
1109
|
+
#
|
|
1110
|
+
# Permanent invariant: NO silent pseudo-embedding fallback. If the
|
|
1111
|
+
# configured embedding model cannot be loaded or fails to encode,
|
|
1112
|
+
# CodeEmbedder.encode() raises EmbeddingUnavailableError -- caught
|
|
1113
|
+
# here explicitly. Symbol/File persistence still proceeds (lexical
|
|
1114
|
+
# search remains available, per roadmap §17's "degraded mode"), but
|
|
1115
|
+
# no embedding is generated, persisted, or added to the vector index
|
|
1116
|
+
# for this run, and the failure is reported in the returned stats
|
|
1117
|
+
# rather than silently reported as success.
|
|
1118
|
+
#
|
|
1119
|
+
# Backend-hardening fix: mirrors the exact same "wipe this repo's
|
|
1120
|
+
# entries before a full re-index" treatment `bm25_index.remove_repo`
|
|
1121
|
+
# already receives below (step 5) -- see that call's own comment for
|
|
1122
|
+
# why this is only correct for a FULL run (an incremental run's
|
|
1123
|
+
# `_purge_stale_entries`/`remove_docs` calls already remove exactly
|
|
1124
|
+
# the stale entries, and a whole-repo wipe here would discard every
|
|
1125
|
+
# unchanged file's embeddings with nothing in this run to re-add
|
|
1126
|
+
# them). Closes a real, previously-disclosed gap: unlike
|
|
1127
|
+
# `BM25Index`, `VectorIndex` had no repository-replacement call at
|
|
1128
|
+
# all until now, so a full re-index of an already-indexed
|
|
1129
|
+
# repository silently accumulated duplicate vector entries forever
|
|
1130
|
+
# (see `VectorIndex.remove_repo`'s own docstring and
|
|
1131
|
+
# CODEPULSE_PROGRESS.md for the full history).
|
|
1132
|
+
if not incremental_plan.is_incremental:
|
|
1133
|
+
self.vector_index.remove_repo(repo_id)
|
|
1134
|
+
|
|
1135
|
+
chunk_embeddings: list[tuple[Symbol, CodeChunk, np.ndarray]] = []
|
|
1136
|
+
embedding_error: str | None = None
|
|
1137
|
+
|
|
1138
|
+
if all_chunks:
|
|
1139
|
+
chunk_texts = [c.content for c in all_chunks]
|
|
1140
|
+
try:
|
|
1141
|
+
embeddings = self.embedder.encode(chunk_texts)
|
|
1142
|
+
except EmbeddingUnavailableError as e:
|
|
1143
|
+
embedding_error = str(e)
|
|
1144
|
+
logger.error(
|
|
1145
|
+
"Embedding generation failed for '%s' -- proceeding in "
|
|
1146
|
+
"lexical-only degraded mode, no embeddings persisted this run: %s",
|
|
1147
|
+
repo_path,
|
|
1148
|
+
e,
|
|
1149
|
+
)
|
|
1150
|
+
embeddings = None
|
|
1151
|
+
|
|
1152
|
+
if embeddings is not None:
|
|
1153
|
+
doc_ids = []
|
|
1154
|
+
metadatas = []
|
|
1155
|
+
for idx, c in enumerate(all_chunks):
|
|
1156
|
+
# Phase 0.6: doc_id is repo-qualified and metadata carries
|
|
1157
|
+
# an explicit repo_id -- without this, two repositories
|
|
1158
|
+
# indexing files at the same relative path (a realistic,
|
|
1159
|
+
# explicitly-tested scenario) would collide in this
|
|
1160
|
+
# shared in-memory vector index instead of staying
|
|
1161
|
+
# isolated.
|
|
1162
|
+
chunk_symbol = chunk_symbol_map[id(c)]
|
|
1163
|
+
doc_id = chunk_doc_id(repo_id, c.file_path, chunk_symbol.name, chunk_symbol.start_line, c.chunk_index)
|
|
1164
|
+
doc_ids.append(doc_id)
|
|
1165
|
+
metadatas.append({
|
|
1166
|
+
"repo_id": repo_id,
|
|
1167
|
+
"symbol_name": c.symbol_name,
|
|
1168
|
+
"symbol_start_line": chunk_symbol.start_line,
|
|
1169
|
+
"file_path": c.file_path,
|
|
1170
|
+
"chunk_type": c.chunk_type,
|
|
1171
|
+
"start_line": c.start_line,
|
|
1172
|
+
"end_line": c.end_line,
|
|
1173
|
+
"snippet": c.content[:200],
|
|
1174
|
+
})
|
|
1175
|
+
chunk_embeddings.append((chunk_symbol, c, embeddings[idx]))
|
|
1176
|
+
|
|
1177
|
+
self.vector_index.add_batch(doc_ids, embeddings, metadatas)
|
|
1178
|
+
|
|
1179
|
+
# 5. Populate BM25 (Tantivy-backed, Phase 2.1) Index
|
|
1180
|
+
# Phase 0.6: doc_id is repo-qualified (same collision reasoning as
|
|
1181
|
+
# the vector index above) and repo_id is threaded into the BM25
|
|
1182
|
+
# metadata and symbol_repo_map so repository-scoped search can
|
|
1183
|
+
# filter candidates without a second identity system.
|
|
1184
|
+
#
|
|
1185
|
+
# Phase 2.1: `remove_repo` deletes every lexical document this
|
|
1186
|
+
# engine has previously indexed for THIS repository before the
|
|
1187
|
+
# fresh add pass below -- required so that re-indexing an
|
|
1188
|
+
# already-indexed repository (a real, supported flow: a new
|
|
1189
|
+
# IndexJob can be created once the previous one is no longer
|
|
1190
|
+
# active, Phase 1.3) replaces rather than accumulates its lexical
|
|
1191
|
+
# documents. Safe to call on a never-before-indexed (or empty)
|
|
1192
|
+
# repository -- a no-op in that case (see BM25Index.remove_repo's
|
|
1193
|
+
# own docstring). This was a real, pre-existing, untested gap in
|
|
1194
|
+
# the pre-Phase-2.1 in-memory BM25Index (no doc_id-based replace
|
|
1195
|
+
# logic existed there at all); fixed here because Tantivy's
|
|
1196
|
+
# delete-by-term makes it essentially free, with zero impact on
|
|
1197
|
+
# the ranking contract.
|
|
1198
|
+
#
|
|
1199
|
+
# Phase 3.1: this whole-repo wipe is only correct for a FULL run,
|
|
1200
|
+
# where `all_symbols` below is about to cover every symbol in the
|
|
1201
|
+
# repository (so wipe-then-readd-everything is a correct
|
|
1202
|
+
# replace). On an INCREMENTAL run, `all_symbols` only covers the
|
|
1203
|
+
# changed/added subset -- calling `remove_repo` here would wipe
|
|
1204
|
+
# every unchanged file's lexical documents too, with nothing in
|
|
1205
|
+
# this run re-adding them back. `_purge_stale_entries` (called
|
|
1206
|
+
# earlier, before the parse loop) already removed exactly the
|
|
1207
|
+
# stale entries for modified/deleted files, so no whole-repo wipe
|
|
1208
|
+
# is needed -- or correct -- for an incremental run.
|
|
1209
|
+
if not incremental_plan.is_incremental:
|
|
1210
|
+
self.bm25_index.remove_repo(repo_id)
|
|
1211
|
+
# The same wipe-then-readd for the in-memory symbol map: it can
|
|
1212
|
+
# start non-empty (restored from the durable rows at startup, or
|
|
1213
|
+
# left by an earlier run in this process), and re-adding only the
|
|
1214
|
+
# CURRENT symbols would otherwise leave a deleted file's symbols
|
|
1215
|
+
# behind in autocomplete and the graph-score walk.
|
|
1216
|
+
for stale_doc_id in [d for d, r in self.symbol_repo_map.items() if r == repo_id]:
|
|
1217
|
+
self._forget_symbol(stale_doc_id)
|
|
1218
|
+
for sym in all_symbols:
|
|
1219
|
+
doc_id = symbol_doc_id(repo_id, sym.file_path, sym.name, sym.start_line)
|
|
1220
|
+
self.symbols_by_id[doc_id] = sym
|
|
1221
|
+
self.symbol_repo_map[doc_id] = repo_id
|
|
1222
|
+
if id(sym) in unchanged_symbol_ids:
|
|
1223
|
+
# Phase 3.3: this symbol's existing lexical document is
|
|
1224
|
+
# already correct and was deliberately left untouched by
|
|
1225
|
+
# `_diff_and_purge_file_symbols` (never purged) -- adding
|
|
1226
|
+
# it again here would create a duplicate Tantivy
|
|
1227
|
+
# document under the same doc_id. `unchanged_symbol_ids`
|
|
1228
|
+
# is only ever populated during an incremental run (see
|
|
1229
|
+
# its own docstring), so this is unreachable -- and would
|
|
1230
|
+
# be unsafe -- during a full run, where `remove_repo`
|
|
1231
|
+
# just unconditionally wiped every symbol's document
|
|
1232
|
+
# above and every one of them genuinely needs re-adding.
|
|
1233
|
+
continue
|
|
1234
|
+
self.bm25_index.add_symbol(doc_id, sym, repo_id=repo_id)
|
|
1235
|
+
|
|
1236
|
+
self.bm25_index.build()
|
|
1237
|
+
|
|
1238
|
+
# Phase 3.5 (roadmap §37 item 3.5: "Local BFS incremental graph
|
|
1239
|
+
# update"): the complete set of symbol identities that stopped
|
|
1240
|
+
# existing under their PRIOR (file_path, name) this run --
|
|
1241
|
+
# every disappeared/renamed-away key already accumulated above,
|
|
1242
|
+
# plus every symbol in `all_symbols` that wasn't recognized as
|
|
1243
|
+
# byte-identical (i.e. every genuinely new-or-changed symbol,
|
|
1244
|
+
# under its CURRENT identity -- stable across a body-only
|
|
1245
|
+
# change, so "current" and "prior" are the same key for that
|
|
1246
|
+
# case). Passed to `_persist_to_db` -> `_persist_call_graph_edges`
|
|
1247
|
+
# to decide exactly which `CallEdge` rows need recomputing.
|
|
1248
|
+
changed_symbol_keys = removed_symbol_keys | {
|
|
1249
|
+
(sym.file_path, sym.name) for sym in all_symbols if id(sym) not in unchanged_symbol_ids
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
# 6. Save to Database
|
|
1253
|
+
try:
|
|
1254
|
+
stats = self._persist_to_db(
|
|
1255
|
+
repo_name=repo_name,
|
|
1256
|
+
repo_url=resolved_repo_url,
|
|
1257
|
+
parsed_files=parsed_files,
|
|
1258
|
+
all_symbols=all_symbols,
|
|
1259
|
+
all_chunks=all_chunks,
|
|
1260
|
+
chunk_embeddings=chunk_embeddings,
|
|
1261
|
+
is_incremental=incremental_plan.is_incremental,
|
|
1262
|
+
changed_symbol_keys=changed_symbol_keys,
|
|
1263
|
+
module_snapshot=module_snapshot,
|
|
1264
|
+
)
|
|
1265
|
+
except IndexingPersistenceError:
|
|
1266
|
+
# The database kept its previous state (the transaction rolled back), so put the in-memory
|
|
1267
|
+
# indexes back in line with it; otherwise a running server would serve content that a
|
|
1268
|
+
# restart would lose. No snapshot is recorded for a run that was not saved.
|
|
1269
|
+
self._realign_memory_with_database(repo_id, resolved_repo_url)
|
|
1270
|
+
raise
|
|
1271
|
+
|
|
1272
|
+
# Phase 3.1: record this run's snapshot (commit_sha + embedding
|
|
1273
|
+
# config) AFTER successful persistence, whether this run was
|
|
1274
|
+
# incremental or full -- so the *next* call's
|
|
1275
|
+
# `_plan_incremental_run` has an up-to-date basis for comparison.
|
|
1276
|
+
# See `_record_snapshot`'s own docstring for why a failure here
|
|
1277
|
+
# is logged rather than raised.
|
|
1278
|
+
self._record_snapshot(repo_id, repo_path)
|
|
1279
|
+
|
|
1280
|
+
return {
|
|
1281
|
+
"repository_name": repo_name,
|
|
1282
|
+
"repository_id": repo_id,
|
|
1283
|
+
"total_files": len(parsed_files),
|
|
1284
|
+
"total_symbols": len(all_symbols),
|
|
1285
|
+
"total_chunks": len(all_chunks),
|
|
1286
|
+
"call_graph_edges": len(repo_call_graph.edges),
|
|
1287
|
+
"embedding_status": "failed" if embedding_error else ("skipped_no_chunks" if not all_chunks else "success"),
|
|
1288
|
+
"embedding_error": embedding_error,
|
|
1289
|
+
"degraded_mode": embedding_error is not None,
|
|
1290
|
+
"database_stats": stats,
|
|
1291
|
+
# Phase 3.1: discloses whether this run was incremental (and
|
|
1292
|
+
# why), rather than requiring a caller to reverse-engineer it
|
|
1293
|
+
# from `total_files` alone.
|
|
1294
|
+
"incremental": incremental_plan.is_incremental,
|
|
1295
|
+
"incremental_reason": incremental_plan.reason,
|
|
1296
|
+
"deleted_files": list(incremental_plan.deleted_relpaths),
|
|
1297
|
+
# Phase 3.2: {old, new} pairs re-keyed in place this run.
|
|
1298
|
+
"renamed_files": [{"old": old, "new": new} for old, new in incremental_plan.renamed],
|
|
1299
|
+
# Phase 3.3: how many symbols were recognized as byte-
|
|
1300
|
+
# identical to their existing, durable row and therefore
|
|
1301
|
+
# skipped re-chunking/re-embedding (and, for BM25, skipped
|
|
1302
|
+
# re-adding) entirely this run.
|
|
1303
|
+
"symbols_skipped_unchanged": len(unchanged_symbol_ids),
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
def _persist_call_graph_edges(
|
|
1307
|
+
self,
|
|
1308
|
+
session,
|
|
1309
|
+
repo_id: int,
|
|
1310
|
+
is_incremental: bool,
|
|
1311
|
+
changed_symbol_keys: set[tuple[str, str]],
|
|
1312
|
+
) -> None:
|
|
1313
|
+
"""
|
|
1314
|
+
Phase 3.5 (roadmap §37 item 3.5: "Local BFS incremental graph
|
|
1315
|
+
update"; §9: "rather than rebuilding the whole graph, recompute
|
|
1316
|
+
only edges whose *source* symbol is in the changed set, plus a
|
|
1317
|
+
local BFS one hop out to catch edges whose *target* changed (a
|
|
1318
|
+
symbol being renamed/deleted must have inbound edges from
|
|
1319
|
+
unrelated files corrected too)").
|
|
1320
|
+
|
|
1321
|
+
Replaces the prior behavior -- every run unconditionally
|
|
1322
|
+
appending every edge in `self.call_graph.edges` -- which is
|
|
1323
|
+
exactly the long-disclosed, previously-deferred gap every
|
|
1324
|
+
milestone since Phase 0.4 has pointed at this one to fix:
|
|
1325
|
+
"CallEdge rows still duplicate on every re-index." Uses
|
|
1326
|
+
`CallEdge.repo_id` (added by the repository-isolation fix that
|
|
1327
|
+
preceded this milestone) to scope every delete precisely to
|
|
1328
|
+
this repository -- nothing here can touch another repository's
|
|
1329
|
+
rows even by accident.
|
|
1330
|
+
|
|
1331
|
+
FULL run (`is_incremental=False`): every existing `CallEdge` row
|
|
1332
|
+
for this `repo_id` is deleted, then the complete fresh set from
|
|
1333
|
+
`self.call_graph.edges` is inserted -- a full, correct,
|
|
1334
|
+
one-time replace. Mirrors `BM25Index.remove_repo`'s existing
|
|
1335
|
+
full-run pattern (Phase 2.1), which `CallEdge` could not follow
|
|
1336
|
+
until it had a `repo_id` to scope the delete by.
|
|
1337
|
+
|
|
1338
|
+
INCREMENTAL run: a genuinely surgical update, not a full
|
|
1339
|
+
rebuild, matching §9's literal recompute-only-what-changed
|
|
1340
|
+
design:
|
|
1341
|
+
1. Delete every existing row whose `(source_file,
|
|
1342
|
+
source_symbol_name)` is in `changed_symbol_keys` -- these
|
|
1343
|
+
are about to be replaced by a fresh recompute of exactly
|
|
1344
|
+
that symbol's current outgoing calls.
|
|
1345
|
+
2. "Local BFS one hop out": query the REMAINING rows (after
|
|
1346
|
+
step 1) for this repo whose `(target_file,
|
|
1347
|
+
target_symbol_name)` is in `changed_symbol_keys` -- these
|
|
1348
|
+
belong to OTHER, unrelated (possibly unchanged-this-run)
|
|
1349
|
+
symbols that call something which just changed, was
|
|
1350
|
+
renamed, or disappeared. Their own `(source_file,
|
|
1351
|
+
source_symbol_name)` is collected as `one_hop_keys` and
|
|
1352
|
+
those rows are deleted too, to be recomputed fresh in step
|
|
1353
|
+
3 -- this is what "a symbol being renamed/deleted must
|
|
1354
|
+
have inbound edges from unrelated files corrected too"
|
|
1355
|
+
concretely means here: the caller's own edge, freshly
|
|
1356
|
+
re-derived from `self.call_graph` (which is guaranteed
|
|
1357
|
+
complete for this repository -- built from every file's
|
|
1358
|
+
parse result, cached or freshly reparsed, per Phase 3.1's
|
|
1359
|
+
own cache-completeness design -- so the caller's file
|
|
1360
|
+
does NOT need to have been reparsed this run for its edge
|
|
1361
|
+
to be correctly recomputed), will naturally reflect the
|
|
1362
|
+
correction (e.g. downgrading to `"unresolved"` if the
|
|
1363
|
+
callee it used to resolve to no longer exists under that
|
|
1364
|
+
identity).
|
|
1365
|
+
3. Insert every edge in `self.call_graph.edges` whose
|
|
1366
|
+
`(source_file, source_symbol_name)` is in
|
|
1367
|
+
`changed_symbol_keys | one_hop_keys`.
|
|
1368
|
+
|
|
1369
|
+
A symbol whose file was neither changed nor a one-hop neighbor
|
|
1370
|
+
of anything changed is completely untouched -- its existing,
|
|
1371
|
+
already-correct `CallEdge` rows are left exactly as they were,
|
|
1372
|
+
avoiding both the accumulation problem and any unnecessary
|
|
1373
|
+
rewrite.
|
|
1374
|
+
|
|
1375
|
+
Phase 4.1 (roadmap §37 item 4.1): also persists
|
|
1376
|
+
`self.call_graph.inheritance_edges` (`relationship_type=
|
|
1377
|
+
"inherits"`) through this EXACT SAME algorithm, alongside
|
|
1378
|
+
`self.call_graph.edges` (`relationship_type="calls"`) -- not a
|
|
1379
|
+
second persistence mechanism. This is correct, not just
|
|
1380
|
+
convenient, because the delete/BFS logic above is already
|
|
1381
|
+
relationship-type-AGNOSTIC: it deletes and recomputes by
|
|
1382
|
+
`(source_file, source_symbol_name)` / `(target_file,
|
|
1383
|
+
target_symbol_name)` alone, with no `relationship_type` filter
|
|
1384
|
+
anywhere -- so a changed class's stale "calls" edges AND stale
|
|
1385
|
+
"inherits" edges are both correctly deleted together (its
|
|
1386
|
+
complete outgoing edge set changed, regardless of kind), and the
|
|
1387
|
+
local-BFS-one-hop query already finds a caller through an
|
|
1388
|
+
ordinary function call and a subclass extending a renamed/
|
|
1389
|
+
deleted base class equally, via the exact same query. Each row
|
|
1390
|
+
is still written with its own correct `relationship_type`,
|
|
1391
|
+
taken directly from the in-memory `CallGraphEdge.
|
|
1392
|
+
relationship_type` field.
|
|
1393
|
+
"""
|
|
1394
|
+
if not self.call_graph:
|
|
1395
|
+
return
|
|
1396
|
+
|
|
1397
|
+
all_edges = self.call_graph.edges + self.call_graph.inheritance_edges
|
|
1398
|
+
|
|
1399
|
+
if not is_incremental:
|
|
1400
|
+
session.query(CallEdge).filter(CallEdge.repo_id == repo_id).delete(synchronize_session=False)
|
|
1401
|
+
for edge in all_edges:
|
|
1402
|
+
session.add(CallEdge(
|
|
1403
|
+
repo_id=repo_id,
|
|
1404
|
+
source_symbol_name=edge.source_symbol_name,
|
|
1405
|
+
source_file=edge.source_file,
|
|
1406
|
+
target_symbol_name=edge.target_symbol_name,
|
|
1407
|
+
target_file=edge.target_file,
|
|
1408
|
+
call_count=edge.call_count,
|
|
1409
|
+
confidence=edge.confidence,
|
|
1410
|
+
relationship_type=edge.relationship_type,
|
|
1411
|
+
))
|
|
1412
|
+
return
|
|
1413
|
+
|
|
1414
|
+
if not changed_symbol_keys:
|
|
1415
|
+
# An incremental run with an empty changeset (e.g. no
|
|
1416
|
+
# changes since the last indexed commit) -- nothing to
|
|
1417
|
+
# recompute, nothing to touch.
|
|
1418
|
+
return
|
|
1419
|
+
|
|
1420
|
+
for file_path, name in changed_symbol_keys:
|
|
1421
|
+
session.query(CallEdge).filter(
|
|
1422
|
+
CallEdge.repo_id == repo_id,
|
|
1423
|
+
CallEdge.source_file == file_path,
|
|
1424
|
+
CallEdge.source_symbol_name == name,
|
|
1425
|
+
).delete(synchronize_session=False)
|
|
1426
|
+
|
|
1427
|
+
one_hop_keys: set[tuple[str, str]] = set()
|
|
1428
|
+
for file_path, name in changed_symbol_keys:
|
|
1429
|
+
callers = (
|
|
1430
|
+
session.query(CallEdge.source_file, CallEdge.source_symbol_name)
|
|
1431
|
+
.filter(
|
|
1432
|
+
CallEdge.repo_id == repo_id,
|
|
1433
|
+
CallEdge.target_file == file_path,
|
|
1434
|
+
CallEdge.target_symbol_name == name,
|
|
1435
|
+
)
|
|
1436
|
+
.all()
|
|
1437
|
+
)
|
|
1438
|
+
for src_file, src_name in callers:
|
|
1439
|
+
if (src_file, src_name) not in changed_symbol_keys:
|
|
1440
|
+
one_hop_keys.add((src_file, src_name))
|
|
1441
|
+
|
|
1442
|
+
for file_path, name in one_hop_keys:
|
|
1443
|
+
session.query(CallEdge).filter(
|
|
1444
|
+
CallEdge.repo_id == repo_id,
|
|
1445
|
+
CallEdge.source_file == file_path,
|
|
1446
|
+
CallEdge.source_symbol_name == name,
|
|
1447
|
+
).delete(synchronize_session=False)
|
|
1448
|
+
|
|
1449
|
+
recompute_keys = changed_symbol_keys | one_hop_keys
|
|
1450
|
+
for edge in all_edges:
|
|
1451
|
+
if (edge.source_file, edge.source_symbol_name) not in recompute_keys:
|
|
1452
|
+
continue
|
|
1453
|
+
session.add(CallEdge(
|
|
1454
|
+
repo_id=repo_id,
|
|
1455
|
+
source_symbol_name=edge.source_symbol_name,
|
|
1456
|
+
source_file=edge.source_file,
|
|
1457
|
+
target_symbol_name=edge.target_symbol_name,
|
|
1458
|
+
target_file=edge.target_file,
|
|
1459
|
+
call_count=edge.call_count,
|
|
1460
|
+
confidence=edge.confidence,
|
|
1461
|
+
relationship_type=edge.relationship_type,
|
|
1462
|
+
))
|
|
1463
|
+
|
|
1464
|
+
def _realign_memory_with_database(self, repo_id: int, repo_url: str) -> None:
|
|
1465
|
+
"""
|
|
1466
|
+
Drops everything this process holds in memory for `repo_id` and restores it from the database, so
|
|
1467
|
+
memory and durable state agree again after a run that could not be saved. This is the same restore
|
|
1468
|
+
a restart performs (keyword and vector indexes, symbol map, call graph, module graph); the parse
|
|
1469
|
+
cache is dropped too, so the next run cannot build on content that was never saved.
|
|
1470
|
+
"""
|
|
1471
|
+
self.bm25_index.remove_repo(repo_id)
|
|
1472
|
+
self.vector_index.remove_repo(repo_id)
|
|
1473
|
+
for doc_id in [d for d, r in self.symbol_repo_map.items() if r == repo_id]:
|
|
1474
|
+
self._forget_symbol(doc_id)
|
|
1475
|
+
prefix = f"{repo_id}::"
|
|
1476
|
+
for key in [k for k in self.parse_results if k.startswith(prefix)]:
|
|
1477
|
+
self.parse_results.pop(key, None)
|
|
1478
|
+
dropped = self.call_graphs.pop(repo_id, None)
|
|
1479
|
+
if dropped is not None and self.call_graph is dropped:
|
|
1480
|
+
self.call_graph = None
|
|
1481
|
+
self._restored_call_graph_repo_ids.discard(repo_id)
|
|
1482
|
+
self._restored_module_graphs.pop(repo_id, None)
|
|
1483
|
+
self.hydrate_repository(repo_url)
|
|
1484
|
+
|
|
1485
|
+
def _module_edges_table_exists(self, session) -> bool:
|
|
1486
|
+
"""
|
|
1487
|
+
False on a database that has not been migrated to the revision that
|
|
1488
|
+
adds `module_edges` (an Alembic-tracked database, where
|
|
1489
|
+
`StorageManager` deliberately never creates tables). Everything
|
|
1490
|
+
touching the table checks this first, so an unmigrated database
|
|
1491
|
+
keeps indexing exactly as before and only lacks the restart-proof
|
|
1492
|
+
module structure -- logged once, never silent.
|
|
1493
|
+
"""
|
|
1494
|
+
# Through the session's OWN connection. Inspecting `session.get_bind()` (the engine) opens a
|
|
1495
|
+
# SEPARATE connection, which SQLite refuses ("database is locked") once the surrounding write
|
|
1496
|
+
# transaction has grown past its page cache and spilled -- i.e. on any real-sized repository,
|
|
1497
|
+
# which made the whole save fail (found by the 2026-10-15 publication audit).
|
|
1498
|
+
if sa_inspect(session.connection()).has_table("module_edges"):
|
|
1499
|
+
return True
|
|
1500
|
+
if not self._warned_module_edges_missing:
|
|
1501
|
+
self._warned_module_edges_missing = True
|
|
1502
|
+
logger.warning(
|
|
1503
|
+
"Table 'module_edges' does not exist (the database has not been migrated): "
|
|
1504
|
+
"module structure will not survive a restart until `codepulse migrate` is run."
|
|
1505
|
+
)
|
|
1506
|
+
return False
|
|
1507
|
+
|
|
1508
|
+
def _persist_module_edges(
|
|
1509
|
+
self, session, repo_id: int, files: list[str], edges: list[tuple[str, str]]
|
|
1510
|
+
) -> None:
|
|
1511
|
+
"""
|
|
1512
|
+
Replaces this repository's `module_edges` rows with the complete
|
|
1513
|
+
current snapshot -- on an incremental run that is the merged set, not
|
|
1514
|
+
just the changed files'. One row per resolved edge, plus one row with
|
|
1515
|
+
a NULL target for every file with no outgoing edge, so the node set
|
|
1516
|
+
is stored (see `ModuleEdge`). A wholesale replace is correct and cheap
|
|
1517
|
+
at file level, and is what makes re-indexing idempotent. Runs in the
|
|
1518
|
+
caller's transaction.
|
|
1519
|
+
"""
|
|
1520
|
+
if not self._module_edges_table_exists(session):
|
|
1521
|
+
return
|
|
1522
|
+
session.query(ModuleEdge).filter(ModuleEdge.repo_id == repo_id).delete(synchronize_session=False)
|
|
1523
|
+
with_edges = {source for source, _ in edges}
|
|
1524
|
+
for source_file, target_file in edges:
|
|
1525
|
+
session.add(ModuleEdge(repo_id=repo_id, source_file=source_file, target_file=target_file))
|
|
1526
|
+
for file_path in files:
|
|
1527
|
+
if file_path not in with_edges:
|
|
1528
|
+
session.add(ModuleEdge(repo_id=repo_id, source_file=file_path, target_file=None))
|
|
1529
|
+
|
|
1530
|
+
def _persist_to_db(
|
|
1531
|
+
self,
|
|
1532
|
+
repo_name: str,
|
|
1533
|
+
repo_url: str,
|
|
1534
|
+
parsed_files: list[FileParseResult],
|
|
1535
|
+
all_symbols: list[Symbol],
|
|
1536
|
+
all_chunks: list[CodeChunk],
|
|
1537
|
+
chunk_embeddings: list[tuple[Symbol, CodeChunk, np.ndarray]] | None = None,
|
|
1538
|
+
is_incremental: bool = False,
|
|
1539
|
+
changed_symbol_keys: set[tuple[str, str]] | None = None,
|
|
1540
|
+
module_snapshot: tuple[list[str], list[tuple[str, str]]] | None = None,
|
|
1541
|
+
) -> dict[str, int]:
|
|
1542
|
+
"""
|
|
1543
|
+
Persists repository hierarchy to the database.
|
|
1544
|
+
|
|
1545
|
+
`is_incremental`/`changed_symbol_keys` (Phase 3.5, default to a
|
|
1546
|
+
safe full-rebuild for any caller that doesn't pass them, e.g. a
|
|
1547
|
+
pre-existing direct test call) drive `_persist_call_graph_edges`
|
|
1548
|
+
-- see that method's own docstring.
|
|
1549
|
+
|
|
1550
|
+
Phase 0.3 behavior (Repository/File upsert with content_hash):
|
|
1551
|
+
- Repository rows are upserted by natural key `url` -- the same
|
|
1552
|
+
value used consistently across calls to `index_directory` (an
|
|
1553
|
+
explicit git/repo URL, or the absolute local repo path when none
|
|
1554
|
+
is given). Re-indexing the same repository updates the existing
|
|
1555
|
+
row instead of inserting a duplicate one.
|
|
1556
|
+
- File rows are upserted by natural key `(repo_id, path)`:
|
|
1557
|
+
* new path -> insert a new File row.
|
|
1558
|
+
* existing path, same content_hash -> reuse the row as-is
|
|
1559
|
+
(only `last_indexed` is refreshed); no new row is created.
|
|
1560
|
+
* existing path, different content_hash -> update the existing
|
|
1561
|
+
row in place (no new row is created).
|
|
1562
|
+
Both natural keys are also enforced with a UNIQUE constraint at
|
|
1563
|
+
the database level (see storage/db.py), so this invariant does
|
|
1564
|
+
not depend on application logic alone.
|
|
1565
|
+
Phase 0.4 behavior (Symbol upsert by (file_id, name, start_line)):
|
|
1566
|
+
- Symbol rows are upserted by natural key `(file_id, name,
|
|
1567
|
+
start_line)`, using `body_hash` for change detection:
|
|
1568
|
+
* new (file_id, name, start_line) -> insert a new row.
|
|
1569
|
+
* existing key, same body_hash -> reuse the row
|
|
1570
|
+
untouched (zero writes -- Symbol has no freshness/timestamp
|
|
1571
|
+
field to refresh, unlike File).
|
|
1572
|
+
* existing key, different body_hash -> update the existing
|
|
1573
|
+
row in place (no new row is created).
|
|
1574
|
+
Enforced with a UNIQUE constraint at the database level (see
|
|
1575
|
+
storage/db.py), not only in application logic.
|
|
1576
|
+
- Dependency (call graph edge) rows are NOT upserted in this
|
|
1577
|
+
milestone -- that is Phase 3 (call-graph confidence) scope.
|
|
1578
|
+
Re-indexing still appends new Dependency rows every time. This
|
|
1579
|
+
is a known, intentional limitation of Phase 0.4.
|
|
1580
|
+
Phase 0.5 behavior (Embedding persistence + hydration):
|
|
1581
|
+
- Embedding rows are upserted by natural key `(symbol_id,
|
|
1582
|
+
chunk_type, chunk_index)`, using a `content_hash` of the chunk's
|
|
1583
|
+
own text for change detection:
|
|
1584
|
+
* new (symbol_id, chunk_type, chunk_index) -> insert a new row.
|
|
1585
|
+
* existing key, same content_hash -> reuse the row
|
|
1586
|
+
untouched; no new row is created.
|
|
1587
|
+
* existing key, different content_hash -> update the
|
|
1588
|
+
existing row in place (no new row is created).
|
|
1589
|
+
Enforced with a UNIQUE constraint at the database level (see
|
|
1590
|
+
storage/db.py), not only in application logic. `chunk_embeddings`
|
|
1591
|
+
is empty whenever embedding generation failed or produced no
|
|
1592
|
+
chunks this run -- in that case no Embedding rows are touched at
|
|
1593
|
+
all (existing durable embeddings are left exactly as they were;
|
|
1594
|
+
nothing is deleted just because this run couldn't re-embed).
|
|
1595
|
+
- A chunk whose parent symbol no longer produces a chunk at that
|
|
1596
|
+
(chunk_type, chunk_index) after a content change (e.g. a large
|
|
1597
|
+
function shrinks below the multi-chunk threshold) can leave a
|
|
1598
|
+
stale Embedding row behind -- this mirrors Phase 0.4's decision
|
|
1599
|
+
not to delete stale Symbol rows, and is intentionally out of
|
|
1600
|
+
scope here too (real stale-data cleanup is Phase 3, per the
|
|
1601
|
+
roadmap's `ON DELETE CASCADE` design for whole-symbol removal).
|
|
1602
|
+
"""
|
|
1603
|
+
session = self.storage.get_session()
|
|
1604
|
+
try:
|
|
1605
|
+
now = datetime.utcnow()
|
|
1606
|
+
|
|
1607
|
+
# --- Repository upsert (natural key: url) ---
|
|
1608
|
+
repo_record = find_repository(session, repo_url)
|
|
1609
|
+
if repo_record is None:
|
|
1610
|
+
repo_record = RepositoryModel(
|
|
1611
|
+
name=repo_name,
|
|
1612
|
+
url=canonical_repository_url(repo_url),
|
|
1613
|
+
# Placeholder -- overwritten below with a real DB count
|
|
1614
|
+
# once this run's File/Symbol upserts are flushed.
|
|
1615
|
+
file_count=len(parsed_files),
|
|
1616
|
+
symbol_count=len(all_symbols),
|
|
1617
|
+
last_sync=now,
|
|
1618
|
+
)
|
|
1619
|
+
session.add(repo_record)
|
|
1620
|
+
else:
|
|
1621
|
+
repo_record.name = repo_name
|
|
1622
|
+
repo_record.last_sync = now
|
|
1623
|
+
|
|
1624
|
+
session.flush()
|
|
1625
|
+
|
|
1626
|
+
# --- File upsert (natural key: (repo_id, path)) ---
|
|
1627
|
+
file_record_map: dict[str, FileModel] = {}
|
|
1628
|
+
new_files = 0
|
|
1629
|
+
updated_files = 0
|
|
1630
|
+
unchanged_files = 0
|
|
1631
|
+
|
|
1632
|
+
for pf in parsed_files:
|
|
1633
|
+
existing_fm = (
|
|
1634
|
+
session.query(FileModel)
|
|
1635
|
+
.filter(
|
|
1636
|
+
FileModel.repo_id == repo_record.id,
|
|
1637
|
+
FileModel.path == pf.file_path,
|
|
1638
|
+
)
|
|
1639
|
+
.first()
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
if existing_fm is None:
|
|
1643
|
+
fm = FileModel(
|
|
1644
|
+
repo_id=repo_record.id,
|
|
1645
|
+
path=pf.file_path,
|
|
1646
|
+
language=pf.language,
|
|
1647
|
+
size_bytes=pf.file_size_bytes,
|
|
1648
|
+
line_count=pf.line_count,
|
|
1649
|
+
content_hash=pf.content_hash,
|
|
1650
|
+
last_indexed=now,
|
|
1651
|
+
)
|
|
1652
|
+
session.add(fm)
|
|
1653
|
+
file_record_map[pf.file_path] = fm
|
|
1654
|
+
new_files += 1
|
|
1655
|
+
elif existing_fm.content_hash != pf.content_hash:
|
|
1656
|
+
existing_fm.language = pf.language
|
|
1657
|
+
existing_fm.size_bytes = pf.file_size_bytes
|
|
1658
|
+
existing_fm.line_count = pf.line_count
|
|
1659
|
+
existing_fm.content_hash = pf.content_hash
|
|
1660
|
+
existing_fm.last_indexed = now
|
|
1661
|
+
file_record_map[pf.file_path] = existing_fm
|
|
1662
|
+
updated_files += 1
|
|
1663
|
+
else:
|
|
1664
|
+
# Content unchanged: reuse the existing row untouched aside
|
|
1665
|
+
# from a freshness timestamp -- do not insert another row.
|
|
1666
|
+
existing_fm.last_indexed = now
|
|
1667
|
+
file_record_map[pf.file_path] = existing_fm
|
|
1668
|
+
unchanged_files += 1
|
|
1669
|
+
|
|
1670
|
+
session.flush()
|
|
1671
|
+
|
|
1672
|
+
# --- Symbol upsert (natural key: (file_id, name, start_line)) ---
|
|
1673
|
+
symbol_record_map: dict[tuple[str, str, int], SymbolModel] = {}
|
|
1674
|
+
new_symbols = 0
|
|
1675
|
+
updated_symbols = 0
|
|
1676
|
+
unchanged_symbols = 0
|
|
1677
|
+
|
|
1678
|
+
for sym in all_symbols:
|
|
1679
|
+
fm = file_record_map.get(sym.file_path)
|
|
1680
|
+
if not fm:
|
|
1681
|
+
continue
|
|
1682
|
+
|
|
1683
|
+
sym_type_value = sym.type.value if hasattr(sym.type, "value") else str(sym.type)
|
|
1684
|
+
map_key = (sym.file_path, sym.name, sym.start_line)
|
|
1685
|
+
|
|
1686
|
+
existing_sm = (
|
|
1687
|
+
session.query(SymbolModel)
|
|
1688
|
+
.filter(
|
|
1689
|
+
SymbolModel.file_id == fm.id,
|
|
1690
|
+
SymbolModel.name == sym.name,
|
|
1691
|
+
SymbolModel.start_line == sym.start_line,
|
|
1692
|
+
)
|
|
1693
|
+
.first()
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
# Phase 4.1: JSON-serialized to match the same "Text
|
|
1697
|
+
# column holding a JSON-serialized list, parsed in
|
|
1698
|
+
# application code" convention `embedding_json` already
|
|
1699
|
+
# uses -- `None` (not `"[]"`) for a symbol with no base
|
|
1700
|
+
# classes, so a never-(re)indexed-since-this-migration
|
|
1701
|
+
# row (genuinely `NULL`) and a real "no bases" result
|
|
1702
|
+
# remain visibly the same thing, which they are (both
|
|
1703
|
+
# mean "nothing known/present"), not a false distinction.
|
|
1704
|
+
base_classes_json = json.dumps(sym.base_classes) if sym.base_classes else None
|
|
1705
|
+
|
|
1706
|
+
if existing_sm is None:
|
|
1707
|
+
sm = SymbolModel(
|
|
1708
|
+
file_id=fm.id,
|
|
1709
|
+
name=sym.name,
|
|
1710
|
+
type=sym_type_value,
|
|
1711
|
+
start_line=sym.start_line,
|
|
1712
|
+
end_line=sym.end_line,
|
|
1713
|
+
signature=sym.signature,
|
|
1714
|
+
docstring=sym.docstring,
|
|
1715
|
+
body=sym.body,
|
|
1716
|
+
body_hash=sym.body_hash,
|
|
1717
|
+
language=sym.language,
|
|
1718
|
+
complexity=sym.complexity,
|
|
1719
|
+
base_classes=base_classes_json,
|
|
1720
|
+
)
|
|
1721
|
+
session.add(sm)
|
|
1722
|
+
symbol_record_map[map_key] = sm
|
|
1723
|
+
new_symbols += 1
|
|
1724
|
+
elif existing_sm.body_hash != sym.body_hash:
|
|
1725
|
+
existing_sm.type = sym_type_value
|
|
1726
|
+
existing_sm.end_line = sym.end_line
|
|
1727
|
+
existing_sm.signature = sym.signature
|
|
1728
|
+
existing_sm.docstring = sym.docstring
|
|
1729
|
+
existing_sm.body = sym.body
|
|
1730
|
+
existing_sm.body_hash = sym.body_hash
|
|
1731
|
+
existing_sm.language = sym.language
|
|
1732
|
+
existing_sm.complexity = sym.complexity
|
|
1733
|
+
existing_sm.base_classes = base_classes_json
|
|
1734
|
+
symbol_record_map[map_key] = existing_sm
|
|
1735
|
+
updated_symbols += 1
|
|
1736
|
+
else:
|
|
1737
|
+
# Content unchanged: reuse the existing row untouched --
|
|
1738
|
+
# zero net writes, per the roadmap's Phase 0 idempotency rule.
|
|
1739
|
+
symbol_record_map[map_key] = existing_sm
|
|
1740
|
+
unchanged_symbols += 1
|
|
1741
|
+
|
|
1742
|
+
session.flush()
|
|
1743
|
+
|
|
1744
|
+
# Phase 3.1: `repo_record.file_count`/`symbol_count` must
|
|
1745
|
+
# reflect the repository's TRUE total, not just this run's
|
|
1746
|
+
# `len(parsed_files)`/`len(all_symbols)` -- those only equal
|
|
1747
|
+
# the true total on a FULL run (where every file is always
|
|
1748
|
+
# parsed). On an INCREMENTAL run, `parsed_files`/`all_symbols`
|
|
1749
|
+
# cover only the changed/added subset, so using their lengths
|
|
1750
|
+
# here would fabricate a shrunken file/symbol count -- a
|
|
1751
|
+
# direct violation of the "no fabricated metrics" invariant,
|
|
1752
|
+
# and it would also corrupt `_plan_incremental_run`'s own
|
|
1753
|
+
# `prior_file_count` denominator (used for the >50%-changed
|
|
1754
|
+
# fallback check) on every subsequent run. Querying the real,
|
|
1755
|
+
# durable row counts is correct for a full run too (identical
|
|
1756
|
+
# result, since every parseable file is always upserted into
|
|
1757
|
+
# `FileModel`/`SymbolModel` on a full run) and is unaffected
|
|
1758
|
+
# by whether `_purge_stale_entries` already ran earlier in
|
|
1759
|
+
# `index_directory` (it always runs, and commits, before this
|
|
1760
|
+
# method is ever called).
|
|
1761
|
+
repo_record.file_count = (
|
|
1762
|
+
session.query(FileModel).filter(FileModel.repo_id == repo_record.id).count()
|
|
1763
|
+
)
|
|
1764
|
+
repo_record.symbol_count = (
|
|
1765
|
+
session.query(SymbolModel)
|
|
1766
|
+
.join(FileModel, SymbolModel.file_id == FileModel.id)
|
|
1767
|
+
.filter(FileModel.repo_id == repo_record.id)
|
|
1768
|
+
.count()
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
# --- Embedding upsert (natural key: (symbol_id, chunk_type, chunk_index)) ---
|
|
1772
|
+
new_embeddings = 0
|
|
1773
|
+
updated_embeddings = 0
|
|
1774
|
+
unchanged_embeddings = 0
|
|
1775
|
+
|
|
1776
|
+
for sym, chunk, vector in (chunk_embeddings or []):
|
|
1777
|
+
map_key = (sym.file_path, sym.name, sym.start_line)
|
|
1778
|
+
sm = symbol_record_map.get(map_key)
|
|
1779
|
+
if not sm:
|
|
1780
|
+
# The symbol this chunk belongs to wasn't persisted this
|
|
1781
|
+
# run (shouldn't normally happen -- every symbol that
|
|
1782
|
+
# produces chunks also goes through the upsert above).
|
|
1783
|
+
continue
|
|
1784
|
+
|
|
1785
|
+
chash = compute_content_hash(chunk.content)
|
|
1786
|
+
embedding_json = json.dumps(np.asarray(vector, dtype=np.float64).tolist())
|
|
1787
|
+
|
|
1788
|
+
existing_em = (
|
|
1789
|
+
session.query(EmbeddingModel)
|
|
1790
|
+
.filter(
|
|
1791
|
+
EmbeddingModel.symbol_id == sm.id,
|
|
1792
|
+
EmbeddingModel.chunk_type == chunk.chunk_type,
|
|
1793
|
+
EmbeddingModel.chunk_index == chunk.chunk_index,
|
|
1794
|
+
)
|
|
1795
|
+
.first()
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
if existing_em is None:
|
|
1799
|
+
em = EmbeddingModel(
|
|
1800
|
+
symbol_id=sm.id,
|
|
1801
|
+
chunk_type=chunk.chunk_type,
|
|
1802
|
+
chunk_index=chunk.chunk_index,
|
|
1803
|
+
embedding_json=embedding_json,
|
|
1804
|
+
chunk_content=chunk.content,
|
|
1805
|
+
content_hash=chash,
|
|
1806
|
+
embedding_model=self.embedder.model_name,
|
|
1807
|
+
embedding_dim=int(np.asarray(vector).shape[-1]),
|
|
1808
|
+
)
|
|
1809
|
+
session.add(em)
|
|
1810
|
+
new_embeddings += 1
|
|
1811
|
+
elif existing_em.content_hash != chash:
|
|
1812
|
+
existing_em.embedding_json = embedding_json
|
|
1813
|
+
existing_em.chunk_content = chunk.content
|
|
1814
|
+
existing_em.content_hash = chash
|
|
1815
|
+
existing_em.embedding_model = self.embedder.model_name
|
|
1816
|
+
existing_em.embedding_dim = int(np.asarray(vector).shape[-1])
|
|
1817
|
+
updated_embeddings += 1
|
|
1818
|
+
else:
|
|
1819
|
+
# Content unchanged: reuse the existing row untouched --
|
|
1820
|
+
# zero net writes, per the roadmap's Phase 0 idempotency rule.
|
|
1821
|
+
unchanged_embeddings += 1
|
|
1822
|
+
|
|
1823
|
+
session.flush()
|
|
1824
|
+
|
|
1825
|
+
# Persist call graph edges. Phase 3.5 (roadmap §37 item 3.5:
|
|
1826
|
+
# "Local BFS incremental graph update") -- see
|
|
1827
|
+
# `_persist_call_graph_edges`'s own docstring for the full
|
|
1828
|
+
# design; this finally closes the long-disclosed "CallEdge
|
|
1829
|
+
# rows duplicate/accumulate on every re-index" gap (Phase
|
|
1830
|
+
# 0.4) for both full and incremental runs, using the
|
|
1831
|
+
# `repo_id` column the isolation fix added.
|
|
1832
|
+
self._persist_call_graph_edges(
|
|
1833
|
+
session, repo_record.id, is_incremental, changed_symbol_keys or set()
|
|
1834
|
+
)
|
|
1835
|
+
if module_snapshot is not None:
|
|
1836
|
+
self._persist_module_edges(session, repo_record.id, *module_snapshot)
|
|
1837
|
+
|
|
1838
|
+
session.commit()
|
|
1839
|
+
return {
|
|
1840
|
+
"repo_id": repo_record.id,
|
|
1841
|
+
"saved_files": len(file_record_map),
|
|
1842
|
+
"new_files": new_files,
|
|
1843
|
+
"updated_files": updated_files,
|
|
1844
|
+
"unchanged_files": unchanged_files,
|
|
1845
|
+
"saved_symbols": len(symbol_record_map),
|
|
1846
|
+
"new_symbols": new_symbols,
|
|
1847
|
+
"updated_symbols": updated_symbols,
|
|
1848
|
+
"unchanged_symbols": unchanged_symbols,
|
|
1849
|
+
"new_embeddings": new_embeddings,
|
|
1850
|
+
"updated_embeddings": updated_embeddings,
|
|
1851
|
+
"unchanged_embeddings": unchanged_embeddings,
|
|
1852
|
+
}
|
|
1853
|
+
except Exception as e:
|
|
1854
|
+
session.rollback()
|
|
1855
|
+
logger.error("Database save failed: %s", e)
|
|
1856
|
+
# Used to `return {"error": ...}`, which index_directory ignored: it went on to record a
|
|
1857
|
+
# snapshot and report success for data that was never saved (2026-10-15 audit).
|
|
1858
|
+
raise IndexingPersistenceError(f"saving the index to the database failed: {e}") from e
|
|
1859
|
+
finally:
|
|
1860
|
+
session.close()
|
|
1861
|
+
|
|
1862
|
+
def hydrate_repository(self, repo_url: str) -> dict[str, Any]:
|
|
1863
|
+
"""
|
|
1864
|
+
Phase 0.5 -- load-on-startup hydration.
|
|
1865
|
+
|
|
1866
|
+
Restores the in-memory `vector_index` for ONE repository from
|
|
1867
|
+
durably persisted `code_embeddings` rows, without re-parsing or
|
|
1868
|
+
re-embedding anything. This is what makes semantic search survive a
|
|
1869
|
+
process restart: a fresh `IndexingEngine` can call this to become
|
|
1870
|
+
functional again purely from the database, no embedding-model call
|
|
1871
|
+
required.
|
|
1872
|
+
|
|
1873
|
+
Backend-hardening addition (not a roadmap sprint item): this method
|
|
1874
|
+
ALSO rebuilds `bm25_index` for this repository from durably
|
|
1875
|
+
persisted `SymbolModel`/`FileModel` rows -- no re-parsing, no
|
|
1876
|
+
re-embedding, just re-tokenizing already-stored symbol text. This
|
|
1877
|
+
closes a real, previously-disclosed gap (`tests/
|
|
1878
|
+
test_restart_idempotency.py`'s own "BM25 index has no backing
|
|
1879
|
+
table and must not survive restart" assertion, since RELAXED to
|
|
1880
|
+
match): before this, keyword/hybrid search returned nothing at
|
|
1881
|
+
all until a fresh `index_directory()` call, even though every
|
|
1882
|
+
symbol needed to rebuild it already existed durably in `symbols`/
|
|
1883
|
+
`files`. `SymbolModel`/`FileModel` remain the actual source of
|
|
1884
|
+
truth throughout -- `bm25_index` is still a derived, rebuildable
|
|
1885
|
+
artifact (per `BM25Index`'s own class docstring), just rebuilt
|
|
1886
|
+
from SQL at startup instead of only ever from a fresh parse. The
|
|
1887
|
+
in-memory symbol map (`symbols_by_id`/`symbol_repo_map`) is a
|
|
1888
|
+
derived cache of the same symbol rows and is restored here too
|
|
1889
|
+
(2026-10-12), and the call graph is restored from the durable
|
|
1890
|
+
`call_edges` rows by `_restore_call_graph` (same date) -- both
|
|
1891
|
+
previously required a fresh `index_directory()` call, which made
|
|
1892
|
+
callers/impact/autocomplete and the graph part of hybrid ranking
|
|
1893
|
+
silently empty after every restart. What is still NOT restored:
|
|
1894
|
+
`parse_results` (so a restored graph cannot seed an incremental
|
|
1895
|
+
update; see `_plan_incremental_run`) and the module-structure
|
|
1896
|
+
import graph.
|
|
1897
|
+
|
|
1898
|
+
Repository scoping: only embeddings/symbols that belong to (or
|
|
1899
|
+
whose File belongs to) the given `repo_url` are loaded, so data
|
|
1900
|
+
from one repository can never be hydrated into another's results.
|
|
1901
|
+
Each loaded vector's metadata is also tagged with `repo_id` (the
|
|
1902
|
+
same `RepositoryModel.id` used everywhere else), which is what
|
|
1903
|
+
lets `search(..., repository_id=...)` (Phase 0.6) filter hydrated
|
|
1904
|
+
embeddings correctly even though `hydrate_all()` loads every
|
|
1905
|
+
repository into the same shared `vector_index`/`bm25_index`.
|
|
1906
|
+
|
|
1907
|
+
Vector-dimension safety: a persisted embedding row whose
|
|
1908
|
+
`embedding_dim` doesn't match the currently configured
|
|
1909
|
+
`vector_index.dimension` is skipped explicitly (e.g. the
|
|
1910
|
+
embedding model or dimension was changed since that row was
|
|
1911
|
+
written) rather than being loaded and silently corrupting
|
|
1912
|
+
similarity results. No equivalent skip exists for symbol
|
|
1913
|
+
hydration -- there is no "wrong shape" failure mode for plain
|
|
1914
|
+
text fields the way there is for a fixed-dimension vector; a row
|
|
1915
|
+
whose `type` doesn't parse as a known `SymbolType` (a defensive,
|
|
1916
|
+
not-currently-reachable case -- every parser only ever produces
|
|
1917
|
+
valid `SymbolType` values) is skipped and logged instead.
|
|
1918
|
+
"""
|
|
1919
|
+
session = self.storage.get_session()
|
|
1920
|
+
try:
|
|
1921
|
+
repo_record = find_repository(session, repo_url)
|
|
1922
|
+
if repo_record is None:
|
|
1923
|
+
return {
|
|
1924
|
+
"repo_id": None,
|
|
1925
|
+
"hydrated_embeddings": 0,
|
|
1926
|
+
"skipped_dimension_mismatch": 0,
|
|
1927
|
+
"hydrated_symbols": 0,
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
rows = (
|
|
1931
|
+
session.query(EmbeddingModel, SymbolModel, FileModel)
|
|
1932
|
+
.join(SymbolModel, EmbeddingModel.symbol_id == SymbolModel.id)
|
|
1933
|
+
.join(FileModel, SymbolModel.file_id == FileModel.id)
|
|
1934
|
+
.filter(FileModel.repo_id == repo_record.id)
|
|
1935
|
+
.all()
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
doc_ids: list[str] = []
|
|
1939
|
+
vectors: list[np.ndarray] = []
|
|
1940
|
+
metadatas: list[dict[str, Any]] = []
|
|
1941
|
+
skipped_dimension_mismatch = 0
|
|
1942
|
+
|
|
1943
|
+
for em, sm, fm in rows:
|
|
1944
|
+
if em.embedding_dim is not None and em.embedding_dim != self.vector_index.dimension:
|
|
1945
|
+
skipped_dimension_mismatch += 1
|
|
1946
|
+
continue
|
|
1947
|
+
try:
|
|
1948
|
+
raw = json.loads(em.embedding_json)
|
|
1949
|
+
except (TypeError, ValueError) as e:
|
|
1950
|
+
logger.warning(
|
|
1951
|
+
"Skipping unreadable embedding row id=%s during hydration: %s", em.id, e
|
|
1952
|
+
)
|
|
1953
|
+
continue
|
|
1954
|
+
vec = np.asarray(raw, dtype=np.float32)
|
|
1955
|
+
if vec.shape[-1] != self.vector_index.dimension:
|
|
1956
|
+
skipped_dimension_mismatch += 1
|
|
1957
|
+
continue
|
|
1958
|
+
|
|
1959
|
+
# Phase 0.6: repo-qualified doc_id + explicit repo_id in
|
|
1960
|
+
# metadata, matching the scheme used at index time -- see
|
|
1961
|
+
# index_directory's comment on why this prefix is needed to
|
|
1962
|
+
# avoid collisions between repositories sharing a path.
|
|
1963
|
+
doc_id = chunk_doc_id(repo_record.id, fm.path, sm.name, sm.start_line, em.chunk_index)
|
|
1964
|
+
doc_ids.append(doc_id)
|
|
1965
|
+
vectors.append(vec)
|
|
1966
|
+
metadatas.append({
|
|
1967
|
+
"repo_id": repo_record.id,
|
|
1968
|
+
"symbol_name": sm.name,
|
|
1969
|
+
"symbol_start_line": sm.start_line,
|
|
1970
|
+
"file_path": fm.path,
|
|
1971
|
+
"chunk_type": em.chunk_type,
|
|
1972
|
+
"start_line": sm.start_line,
|
|
1973
|
+
"end_line": sm.end_line,
|
|
1974
|
+
"snippet": (em.chunk_content or "")[:200],
|
|
1975
|
+
})
|
|
1976
|
+
|
|
1977
|
+
if vectors:
|
|
1978
|
+
self.vector_index.add_batch(doc_ids, np.vstack(vectors), metadatas)
|
|
1979
|
+
|
|
1980
|
+
# Backend-hardening addition: rebuild the lexical index from
|
|
1981
|
+
# every persisted symbol in this repository (not just ones
|
|
1982
|
+
# with an embedding -- index_directory() adds every symbol to
|
|
1983
|
+
# bm25_index regardless of embedding success, for degraded-mode
|
|
1984
|
+
# keyword search; hydration matches that behavior exactly).
|
|
1985
|
+
symbol_rows = (
|
|
1986
|
+
session.query(SymbolModel, FileModel)
|
|
1987
|
+
.join(FileModel, SymbolModel.file_id == FileModel.id)
|
|
1988
|
+
.filter(FileModel.repo_id == repo_record.id)
|
|
1989
|
+
.all()
|
|
1990
|
+
)
|
|
1991
|
+
hydrated_symbols = 0
|
|
1992
|
+
for sm, fm in symbol_rows:
|
|
1993
|
+
try:
|
|
1994
|
+
symbol_obj = Symbol(
|
|
1995
|
+
name=sm.name,
|
|
1996
|
+
type=SymbolType(sm.type),
|
|
1997
|
+
file_path=fm.path,
|
|
1998
|
+
start_line=sm.start_line,
|
|
1999
|
+
end_line=sm.end_line,
|
|
2000
|
+
signature=sm.signature or "",
|
|
2001
|
+
docstring=sm.docstring,
|
|
2002
|
+
body=sm.body or "",
|
|
2003
|
+
language=sm.language,
|
|
2004
|
+
)
|
|
2005
|
+
except ValueError as e:
|
|
2006
|
+
logger.warning(
|
|
2007
|
+
"Skipping unreadable symbol row id=%s during lexical hydration: %s", sm.id, e
|
|
2008
|
+
)
|
|
2009
|
+
continue
|
|
2010
|
+
# Matches index_directory's own doc_id format exactly
|
|
2011
|
+
# (`symbol_doc_id`) -- required so a subsequent
|
|
2012
|
+
# index_directory() call's remove_repo/add_symbol pair
|
|
2013
|
+
# replaces this hydrated entry rather than duplicating it.
|
|
2014
|
+
doc_id = symbol_doc_id(repo_record.id, fm.path, sm.name, sm.start_line)
|
|
2015
|
+
self.bm25_index.add_symbol(doc_id, symbol_obj, repo_id=repo_record.id)
|
|
2016
|
+
# The in-memory symbol map (hybrid ranking's graph score,
|
|
2017
|
+
# autocomplete, stats) is a derived cache of these same
|
|
2018
|
+
# rows; restoring it keeps those correct after a restart.
|
|
2019
|
+
self.symbols_by_id[doc_id] = symbol_obj
|
|
2020
|
+
self.symbol_repo_map[doc_id] = repo_record.id
|
|
2021
|
+
hydrated_symbols += 1
|
|
2022
|
+
if hydrated_symbols:
|
|
2023
|
+
self.bm25_index.build()
|
|
2024
|
+
|
|
2025
|
+
self._restore_call_graph(session, repo_record.id)
|
|
2026
|
+
self._restore_module_graph(session, repo_record.id)
|
|
2027
|
+
|
|
2028
|
+
return {
|
|
2029
|
+
"repo_id": repo_record.id,
|
|
2030
|
+
"hydrated_embeddings": len(doc_ids),
|
|
2031
|
+
"skipped_dimension_mismatch": skipped_dimension_mismatch,
|
|
2032
|
+
"hydrated_symbols": hydrated_symbols,
|
|
2033
|
+
}
|
|
2034
|
+
finally:
|
|
2035
|
+
session.close()
|
|
2036
|
+
|
|
2037
|
+
def _restore_module_graph(self, session, repo_id: int) -> None:
|
|
2038
|
+
"""
|
|
2039
|
+
Rebuilds this repository's module-structure graph from its durable
|
|
2040
|
+
`module_edges` rows (resolved edges plus a NULL-target row per file
|
|
2041
|
+
without outgoing edges, so the node set comes from the same place),
|
|
2042
|
+
so module structure works immediately after a restart.
|
|
2043
|
+
|
|
2044
|
+
Skipped -- the repository stays cold, exactly as before this
|
|
2045
|
+
feature -- when the table does not exist yet OR the repository has
|
|
2046
|
+
no rows (it has not been indexed since the table was added). A
|
|
2047
|
+
partial graph built from anything else would show a confident
|
|
2048
|
+
all-singletons structure for a repository that does have imports.
|
|
2049
|
+
"""
|
|
2050
|
+
if repo_id in self._restored_module_graphs or not self._module_edges_table_exists(session):
|
|
2051
|
+
return
|
|
2052
|
+
rows = session.query(ModuleEdge.source_file, ModuleEdge.target_file).filter(
|
|
2053
|
+
ModuleEdge.repo_id == repo_id
|
|
2054
|
+
).all()
|
|
2055
|
+
if not rows:
|
|
2056
|
+
return
|
|
2057
|
+
files = {source for source, _ in rows} | {target for _, target in rows if target is not None}
|
|
2058
|
+
self._restored_module_graphs[repo_id] = ModuleGraphBuilder.from_edges(
|
|
2059
|
+
sorted(files),
|
|
2060
|
+
[(source, target) for source, target in rows if target is not None],
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
def _restore_call_graph(self, session, repo_id: int) -> None:
|
|
2064
|
+
"""
|
|
2065
|
+
Rebuilds `self.call_graphs[repo_id]` from the durable `call_edges`
|
|
2066
|
+
rows (calls AND inheritance), so callers/callees/impact/references
|
|
2067
|
+
work immediately after a restart instead of silently returning
|
|
2068
|
+
nothing until a re-index (2026-10-12 QA finding).
|
|
2069
|
+
|
|
2070
|
+
Never replaces a graph this process already built from parse
|
|
2071
|
+
results. The restored graph is flagged in
|
|
2072
|
+
`_restored_call_graph_repo_ids` so `_plan_incremental_run` still
|
|
2073
|
+
forces a full run: it has no parse cache behind it.
|
|
2074
|
+
"""
|
|
2075
|
+
if repo_id in self.call_graphs:
|
|
2076
|
+
return
|
|
2077
|
+
rows = (
|
|
2078
|
+
session.query(CallEdge)
|
|
2079
|
+
.filter(CallEdge.repo_id == repo_id)
|
|
2080
|
+
.order_by(CallEdge.id)
|
|
2081
|
+
.all()
|
|
2082
|
+
)
|
|
2083
|
+
calls: list[CallGraphEdge] = []
|
|
2084
|
+
inherits: list[CallGraphEdge] = []
|
|
2085
|
+
for row in rows:
|
|
2086
|
+
edge = CallGraphEdge(
|
|
2087
|
+
source_symbol_name=row.source_symbol_name,
|
|
2088
|
+
source_file=row.source_file,
|
|
2089
|
+
target_symbol_name=row.target_symbol_name,
|
|
2090
|
+
target_file=row.target_file,
|
|
2091
|
+
call_count=row.call_count,
|
|
2092
|
+
confidence=row.confidence,
|
|
2093
|
+
relationship_type=row.relationship_type,
|
|
2094
|
+
)
|
|
2095
|
+
(inherits if row.relationship_type == "inherits" else calls).append(edge)
|
|
2096
|
+
graph = CallGraphBuilder.from_edges(calls, inherits)
|
|
2097
|
+
self.call_graphs[repo_id] = graph
|
|
2098
|
+
self._restored_call_graph_repo_ids.add(repo_id)
|
|
2099
|
+
if self.call_graph is None:
|
|
2100
|
+
# Unscoped search reads this "most recently indexed" alias; with
|
|
2101
|
+
# no index run yet in this process, the first restored graph is
|
|
2102
|
+
# the closest equivalent (exact for the common single-repo case).
|
|
2103
|
+
self.call_graph = graph
|
|
2104
|
+
|
|
2105
|
+
def hydrate_all(self) -> dict[str, Any]:
|
|
2106
|
+
"""
|
|
2107
|
+
Phase 0.5 -- load-on-startup hydration for every repository in the
|
|
2108
|
+
database. Called automatically once from `__init__` so a freshly
|
|
2109
|
+
constructed engine (e.g. a new server process) has semantic AND
|
|
2110
|
+
(backend-hardening addition, not a roadmap sprint item) keyword
|
|
2111
|
+
search available immediately from durable storage, without
|
|
2112
|
+
re-indexing. See `hydrate_repository` for the per-repository
|
|
2113
|
+
details.
|
|
2114
|
+
"""
|
|
2115
|
+
session = self.storage.get_session()
|
|
2116
|
+
try:
|
|
2117
|
+
repo_urls = [r.url for r in session.query(RepositoryModel.url).all()]
|
|
2118
|
+
finally:
|
|
2119
|
+
session.close()
|
|
2120
|
+
|
|
2121
|
+
total_hydrated = 0
|
|
2122
|
+
total_skipped = 0
|
|
2123
|
+
total_symbols = 0
|
|
2124
|
+
for url in repo_urls:
|
|
2125
|
+
result = self.hydrate_repository(url)
|
|
2126
|
+
total_hydrated += result["hydrated_embeddings"]
|
|
2127
|
+
total_skipped += result["skipped_dimension_mismatch"]
|
|
2128
|
+
total_symbols += result["hydrated_symbols"]
|
|
2129
|
+
|
|
2130
|
+
if total_hydrated or total_skipped or total_symbols:
|
|
2131
|
+
logger.info(
|
|
2132
|
+
"Startup hydration: %d embeddings + %d lexical symbols loaded across %d repositories (%d skipped: dimension mismatch)",
|
|
2133
|
+
total_hydrated,
|
|
2134
|
+
total_symbols,
|
|
2135
|
+
len(repo_urls),
|
|
2136
|
+
total_skipped,
|
|
2137
|
+
)
|
|
2138
|
+
|
|
2139
|
+
return {
|
|
2140
|
+
"repositories": len(repo_urls),
|
|
2141
|
+
"hydrated_embeddings": total_hydrated,
|
|
2142
|
+
"skipped_dimension_mismatch": total_skipped,
|
|
2143
|
+
"hydrated_symbols": total_symbols,
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
def _forget_symbol(self, doc_id: str) -> None:
|
|
2147
|
+
self.symbols_by_id.pop(doc_id, None)
|
|
2148
|
+
self.symbol_repo_map.pop(doc_id, None)
|
|
2149
|
+
|
|
2150
|
+
# An unscoped search asks the vector index for this many chunks per requested
|
|
2151
|
+
# result: a symbol has about 2.5 chunks, and several of a symbol's chunks can
|
|
2152
|
+
# rank together, so `limit` results need well over `limit` chunk hits.
|
|
2153
|
+
_CHUNK_HITS_PER_RESULT = 6
|
|
2154
|
+
|
|
2155
|
+
def _fold_vector_hits_by_symbol(self, hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
2156
|
+
"""
|
|
2157
|
+
One vector hit per SYMBOL instead of per chunk (FUTURE_CHANGES item 20).
|
|
2158
|
+
|
|
2159
|
+
The vector index stores one document per chunk while the lexical index
|
|
2160
|
+
stores one per symbol, and fusion merges candidates by `doc_id`. Here each
|
|
2161
|
+
chunk hit is re-keyed to its symbol's id (`symbol_doc_id`, built from the
|
|
2162
|
+
chunk's metadata: repository, file, name and the symbol's start line, so
|
|
2163
|
+
same-named symbols in a file stay apart: item 31); a symbol takes its BEST
|
|
2164
|
+
chunk's score (ties go to the lower chunk id, so the choice never depends on
|
|
2165
|
+
index order). The row's metadata describes the symbol (type, signature,
|
|
2166
|
+
docstring, language, its own line range) the way the lexical index's does,
|
|
2167
|
+
which a chunk's metadata could not; the best chunk's snippet is kept. Output
|
|
2168
|
+
is ordered best score first, then by id, so the same query over the same
|
|
2169
|
+
snapshot always returns the same list.
|
|
2170
|
+
"""
|
|
2171
|
+
best: dict[str, dict[str, Any]] = {}
|
|
2172
|
+
for hit in hits:
|
|
2173
|
+
meta = hit.get("metadata", {})
|
|
2174
|
+
key = symbol_doc_id(meta.get("repo_id"), meta.get("file_path"), meta.get("symbol_name"), meta.get("symbol_start_line"))
|
|
2175
|
+
current = best.get(key)
|
|
2176
|
+
if current is None or (-hit["relevance_score"], hit["doc_id"]) < (-current["relevance_score"], current["doc_id"]):
|
|
2177
|
+
best[key] = hit
|
|
2178
|
+
|
|
2179
|
+
folded: list[dict[str, Any]] = []
|
|
2180
|
+
for key, hit in best.items():
|
|
2181
|
+
chunk_meta = hit.get("metadata", {})
|
|
2182
|
+
symbol = self.symbols_by_id.get(key)
|
|
2183
|
+
if symbol is not None:
|
|
2184
|
+
metadata = {
|
|
2185
|
+
"symbol_name": symbol.name,
|
|
2186
|
+
"file_path": symbol.file_path,
|
|
2187
|
+
"type": symbol.type.value if hasattr(symbol.type, "value") else str(symbol.type),
|
|
2188
|
+
"start_line": symbol.start_line,
|
|
2189
|
+
"end_line": symbol.end_line,
|
|
2190
|
+
"signature": symbol.signature,
|
|
2191
|
+
"docstring": symbol.docstring,
|
|
2192
|
+
"language": symbol.language,
|
|
2193
|
+
"repo_id": chunk_meta.get("repo_id"),
|
|
2194
|
+
"snippet": chunk_meta.get("snippet", ""),
|
|
2195
|
+
}
|
|
2196
|
+
else:
|
|
2197
|
+
metadata = dict(chunk_meta)
|
|
2198
|
+
folded.append({**hit, "doc_id": key, "metadata": metadata})
|
|
2199
|
+
folded.sort(key=lambda h: (-h["relevance_score"], h["doc_id"]))
|
|
2200
|
+
return folded
|
|
2201
|
+
|
|
2202
|
+
def search(
|
|
2203
|
+
self,
|
|
2204
|
+
query: str,
|
|
2205
|
+
limit: int = 20,
|
|
2206
|
+
search_type: str = "hybrid", # "keyword", "semantic", "hybrid", "rrf"
|
|
2207
|
+
repository_id: int | None = None,
|
|
2208
|
+
) -> list[SearchResultItem]:
|
|
2209
|
+
"""
|
|
2210
|
+
Executes search across the indexed repository/repositories.
|
|
2211
|
+
|
|
2212
|
+
Phase 0.6 -- repository-scoped search: when `repository_id` is
|
|
2213
|
+
given, every candidate-generation step (BM25, vector, and the
|
|
2214
|
+
call-graph-derived hybrid signal) is filtered to documents/symbols
|
|
2215
|
+
tagged with that exact `repo_id` (the same `RepositoryModel.id`
|
|
2216
|
+
used throughout the data model -- not a second identity system).
|
|
2217
|
+
A search scoped to repository A can never return a result whose
|
|
2218
|
+
ownership chain (repository -> file -> symbol -> chunk/embedding)
|
|
2219
|
+
traces to a different repository, regardless of how well that
|
|
2220
|
+
other repository's content happens to score against the query.
|
|
2221
|
+
|
|
2222
|
+
This filtering is enforced HERE, inside the search implementation
|
|
2223
|
+
itself -- not left to a caller (REST/CLI/MCP) to post-filter -- and
|
|
2224
|
+
happens BEFORE the result set is truncated to `limit`, so a
|
|
2225
|
+
well-populated target repository is never starved of results just
|
|
2226
|
+
because another repository's documents also scored highly.
|
|
2227
|
+
|
|
2228
|
+
When `repository_id` is None, behavior is byte-for-byte identical
|
|
2229
|
+
to pre-Phase-0.6 search: fully global/unscoped across every
|
|
2230
|
+
indexed/hydrated repository, exactly as before this milestone.
|
|
2231
|
+
|
|
2232
|
+
Backend-hardening addition (not a roadmap sprint item): query-side
|
|
2233
|
+
embedding-failure degradation. `"hybrid"`/`"rrf"` gracefully fall
|
|
2234
|
+
back to keyword-only results if the query-side `self.embedder.
|
|
2235
|
+
encode(query)` call raises `EmbeddingUnavailableError` -- mirroring
|
|
2236
|
+
`index_directory()`'s own established "proceed in lexical-only
|
|
2237
|
+
degraded mode" philosophy for the exact same exception, just on
|
|
2238
|
+
the read path instead of the write path. `fuse`/`fuse_rrf` both
|
|
2239
|
+
already handle an empty `vector_results` list correctly (the same
|
|
2240
|
+
code path a repository with zero embeddings already exercises),
|
|
2241
|
+
so no new fusion logic was needed -- only the failure needed
|
|
2242
|
+
catching. `"semantic"` (a request for vector-only results, with no
|
|
2243
|
+
other candidate source to fall back to) deliberately still lets
|
|
2244
|
+
the exception propagate uncaught -- returning an empty list would
|
|
2245
|
+
misrepresent "the model is broken" as "nothing matched," which
|
|
2246
|
+
this project's own "no fabricated/misleading success" discipline
|
|
2247
|
+
does not allow; `POST /v1/search` catches it there instead and
|
|
2248
|
+
returns a clear, honest 503 rather than an opaque 500 (see
|
|
2249
|
+
`codepulse/api/app.py`).
|
|
2250
|
+
"""
|
|
2251
|
+
|
|
2252
|
+
def _repo_filter(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
2253
|
+
if repository_id is None:
|
|
2254
|
+
return results
|
|
2255
|
+
return [r for r in results if r.get("metadata", {}).get("repo_id") == repository_id]
|
|
2256
|
+
|
|
2257
|
+
def _encode_query_or_degrade(query: str) -> np.ndarray | None:
|
|
2258
|
+
"""Returns the query embedding, or None if the embedding model
|
|
2259
|
+
is unavailable -- the caller treats None as "no vector
|
|
2260
|
+
candidates this call," never as an error to propagate. See
|
|
2261
|
+
this method's own docstring for why only hybrid/rrf use this
|
|
2262
|
+
(semantic has no fallback and must raise)."""
|
|
2263
|
+
try:
|
|
2264
|
+
return self.embedder.encode(query)
|
|
2265
|
+
except EmbeddingUnavailableError as e:
|
|
2266
|
+
logger.warning(
|
|
2267
|
+
"Query-side embedding unavailable, degrading to keyword-only results "
|
|
2268
|
+
"for this %s search: %s",
|
|
2269
|
+
search_type,
|
|
2270
|
+
e,
|
|
2271
|
+
)
|
|
2272
|
+
return None
|
|
2273
|
+
|
|
2274
|
+
if search_type == "keyword":
|
|
2275
|
+
# Scoped: pull every candidate so filtering can't starve the
|
|
2276
|
+
# result set below `limit`; the ranker still truncates to
|
|
2277
|
+
# `limit` at the end. Unscoped: unchanged (`top_k=limit`).
|
|
2278
|
+
bm25_pool = self.bm25_index.num_docs if repository_id is not None else limit
|
|
2279
|
+
bm25_res = _repo_filter(self.bm25_index.search(query, top_k=bm25_pool))
|
|
2280
|
+
return self.ranker.fuse(query, bm25_results=bm25_res, vector_results=[], top_k=limit)
|
|
2281
|
+
|
|
2282
|
+
elif search_type == "semantic":
|
|
2283
|
+
vector_pool = len(self.vector_index.doc_ids) if repository_id is not None else limit * self._CHUNK_HITS_PER_RESULT
|
|
2284
|
+
query_vec = self.embedder.encode(query)
|
|
2285
|
+
vector_res = self._fold_vector_hits_by_symbol(_repo_filter(self.vector_index.search(query_vec, top_k=vector_pool)))
|
|
2286
|
+
# Nothing is fused here: ranked purely by similarity (no name boost).
|
|
2287
|
+
return self.ranker.rank_by_similarity(vector_res, top_k=limit)
|
|
2288
|
+
|
|
2289
|
+
elif search_type == "rrf":
|
|
2290
|
+
# Phase 2.3 (roadmap §36/§37 item 2.3): the already-implemented
|
|
2291
|
+
# Reciprocal Rank Fusion path, now reachable via `search_type`.
|
|
2292
|
+
# Candidate generation and repository-scoping are unchanged
|
|
2293
|
+
# from -- and pool-sized identically to -- the "hybrid" branch
|
|
2294
|
+
# below, so RRF is isolated exactly as strongly as weighted
|
|
2295
|
+
# fusion already is; only the final fusion step differs (rank-
|
|
2296
|
+
# based RRF instead of weighted-linear combination). No
|
|
2297
|
+
# call-graph signal is computed here: `HybridRanker.fuse_rrf`
|
|
2298
|
+
# does not accept one (unchanged from before this milestone --
|
|
2299
|
+
# extending RRF's own formula to fuse graph scores would be a
|
|
2300
|
+
# ranking-algorithm change, out of this milestone's scope,
|
|
2301
|
+
# which only wires up the existing, unmodified RRF formula).
|
|
2302
|
+
bm25_pool = self.bm25_index.num_docs if repository_id is not None else limit * 2
|
|
2303
|
+
vector_pool = len(self.vector_index.doc_ids) if repository_id is not None else limit * self._CHUNK_HITS_PER_RESULT
|
|
2304
|
+
|
|
2305
|
+
bm25_res = _repo_filter(self.bm25_index.search(query, top_k=bm25_pool))
|
|
2306
|
+
query_vec = _encode_query_or_degrade(query)
|
|
2307
|
+
vector_res = (
|
|
2308
|
+
self._fold_vector_hits_by_symbol(_repo_filter(self.vector_index.search(query_vec, top_k=vector_pool)))
|
|
2309
|
+
if query_vec is not None
|
|
2310
|
+
else []
|
|
2311
|
+
)
|
|
2312
|
+
|
|
2313
|
+
return self.ranker.fuse_rrf(bm25_results=bm25_res, vector_results=vector_res, top_k=limit)
|
|
2314
|
+
|
|
2315
|
+
else: # hybrid
|
|
2316
|
+
bm25_pool = self.bm25_index.num_docs if repository_id is not None else limit * 2
|
|
2317
|
+
vector_pool = len(self.vector_index.doc_ids) if repository_id is not None else limit * self._CHUNK_HITS_PER_RESULT
|
|
2318
|
+
|
|
2319
|
+
bm25_res = _repo_filter(self.bm25_index.search(query, top_k=bm25_pool))
|
|
2320
|
+
query_vec = _encode_query_or_degrade(query)
|
|
2321
|
+
vector_res = (
|
|
2322
|
+
self._fold_vector_hits_by_symbol(_repo_filter(self.vector_index.search(query_vec, top_k=vector_pool)))
|
|
2323
|
+
if query_vec is not None
|
|
2324
|
+
else []
|
|
2325
|
+
)
|
|
2326
|
+
|
|
2327
|
+
# Compute graph centrality / importance scores, scoped to the
|
|
2328
|
+
# requested repository's own call graph (self.call_graphs) so a
|
|
2329
|
+
# symbol's caller count from a DIFFERENT repository's graph can
|
|
2330
|
+
# never leak into this repository's score.
|
|
2331
|
+
graph_scores: dict[str, float] = {}
|
|
2332
|
+
graph = self.call_graphs.get(repository_id) if repository_id is not None else self.call_graph
|
|
2333
|
+
if graph:
|
|
2334
|
+
for doc_id, sym in self.symbols_by_id.items():
|
|
2335
|
+
if repository_id is not None and self.symbol_repo_map.get(doc_id) != repository_id:
|
|
2336
|
+
continue
|
|
2337
|
+
callers = graph.reverse_adjacency.get((sym.file_path, sym.name), set())
|
|
2338
|
+
# Normalized graph score (more callers = more important)
|
|
2339
|
+
graph_scores[doc_id] = min(1.0, len(callers) / 5.0)
|
|
2340
|
+
|
|
2341
|
+
return self.ranker.fuse(
|
|
2342
|
+
query,
|
|
2343
|
+
bm25_results=bm25_res,
|
|
2344
|
+
vector_results=vector_res,
|
|
2345
|
+
graph_scores=graph_scores,
|
|
2346
|
+
top_k=limit,
|
|
2347
|
+
)
|
|
2348
|
+
|
|
2349
|
+
def analyze_impact(
|
|
2350
|
+
self, symbol_name: str, file_path: str, repo_id: int, max_depth: int = 3
|
|
2351
|
+
) -> dict[str, Any]:
|
|
2352
|
+
"""
|
|
2353
|
+
Phase 4.2 (roadmap §37 item 4.2 "Impact analysis: direct vs.
|
|
2354
|
+
transitive bucketing"; §11 "Impact analysis output"; DoD:
|
|
2355
|
+
"impact analysis output clearly separates confidence tiers and
|
|
2356
|
+
depth buckets"). The shared intelligence-core impact-analysis
|
|
2357
|
+
function -- REST and CLI both call this directly instead of each
|
|
2358
|
+
independently calling `CallGraphBuilder.get_callers`/
|
|
2359
|
+
`get_callees` and reimplementing bucketing (the architecture
|
|
2360
|
+
violation this milestone's own read-only audit found: graph
|
|
2361
|
+
logic embedded directly in the FastAPI route, and a second,
|
|
2362
|
+
independent copy in the CLI command).
|
|
2363
|
+
|
|
2364
|
+
**CALLS ONLY** -- an explicit, approved architectural decision
|
|
2365
|
+
(see CODEPULSE_PROGRESS.md's Phase 4.2 entry): traverses only
|
|
2366
|
+
`CallGraphBuilder`'s calls-only adjacency via `get_callers`/
|
|
2367
|
+
`get_callees`, which never read `inheritance_adjacency`/
|
|
2368
|
+
`reverse_inheritance_adjacency` (Phase 4.1's own separation
|
|
2369
|
+
invariant). Inheritance-aware impact analysis is explicitly
|
|
2370
|
+
deferred, not part of this milestone.
|
|
2371
|
+
|
|
2372
|
+
**Repository isolation**: `repo_id` must be the repository the
|
|
2373
|
+
REQUESTED symbol actually belongs to (the caller resolves this
|
|
2374
|
+
from the symbol's own file, never from an unrelated/client-
|
|
2375
|
+
suppliable parameter) -- looks up `self.call_graphs.get(repo_id)`
|
|
2376
|
+
(the correctly-scoped, per-repository dict), never the singular,
|
|
2377
|
+
"most recently indexed" `self.call_graph` alias. A cold cache
|
|
2378
|
+
(e.g. immediately after a process restart, before any
|
|
2379
|
+
`index_directory()` call -- `self.call_graphs` does not survive
|
|
2380
|
+
restart) degrades to empty callers/callees, exactly like the
|
|
2381
|
+
pre-4.2 REST handler's own fallback, never an error.
|
|
2382
|
+
|
|
2383
|
+
**Direct/transitive buckets** (§11's own literal definition, no
|
|
2384
|
+
additional buckets): `direct` = `depth == 1`, `transitive` =
|
|
2385
|
+
`depth >= 2`, applied symmetrically to both callers and callees
|
|
2386
|
+
(the pre-4.2 endpoint only ever bucketed callers, as counts, and
|
|
2387
|
+
left callees entirely unbucketed). Each entry already carries
|
|
2388
|
+
`confidence` (threaded through by `get_callers`/`get_callees`
|
|
2389
|
+
themselves, reusing the existing high/low/unresolved
|
|
2390
|
+
vocabulary) and the two lists are already deterministically
|
|
2391
|
+
ordered by those methods -- this method does no additional
|
|
2392
|
+
sorting or filtering of its own.
|
|
2393
|
+
|
|
2394
|
+
The four legacy fields (`direct_callers`, `indirect_callers`,
|
|
2395
|
+
`call_chain`, `dependencies`) are computed identically to their
|
|
2396
|
+
pre-4.2 values, from the SAME underlying `callers`/`callees`
|
|
2397
|
+
lists the new bucketed fields are built from -- this method is a
|
|
2398
|
+
strict superset of the old inline REST logic, not a redesign,
|
|
2399
|
+
guaranteeing zero behavioral drift for existing consumers/tests.
|
|
2400
|
+
"""
|
|
2401
|
+
repo_call_graph = self.call_graphs.get(repo_id)
|
|
2402
|
+
if repo_call_graph:
|
|
2403
|
+
callers = repo_call_graph.get_callers(symbol_name, file_path, max_depth=max_depth)
|
|
2404
|
+
callees = repo_call_graph.get_callees(symbol_name, file_path, max_depth=max_depth)
|
|
2405
|
+
else:
|
|
2406
|
+
callers, callees = [], []
|
|
2407
|
+
|
|
2408
|
+
direct_callers = [c for c in callers if c.get("depth") == 1]
|
|
2409
|
+
indirect_callers = [c for c in callers if c.get("depth", 0) > 1]
|
|
2410
|
+
direct_callees = [c for c in callees if c.get("depth") == 1]
|
|
2411
|
+
transitive_callees = [c for c in callees if c.get("depth", 0) > 1]
|
|
2412
|
+
|
|
2413
|
+
return {
|
|
2414
|
+
"symbol": symbol_name,
|
|
2415
|
+
"direct_callers": len(direct_callers),
|
|
2416
|
+
"indirect_callers": len(indirect_callers),
|
|
2417
|
+
"call_chain": callers,
|
|
2418
|
+
"dependencies": callees,
|
|
2419
|
+
"callers": {"direct": direct_callers, "transitive": indirect_callers},
|
|
2420
|
+
"callees": {"direct": direct_callees, "transitive": transitive_callees},
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
def analyze_module_structure(self, repo_id: int) -> ModuleGraphBuilder:
|
|
2424
|
+
"""
|
|
2425
|
+
Phase 4.3 (roadmap §37 item 4.3 "Deterministic module-clustering
|
|
2426
|
+
via import graph"; §11 "Architecture discovery"; §45 backlog
|
|
2427
|
+
B18). Library/shared-core-only per this milestone's explicit
|
|
2428
|
+
approved scope -- no REST route, CLI command, or MCP tool calls
|
|
2429
|
+
this yet; a future one can, through this exact method, without
|
|
2430
|
+
duplicating any of `ModuleGraphBuilder`'s own construction
|
|
2431
|
+
logic.
|
|
2432
|
+
|
|
2433
|
+
Repository-scoped by the EXACT SAME pattern `index_directory`
|
|
2434
|
+
already uses to build `self.call_graphs[repo_id]`: filters
|
|
2435
|
+
`self.parse_results` (this engine's own cache, keyed
|
|
2436
|
+
`"{repo_id}::{rel_path}"`) down to only this repository's own
|
|
2437
|
+
files before constructing `ModuleGraphBuilder` -- isolation
|
|
2438
|
+
holds by construction, not by a runtime filter inside the
|
|
2439
|
+
analysis itself.
|
|
2440
|
+
|
|
2441
|
+
Computed fresh from the current in-memory snapshot on every
|
|
2442
|
+
call when this process has parse results for the repository --
|
|
2443
|
+
no incremental-update machinery, no cross-call caching, per this
|
|
2444
|
+
milestone's original approved scope. 2026-10-12 (user-approved,
|
|
2445
|
+
CODEPULSE_FUTURE_CHANGES.md item 15): the resolved import edges
|
|
2446
|
+
are now persisted (`module_edges`), so after a process restart --
|
|
2447
|
+
when `self.parse_results` is empty -- the graph restored from
|
|
2448
|
+
them at startup is returned instead of an empty one. Returns an
|
|
2449
|
+
empty-graph `ModuleGraphBuilder` only when the repository has
|
|
2450
|
+
neither (never indexed, or the database is not yet migrated to
|
|
2451
|
+
`module_edges`).
|
|
2452
|
+
"""
|
|
2453
|
+
prefix = f"{repo_id}::"
|
|
2454
|
+
repo_parsed_files = [
|
|
2455
|
+
cached for key, cached in self.parse_results.items() if key.startswith(prefix)
|
|
2456
|
+
]
|
|
2457
|
+
if not repo_parsed_files and repo_id in self._restored_module_graphs:
|
|
2458
|
+
# No parse results in this process (a restart): use the graph
|
|
2459
|
+
# restored from the durable `module_edges` rows.
|
|
2460
|
+
return self._restored_module_graphs[repo_id]
|
|
2461
|
+
return ModuleGraphBuilder(repo_parsed_files)
|
|
2462
|
+
|
|
2463
|
+
def analyze_references(self, symbol_name: str, file_path: str, repo_id: int) -> dict[str, Any]:
|
|
2464
|
+
"""
|
|
2465
|
+
Phase 4.4 (roadmap §37 item 4.4 "References endpoint"; §21:
|
|
2466
|
+
`references` is its own endpoint group, explicitly SEPARATE from
|
|
2467
|
+
`graph (callers/callees/impact)`). "References" answers "what
|
|
2468
|
+
directly points at this symbol" -- DIRECT usages only, never a
|
|
2469
|
+
transitive/depth-bucketed traversal (that concept is
|
|
2470
|
+
`analyze_impact`'s, deliberately not reused here so the two
|
|
2471
|
+
stay distinct, per this project's own repeated discipline
|
|
2472
|
+
against conflating call graph / inheritance graph / impact
|
|
2473
|
+
analysis).
|
|
2474
|
+
|
|
2475
|
+
Two direct-reference kinds, both single-hop:
|
|
2476
|
+
- `callers`: symbols that directly call this one (`get_callers`
|
|
2477
|
+
called with `max_depth=1` -- reuses that method's own
|
|
2478
|
+
tested BFS/confidence/deterministic-ordering machinery
|
|
2479
|
+
exactly as-is, just capped to one hop, not a new algorithm).
|
|
2480
|
+
- `subclasses`: classes that directly list this symbol as one
|
|
2481
|
+
of their `base_classes` (`get_direct_subclasses` -- reads
|
|
2482
|
+
ONLY the inheritance-specific adjacency, never the calls-only
|
|
2483
|
+
one, preserving Phase 4.1's separation invariant. This is
|
|
2484
|
+
inheritance data's first real query surface -- extracted and
|
|
2485
|
+
resolved since Phase 4.1, explicitly excluded from impact
|
|
2486
|
+
analysis by Phase 4.2's Decision 1, and finally exposed
|
|
2487
|
+
here).
|
|
2488
|
+
|
|
2489
|
+
Repository-scoped by the EXACT SAME `self.call_graphs.get(repo_id)`
|
|
2490
|
+
pattern `analyze_impact`/`analyze_module_structure` already use.
|
|
2491
|
+
Computed fresh from the current in-memory snapshot on every call
|
|
2492
|
+
-- no persistence, no incremental machinery. A cold cache (never
|
|
2493
|
+
indexed this process, or after a restart) degrades to empty
|
|
2494
|
+
lists, never an error.
|
|
2495
|
+
"""
|
|
2496
|
+
repo_call_graph = self.call_graphs.get(repo_id)
|
|
2497
|
+
if repo_call_graph:
|
|
2498
|
+
callers = repo_call_graph.get_callers(symbol_name, file_path, max_depth=1)
|
|
2499
|
+
subclasses = repo_call_graph.get_direct_subclasses(symbol_name, file_path)
|
|
2500
|
+
else:
|
|
2501
|
+
callers, subclasses = [], []
|
|
2502
|
+
|
|
2503
|
+
return {
|
|
2504
|
+
"symbol": symbol_name,
|
|
2505
|
+
"callers": callers,
|
|
2506
|
+
"subclasses": subclasses,
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
def find_similar_symbols(
|
|
2510
|
+
self, query_text: str, exclude_symbol_name: str, repo_id: int,
|
|
2511
|
+
threshold: float = 0.75, limit: int = 10,
|
|
2512
|
+
) -> list[dict[str, Any]]:
|
|
2513
|
+
"""
|
|
2514
|
+
Phase 5.1 (roadmap §12 "related_symbols"). Shared intelligence-
|
|
2515
|
+
core semantic-similarity lookup -- extracted from what was
|
|
2516
|
+
previously inline-only logic in `POST /v1/symbols/{id}/similar`,
|
|
2517
|
+
so the new §12 context-bundle assembler (`get_context_bundle`)
|
|
2518
|
+
can reuse it instead of duplicating vector-search logic a
|
|
2519
|
+
second time.
|
|
2520
|
+
|
|
2521
|
+
Fixed a real, pre-existing repository-isolation gap while
|
|
2522
|
+
extracting this: the original inline route logic called
|
|
2523
|
+
`self.vector_index.search(...)` with NO `repo_id` filtering at
|
|
2524
|
+
all (unlike `IndexingEngine.search()`'s own "semantic" branch,
|
|
2525
|
+
repo-scoped since Phase 0.6) -- a caller could receive another
|
|
2526
|
+
repository's "similar" symbols. This method filters candidates
|
|
2527
|
+
by `meta.get("repo_id") == repo_id`, over-fetching the entire
|
|
2528
|
+
vector index pool first (`top_k=len(self.vector_index.doc_ids)`)
|
|
2529
|
+
exactly like `search()`'s own established `_repo_filter`
|
|
2530
|
+
pattern, rather than trusting a small `top_k` to happen to
|
|
2531
|
+
contain enough same-repository results.
|
|
2532
|
+
"""
|
|
2533
|
+
query_vec = self.embedder.encode(query_text)
|
|
2534
|
+
pool = len(self.vector_index.doc_ids)
|
|
2535
|
+
vector_res = self.vector_index.search(query_vec, top_k=pool) if pool else []
|
|
2536
|
+
# One entry per symbol (its best chunk), not one per chunk: a symbol
|
|
2537
|
+
# with several chunks used to be listed several times (item 20).
|
|
2538
|
+
vector_res = self._fold_vector_hits_by_symbol([r for r in vector_res if r.get("metadata", {}).get("repo_id") == repo_id])
|
|
2539
|
+
|
|
2540
|
+
similar_items: list[dict[str, Any]] = []
|
|
2541
|
+
for r in vector_res:
|
|
2542
|
+
meta = r.get("metadata", {})
|
|
2543
|
+
if meta.get("repo_id") != repo_id:
|
|
2544
|
+
continue
|
|
2545
|
+
if meta.get("symbol_name") == exclude_symbol_name:
|
|
2546
|
+
continue
|
|
2547
|
+
if r["relevance_score"] >= threshold:
|
|
2548
|
+
similar_items.append({
|
|
2549
|
+
"symbol_name": meta.get("symbol_name"),
|
|
2550
|
+
"file_path": meta.get("file_path"),
|
|
2551
|
+
"similarity": round(r["similarity"], 4),
|
|
2552
|
+
"relevance_score": round(r["relevance_score"], 4),
|
|
2553
|
+
})
|
|
2554
|
+
if len(similar_items) >= limit:
|
|
2555
|
+
break
|
|
2556
|
+
return similar_items
|
|
2557
|
+
|
|
2558
|
+
def get_context_bundle(
|
|
2559
|
+
self,
|
|
2560
|
+
symbol_name: str,
|
|
2561
|
+
file_path: str,
|
|
2562
|
+
repo_id: int,
|
|
2563
|
+
signature: str = "",
|
|
2564
|
+
docstring: str | None = None,
|
|
2565
|
+
body: str = "",
|
|
2566
|
+
include_body: bool = False,
|
|
2567
|
+
max_related: int = 10,
|
|
2568
|
+
) -> dict[str, Any]:
|
|
2569
|
+
"""
|
|
2570
|
+
Phase 5.1 (roadmap §12 "Agent-Native Retrieval / Context API"):
|
|
2571
|
+
the shared context-bundle assembly function §12 itself requires
|
|
2572
|
+
be "shared by REST + MCP" -- `POST /v1/agent/context` (Phase
|
|
2573
|
+
5.2) and the MCP `get_context` tool (Phase 5.3) both call this
|
|
2574
|
+
directly; neither duplicates the assembly logic.
|
|
2575
|
+
|
|
2576
|
+
Composes three ALREADY-EXISTING, independently-tested
|
|
2577
|
+
capabilities -- no new graph/retrieval algorithm is introduced
|
|
2578
|
+
here, purely assembly: `CallGraphBuilder.get_callers`/
|
|
2579
|
+
`get_callees` (reused with `max_depth=1`, exactly like
|
|
2580
|
+
`analyze_references` already does, for `callers`/`callees`) and
|
|
2581
|
+
`find_similar_symbols` (above) for `related_symbols`.
|
|
2582
|
+
|
|
2583
|
+
Token-efficiency rules, implemented exactly per §12's own
|
|
2584
|
+
numbered list: (1) `definition.body` is `None` unless
|
|
2585
|
+
`include_body=True` -- signature+docstring are always included,
|
|
2586
|
+
the full body only on explicit request; (2) `callers`/`callees`
|
|
2587
|
+
are capped at `max_related` (default 10), already
|
|
2588
|
+
deterministically ordered by confidence then call-count (Phase
|
|
2589
|
+
4.2's own established sort, not a new ranking); (3)
|
|
2590
|
+
`snapshot_commit` is always present -- the most recent
|
|
2591
|
+
`RepositorySnapshotModel.commit_sha` for this repository, `None`
|
|
2592
|
+
(honestly, never fabricated) for a non-git repository or one
|
|
2593
|
+
never successfully indexed; (4) `truncated` is `True` whenever
|
|
2594
|
+
anything was shortened (the body was omitted, or `callers`/
|
|
2595
|
+
`callees` had more entries than `max_related`), so a caller/
|
|
2596
|
+
agent always knows when a field was silently capped.
|
|
2597
|
+
|
|
2598
|
+
Repository-scoped via `self.call_graphs.get(repo_id)`, matching
|
|
2599
|
+
every other `analyze_*` method's own established pattern --
|
|
2600
|
+
never the singular `self.call_graph` alias.
|
|
2601
|
+
"""
|
|
2602
|
+
repo_call_graph = self.call_graphs.get(repo_id)
|
|
2603
|
+
if repo_call_graph:
|
|
2604
|
+
all_callers = repo_call_graph.get_callers(symbol_name, file_path, max_depth=1)
|
|
2605
|
+
all_callees = repo_call_graph.get_callees(symbol_name, file_path, max_depth=1)
|
|
2606
|
+
else:
|
|
2607
|
+
all_callers, all_callees = [], []
|
|
2608
|
+
|
|
2609
|
+
callers = all_callers[:max_related]
|
|
2610
|
+
callees = all_callees[:max_related]
|
|
2611
|
+
|
|
2612
|
+
query_text = f"{signature}\n{docstring or ''}\n{body[:500]}"
|
|
2613
|
+
related_raw = self.find_similar_symbols(query_text, symbol_name, repo_id, limit=max_related)
|
|
2614
|
+
related_symbols = [
|
|
2615
|
+
{
|
|
2616
|
+
"symbol": r["symbol_name"],
|
|
2617
|
+
"file": r["file_path"],
|
|
2618
|
+
"relevance": r["relevance_score"],
|
|
2619
|
+
"reason": "semantic_similarity",
|
|
2620
|
+
}
|
|
2621
|
+
for r in related_raw
|
|
2622
|
+
]
|
|
2623
|
+
|
|
2624
|
+
session = self.storage.get_session()
|
|
2625
|
+
try:
|
|
2626
|
+
snapshot = (
|
|
2627
|
+
session.query(RepositorySnapshotModel)
|
|
2628
|
+
.filter(RepositorySnapshotModel.repo_id == repo_id)
|
|
2629
|
+
.order_by(RepositorySnapshotModel.id.desc())
|
|
2630
|
+
.first()
|
|
2631
|
+
)
|
|
2632
|
+
snapshot_commit = snapshot.commit_sha if snapshot else None
|
|
2633
|
+
finally:
|
|
2634
|
+
session.close()
|
|
2635
|
+
|
|
2636
|
+
truncated = (
|
|
2637
|
+
(not include_body)
|
|
2638
|
+
or (len(all_callers) > max_related)
|
|
2639
|
+
or (len(all_callees) > max_related)
|
|
2640
|
+
)
|
|
2641
|
+
|
|
2642
|
+
return {
|
|
2643
|
+
"symbol": symbol_name,
|
|
2644
|
+
"file": file_path,
|
|
2645
|
+
"definition": {
|
|
2646
|
+
"signature": signature,
|
|
2647
|
+
"docstring": docstring,
|
|
2648
|
+
"body": body if include_body else None,
|
|
2649
|
+
},
|
|
2650
|
+
"callers": callers,
|
|
2651
|
+
"callees": callees,
|
|
2652
|
+
"related_symbols": related_symbols,
|
|
2653
|
+
"snapshot_commit": snapshot_commit,
|
|
2654
|
+
"truncated": truncated,
|
|
2655
|
+
}
|