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.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. 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
+ }