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,342 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ import tantivy
6
+
7
+ from codepulse.models import Symbol
8
+ from codepulse.search.tokenizer import tokenize_code
9
+
10
+
11
+ def _build_schema() -> "tantivy.Schema":
12
+ """
13
+ Phase 2.1 -- the Tantivy schema backing `BM25Index`.
14
+
15
+ Fields, and why each is shaped the way it is (verified against the
16
+ actually-installed `tantivy` 0.22.2 Python binding -- see
17
+ CODEPULSE_PROGRESS.md's Phase 2.1 section for the exact APIs checked):
18
+
19
+ - `doc_id`: the CodePulse-level identity string (e.g.
20
+ "{repo_id}::{file_path}::{name}@{start_line}", see
21
+ `codepulse.symbol_identity`), stored so it can be read back on
22
+ every hit. `tokenizer_name="raw"` means the whole string is one exact
23
+ token -- this field is never searched against, only ever stored/
24
+ retrieved and, for `add_document`'s direct callers, exact-matched.
25
+ - `repo_id`: `indexed=True` (required for `IndexWriter.delete_documents`,
26
+ which deletes by exact term match on an indexed field) and
27
+ `fast=True` (columnar storage, cheap random access) -- this is the
28
+ field Phase 2.1's repository-replacement semantics deletes by (see
29
+ `remove_repo` below). `stored=True` so it round-trips onto every hit
30
+ without a second lookup. May be absent on a document added with
31
+ `repo_id=None` (direct `BM25Index` usage outside `IndexingEngine`,
32
+ e.g. `tests/test_bm25_vector.py`) -- verified experimentally that an
33
+ absent field reads back as `None` via `Document.get_first`, matching
34
+ the pre-Tantivy dict-based `metadata.get("repo_id")` behavior exactly.
35
+ - `body`: the single combined searchable text (see `add_symbol`'s own
36
+ docstring for its exact construction, unchanged from the pre-Tantivy
37
+ implementation). Uses the built-in `tokenizer_name="whitespace"` --
38
+ the text is pre-tokenized through this project's own
39
+ `codepulse.search.tokenizer.tokenize_code` (the same code-aware
40
+ snake_case/camelCase splitter the old in-memory index used) and
41
+ joined with spaces BEFORE being handed to Tantivy, so indexing and
42
+ querying both go through the identical, already-tested tokenizer;
43
+ Tantivy's own "default" tokenizer is deliberately NOT used here, since
44
+ it doesn't split camelCase identifiers the way `tokenize_code` does,
45
+ and reusing a "raw"/"whitespace" field is the standard way to feed a
46
+ pre-tokenized stream into Tantivy without it re-processing the terms.
47
+ Not stored -- nothing reads this field back, only the parsed
48
+ `metadata` JSON blob (below) is used to reconstruct a result.
49
+ - `metadata`: a JSON field, stored, carrying the exact metadata dict
50
+ `add_symbol`/`add_document` were given (including its own
51
+ `repo_id` key) -- read back verbatim via `Document.get_first`
52
+ (verified experimentally that a Python dict passed to
53
+ `Document.add_json` round-trips as a real `dict`, not a JSON string).
54
+ """
55
+ builder = tantivy.SchemaBuilder()
56
+ builder.add_text_field("doc_id", stored=True, tokenizer_name="raw")
57
+ builder.add_integer_field("repo_id", stored=True, indexed=True, fast=True)
58
+ builder.add_text_field("body", stored=False, tokenizer_name="whitespace")
59
+ builder.add_json_field("metadata", stored=True)
60
+ return builder.build()
61
+
62
+
63
+ class BM25Index:
64
+ """
65
+ Embedded Tantivy-backed lexical index for codebase symbols (Phase 2.1).
66
+
67
+ Replaces the pre-Phase-2.1 pure-Python, in-memory BM25Okapi
68
+ implementation's *internals* with the embedded `tantivy` library
69
+ (roadmap §36 Phase 2: "swap `BM25Index` for a Tantivy-backed embedded
70
+ lexical index... without changing the ranking contract") while
71
+ preserving, unchanged, every part of the class's external contract that
72
+ `IndexingEngine`, `HybridRanker`, and the existing test suite depend on:
73
+
74
+ - Constructor: `BM25Index(k1=None, b=None)`, falling back to
75
+ `get_settings().bm25_k1`/`bm25_b` -- `.k1`/`.b` remain real,
76
+ readable attributes (`tests/test_config.py`) even though Tantivy's
77
+ own internal BM25 scorer does not expose a configurable k1/b through
78
+ its Python binding (verified: no such parameter exists anywhere in
79
+ `tantivy.Query`/`tantivy.Searcher`/`tantivy.Index`'s introspected
80
+ method surface) -- Tantivy's fixed internal BM25 implementation is
81
+ used as-is. This is a disclosed, expected candidate-generation
82
+ difference from the old hand-rolled formula, not a defect: the
83
+ roadmap's own words are "without changing the ranking contract," and
84
+ the *contract* is the shape/meaning of what `HybridRanker` consumes
85
+ (Section 5/9 of the Phase 2.1 architecture gate), not
86
+ bit-for-bit-identical BM25 arithmetic.
87
+ - `.doc_ids`: a real `list[str]` of every currently-indexed document id
88
+ (`tests/test_ignore_discovery.py` reads this directly).
89
+ - `.num_docs`: the current, live document count (post-deletion, not a
90
+ monotonic counter) -- `tests/test_restart_idempotency.py` and
91
+ `IndexingEngine.search()`'s scoped-search candidate-pool sizing both
92
+ depend on this being accurate at any point in time, not just after a
93
+ `build()` call.
94
+ - `add_symbol(doc_id, symbol, extra_text="", repo_id=None)`,
95
+ `add_document(doc_id, text, metadata=None)`, `build()`,
96
+ `search(query, top_k=20)` -- identical signatures and return shapes.
97
+
98
+ Two behaviors are new in Phase 2.1, both required by the Phase 2.1
99
+ architecture gate's approved idempotency design and *not* available in
100
+ the old parallel-list implementation:
101
+
102
+ - `remove_repo(repo_id)`: deletes every currently-indexed document
103
+ belonging to `repo_id`. `IndexingEngine.index_directory()` calls this
104
+ once, immediately before (re-)adding a repository's freshly-parsed
105
+ symbols, so repeated indexing replaces rather than accumulates a
106
+ repository's lexical documents (see that method's own comment for
107
+ the full reasoning; this was a real, pre-existing, untested gap in
108
+ the old `BM25Index`, not something Phase 2.1 is required to fix by
109
+ the roadmap's literal text, but is fixed here because Tantivy's
110
+ `IndexWriter.delete_documents` makes it essentially free and it
111
+ closes a genuine correctness gap with zero ranking-contract impact).
112
+ - A single `IndexWriter` is created once, in `__init__`, and reused for
113
+ every subsequent `add_symbol`/`add_document`/`remove_repo`/`build()`
114
+ call for the lifetime of this `BM25Index` instance -- verified
115
+ experimentally that a Tantivy `IndexWriter` is safe to reuse across
116
+ many add/delete/commit cycles (not a one-shot, single-use object).
117
+
118
+ Source of truth: per the Phase 2.1 architecture gate's Section 5, this
119
+ Tantivy index is a DERIVED, REBUILDABLE search artifact, never the
120
+ durable source of truth -- `SymbolModel`/`FileModel` rows remain
121
+ authoritative, exactly as they already are for the pre-Tantivy
122
+ implementation. The index lives entirely in memory
123
+ (`tantivy.Index(schema)` with no path argument -- verified
124
+ experimentally that this constructs an in-RAM index) and is NOT
125
+ persisted to disk directly -- there are no on-disk Tantivy index
126
+ segments anywhere. It does NOT survive a process restart on its own.
127
+
128
+ Backend-hardening addition (not a roadmap sprint item):
129
+ `IndexingEngine.hydrate_repository`/`hydrate_all` now rebuild this
130
+ index at engine-construction time from the durable `SymbolModel`/
131
+ `FileModel` rows above (re-tokenizing already-stored text, no
132
+ re-parsing, no embedding-model call) -- so a fresh `IndexingEngine`
133
+ has working keyword search immediately, without requiring a new
134
+ `index_directory()` call first. This is still a from-scratch,
135
+ in-memory REBUILD on every process start (not literal on-disk index
136
+ persistence, which was evaluated and deliberately not pursued --
137
+ see CODEPULSE_PROGRESS.md's own entry for the reasoning), just one
138
+ that now happens automatically at startup as well as at indexing
139
+ time.
140
+ """
141
+
142
+ def __init__(self, k1: float | None = None, b: float | None = None) -> None:
143
+ from codepulse.config import get_settings
144
+ settings = get_settings()
145
+ self.k1 = k1 if k1 is not None else settings.bm25_k1
146
+ self.b = b if b is not None else settings.bm25_b
147
+
148
+ self._schema = _build_schema()
149
+ self._index = tantivy.Index(self._schema)
150
+ # A single writer, created once and reused for this instance's
151
+ # entire lifetime -- verified experimentally (see module docstring)
152
+ # that this is safe across many add/delete/commit cycles, avoiding
153
+ # the cost (and, per tantivy-py's own locking model, the risk) of
154
+ # constructing a fresh writer on every `index_directory()` call.
155
+ self._writer = self._index.writer(heap_size=50_000_000, num_threads=1)
156
+ # A searcher reflecting the index's state as of the most recent
157
+ # `build()` (or, before the first `build()`, the empty index) --
158
+ # refreshed at the end of every `build()`. A `Searcher` is a frozen
159
+ # snapshot in tantivy-py (verified experimentally: a `Searcher`
160
+ # fetched before a commit does NOT see documents added by that
161
+ # commit), so `search()` must never reuse a stale one.
162
+ self._searcher = self._index.searcher()
163
+
164
+ # Live bookkeeping mirroring the actual indexed document set --
165
+ # needed because tantivy-py's `Document`/`DocAddress` have no
166
+ # concept of "the CodePulse doc_id" as a first-class handle, and
167
+ # because `.doc_ids`/`.num_docs` (an existing, tested external
168
+ # contract -- see class docstring) must reflect the CURRENT
169
+ # (post-deletion) state at any moment, not just immediately after a
170
+ # `build()`.
171
+ self.doc_ids: list[str] = []
172
+ self._repo_by_doc_id: dict[str, int | None] = {}
173
+
174
+ @property
175
+ def num_docs(self) -> int:
176
+ return len(self.doc_ids)
177
+
178
+ def add_symbol(
179
+ self,
180
+ doc_id: str,
181
+ symbol: Symbol,
182
+ extra_text: str = "",
183
+ repo_id: int | None = None,
184
+ ) -> None:
185
+ """
186
+ Adds a Symbol to the lexical index.
187
+
188
+ `repo_id` (Phase 0.6) is carried through into the document's
189
+ metadata so repository-scoped search can filter candidates by
190
+ owning repository without needing a second identity system --
191
+ it's the same `RepositoryModel.id` already used elsewhere.
192
+ Optional/None for backward compatibility with any caller that
193
+ doesn't track a repository (e.g. tests exercising this index
194
+ directly, unrelated to Phase 0.6).
195
+ """
196
+ # Combine name, docstring, signature, and body snippet -- unchanged
197
+ # construction from the pre-Tantivy implementation (Phase 2.1 does
198
+ # not redesign the search corpus).
199
+ text_parts = [
200
+ symbol.name * 3, # Boost symbol name
201
+ symbol.signature,
202
+ symbol.docstring or "",
203
+ extra_text,
204
+ symbol.body[:1000] if symbol.body else "",
205
+ ]
206
+ combined_text = " ".join(text_parts)
207
+ self.add_document(doc_id, combined_text, metadata={
208
+ "symbol_name": symbol.name,
209
+ "file_path": symbol.file_path,
210
+ "type": symbol.type.value if hasattr(symbol.type, "value") else str(symbol.type),
211
+ "start_line": symbol.start_line,
212
+ "end_line": symbol.end_line,
213
+ "signature": symbol.signature,
214
+ "docstring": symbol.docstring,
215
+ "language": symbol.language,
216
+ "repo_id": repo_id,
217
+ })
218
+
219
+ def add_document(self, doc_id: str, text: str, metadata: dict[str, Any] | None = None) -> None:
220
+ """Indexes a single document text with its ID and optional metadata."""
221
+ meta = metadata or {}
222
+ repo_id = meta.get("repo_id")
223
+
224
+ tokens = tokenize_code(text)
225
+
226
+ doc = tantivy.Document()
227
+ doc.add_text("doc_id", doc_id)
228
+ if repo_id is not None:
229
+ doc.add_integer("repo_id", repo_id)
230
+ doc.add_text("body", " ".join(tokens))
231
+ doc.add_json("metadata", meta)
232
+ self._writer.add_document(doc)
233
+
234
+ self.doc_ids.append(doc_id)
235
+ self._repo_by_doc_id[doc_id] = repo_id
236
+
237
+ def remove_repo(self, repo_id: int) -> None:
238
+ """
239
+ Deletes every currently-indexed lexical document belonging to
240
+ `repo_id` (Phase 2.1 repository-replacement semantics -- see class
241
+ docstring and `IndexingEngine.index_directory()`'s call site).
242
+ Safe to call for a `repo_id` with no existing documents (verified
243
+ experimentally: `IndexWriter.delete_documents` on a non-matching
244
+ term is a harmless no-op), which covers both the first-ever index
245
+ of a repository and an empty repository (roadmap Phase 2.1 test
246
+ cases A and F).
247
+ """
248
+ self._writer.delete_documents("repo_id", repo_id)
249
+ removed = {doc_id for doc_id, r in self._repo_by_doc_id.items() if r == repo_id}
250
+ for doc_id in removed:
251
+ del self._repo_by_doc_id[doc_id]
252
+ if removed:
253
+ self.doc_ids = [d for d in self.doc_ids if d not in removed]
254
+
255
+ def remove_docs(self, doc_ids: list[str]) -> None:
256
+ """
257
+ Phase 3.1 (roadmap §37 item 3.1: git-diff change detection's
258
+ "(deleted)" category) -- deletes exactly the given documents by
259
+ their exact `doc_id` (the `doc_id` field is `tokenizer_name="raw"`,
260
+ so this is an exact-term match, never a partial/fuzzy one), unlike
261
+ `remove_repo`'s coarser whole-repository granularity. Used when
262
+ `IndexingEngine.index_directory()` detects a specific set of
263
+ deleted files via `codepulse.git_diff` and needs to remove only
264
+ those files' symbols from the lexical index, leaving every other
265
+ symbol in the same repository (and every other repository)
266
+ untouched. Safe to call with a `doc_id` that is not currently
267
+ indexed (a harmless no-op for that one id, matching
268
+ `remove_repo`'s existing no-op-safety precedent).
269
+ """
270
+ to_remove = set(doc_ids)
271
+ if not to_remove:
272
+ return
273
+ for doc_id in to_remove:
274
+ self._writer.delete_documents("doc_id", doc_id)
275
+ for doc_id in to_remove:
276
+ self._repo_by_doc_id.pop(doc_id, None)
277
+ self.doc_ids = [d for d in self.doc_ids if d not in to_remove]
278
+
279
+ def build(self) -> None:
280
+ """
281
+ Commits every `add_document`/`remove_repo` call made since the
282
+ last `build()`, and refreshes this index's searcher so `search()`
283
+ immediately sees the committed state -- the same two-phase
284
+ "accumulate, then finalize" shape the pre-Tantivy implementation's
285
+ `build()` (IDF/avgdl computation) had, now mapped onto Tantivy's
286
+ own commit/reload lifecycle (verified experimentally: a `Searcher`
287
+ fetched before `commit()`+`reload()` does not see the new writes;
288
+ one fetched after does).
289
+ """
290
+ self._writer.commit()
291
+ self._index.reload()
292
+ self._searcher = self._index.searcher()
293
+
294
+ def search(self, query: str, top_k: int = 20) -> list[dict[str, Any]]:
295
+ """
296
+ Searches the index with the given query.
297
+ Returns a sorted list of matched documents with normalized lexical
298
+ relevance scores in [0, 1] -- same shape as the pre-Tantivy
299
+ implementation: `{"doc_id", "raw_score", "normalized_score",
300
+ "metadata"}` per result, sorted descending, truncated to `top_k`.
301
+ """
302
+ if self.num_docs == 0 or top_k < 1:
303
+ return []
304
+
305
+ query_tokens = tokenize_code(query)
306
+ if not query_tokens:
307
+ return []
308
+
309
+ subqueries = [
310
+ (tantivy.Occur.Should, tantivy.Query.term_query(self._schema, "body", token))
311
+ for token in query_tokens
312
+ ]
313
+ tantivy_query = tantivy.Query.boolean_query(subqueries)
314
+ search_result = self._searcher.search(tantivy_query, limit=top_k)
315
+
316
+ hits = list(search_result.hits)
317
+ # Preserve the exact pre-Tantivy normalization semantics: divide
318
+ # every raw score in THIS result set by the highest raw score in
319
+ # that same set (not a min-max/global normalization), with the
320
+ # same zero-max safety fallback the old implementation used.
321
+ max_score = max((score for score, _ in hits), default=0.0)
322
+ if max_score <= 0:
323
+ max_score = 1.0
324
+
325
+ results: list[dict[str, Any]] = []
326
+ for score, doc_address in hits:
327
+ doc = self._searcher.doc(doc_address)
328
+ normalized_score = min(1.0, score / max_score) if max_score > 0 else 0.0
329
+ results.append({
330
+ "doc_id": doc.get_first("doc_id"),
331
+ "raw_score": float(score),
332
+ "normalized_score": normalized_score,
333
+ "metadata": doc.get_first("metadata") or {},
334
+ })
335
+
336
+ # Tantivy's own TopDocs collector already returns hits sorted
337
+ # descending by score, truncated to `limit` -- this sort is a
338
+ # cheap, defensive guarantee of that same ordering (matching the
339
+ # pre-Tantivy implementation's own explicit sort), not a
340
+ # correction of anything observed to be wrong.
341
+ results.sort(key=lambda r: r["raw_score"], reverse=True)
342
+ return results[:top_k]