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,120 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Sequence
5
+ import numpy as np
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ DEFAULT_MODEL_NAME = "all-MiniLM-L6-v2"
10
+ EMBEDDING_DIM = 384
11
+
12
+
13
+ class EmbeddingUnavailableError(RuntimeError):
14
+ """
15
+ Raised when the configured embedding model cannot be loaded or cannot
16
+ produce a real embedding.
17
+
18
+ Permanent project invariant (see CLAUDE.md / roadmap §17, §34): there is
19
+ NO pseudo-embedding fallback. Callers MUST treat this as an explicit,
20
+ observable failure -- skip persisting/using the affected vectors and
21
+ report a degraded (lexical-only) status -- never silently substitute a
22
+ hash-based, random, or otherwise fabricated vector in its place.
23
+ """
24
+
25
+
26
+ class CodeEmbedder:
27
+ """
28
+ Generates dense vector embeddings for code snippets and natural language queries.
29
+ Uses SentenceTransformer ('all-MiniLM-L6-v2' by default, 384 dimensions).
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ model_name: str | None = None,
35
+ device: str | None = None,
36
+ dimension: int | None = None,
37
+ ) -> None:
38
+ from codepulse.config import get_settings
39
+ settings = get_settings()
40
+ self.model_name = model_name or settings.embedding_model
41
+ self.device = device or settings.embedding_device
42
+ self.dimension = dimension or settings.embedding_dimension
43
+ self._model = None
44
+
45
+ def _get_model(self):
46
+ if self._model is None:
47
+ try:
48
+ from sentence_transformers import SentenceTransformer
49
+ logger.info("Loading embedding model '%s' on %s...", self.model_name, self.device)
50
+ self._model = SentenceTransformer(self.model_name, device=self.device)
51
+ except Exception as e:
52
+ logger.error(
53
+ "Failed to load embedding model '%s': %s. No pseudo-embedding "
54
+ "fallback will be used -- embedding generation must fail explicitly.",
55
+ self.model_name,
56
+ e,
57
+ )
58
+ raise EmbeddingUnavailableError(
59
+ f"Embedding model '{self.model_name}' could not be loaded: {e}"
60
+ ) from e
61
+ return self._model
62
+
63
+ def preload(self) -> None:
64
+ """
65
+ Loads the model now instead of on the first `encode()`. Used by
66
+ `serve --mcp`: importing sentence-transformers for the first time
67
+ from the stdio server's tool-call worker thread hangs on Windows,
68
+ so the model must be loaded on the main thread before serving.
69
+ Raises EmbeddingUnavailableError exactly as `encode()` would.
70
+ """
71
+ self._get_model()
72
+
73
+ def encode(self, texts: str | list[str], batch_size: int = 32) -> np.ndarray:
74
+ """
75
+ Encodes a single string or list of strings into normalized embedding vectors.
76
+ Returns a numpy array of shape (N, dimension).
77
+
78
+ Raises EmbeddingUnavailableError if the configured model cannot be
79
+ loaded or fails to produce embeddings. There is no fallback path --
80
+ callers are responsible for handling this explicitly (see
81
+ EmbeddingUnavailableError's docstring).
82
+ """
83
+ is_single = isinstance(texts, str)
84
+ text_list = [texts] if is_single else texts
85
+
86
+ if not text_list:
87
+ return np.empty((0, self.dimension), dtype=np.float32)
88
+
89
+ model = self._get_model()
90
+
91
+ # Phase 7.4 (roadmap §37 item 7.4; §18: "embedding_duration_seconds").
92
+ from codepulse.observability import embedding_duration_seconds
93
+ with embedding_duration_seconds.time():
94
+ try:
95
+ embeddings = model.encode(
96
+ text_list,
97
+ batch_size=batch_size,
98
+ show_progress_bar=False,
99
+ convert_to_numpy=True,
100
+ normalize_embeddings=True,
101
+ )
102
+ except EmbeddingUnavailableError:
103
+ raise
104
+ except Exception as e:
105
+ logger.error("Embedding model '%s' failed to encode input: %s", self.model_name, e)
106
+ raise EmbeddingUnavailableError(
107
+ f"Embedding model '{self.model_name}' failed to encode input: {e}"
108
+ ) from e
109
+
110
+ return embeddings[0] if is_single else embeddings
111
+
112
+ @staticmethod
113
+ def cosine_similarity(v1: np.ndarray, v2: np.ndarray) -> float:
114
+ """Computes cosine similarity between two normalized vectors."""
115
+ dot = np.dot(v1, v2)
116
+ norm1 = np.linalg.norm(v1)
117
+ norm2 = np.linalg.norm(v2)
118
+ if norm1 == 0 or norm2 == 0:
119
+ return 0.0
120
+ return float(dot / (norm1 * norm2))
codepulse/git_diff.py ADDED
@@ -0,0 +1,201 @@
1
+ """
2
+ Phase 3.1/3.2 — Git-diff based change detection, including rename/move
3
+ detection (roadmap §37 item 3.1: "Git-diff change detection (added/
4
+ modified/deleted)"; item 3.2: "Rename/move detection + symbol
5
+ re-keying"; §9 Incremental Indexing Design: "Incremental indexing for a
6
+ git repo uses `git diff <last_commit>..<current_commit> --name-status`
7
+ to get the file-level change set directly" and "for git repos, use
8
+ git's own rename detection (`git diff -M`) to map `(old_path ->
9
+ new_path)`").
10
+
11
+ This module is a real, standalone primitive: given a local git working
12
+ tree and a previously-recorded commit SHA, determine exactly which
13
+ files were added, modified, deleted, or renamed/moved since that
14
+ commit. It has no dependency on `IndexingEngine`, the database, or any
15
+ other CodePulse subsystem -- mirroring the established "top-level,
16
+ cross-cutting concern" placement of `codepulse/jobs.py`,
17
+ `codepulse/rate_limit.py`, and `codepulse/ingestion_safety.py`.
18
+
19
+ Rename/move detection (Phase 3.2, new in this module):
20
+ - `-M` is passed explicitly to `git diff` (git's own rename-detection
21
+ flag, per §9's own literal text), rather than relying on ambient
22
+ `diff.renames` config or git's version-dependent default -- so
23
+ behavior is deterministic across environments. This uses git's own
24
+ default similarity threshold (50%): a path pair git itself judges
25
+ at least 50% similar is reported as a rename (`R<NNN>`, e.g. `R100`
26
+ for a byte-identical move, `R087` for a move with a fairly small
27
+ edit); below that threshold, git reports its own plain delete+add
28
+ instead, and this module does not override or second-guess that
29
+ judgment -- matching the roadmap's own risk-register mitigation
30
+ ("git rename detection... with fallback to delete+recreate rather
31
+ than a risky guess").
32
+ - Copy detection (`-C`) is deliberately NOT enabled -- the roadmap
33
+ only names rename/move detection, not copy detection, and enabling
34
+ it would require a different, unrequested handling path (a copy
35
+ keeps the original file too, unlike a rename).
36
+ - `ChangeSet.renamed` is a list of `(old_path, new_path)` pairs. It is
37
+ disjoint from `added`/`modified`/`deleted` -- once rename detection
38
+ is active, git itself never double-reports a renamed path as a
39
+ separate delete+add.
40
+
41
+ Scope, precisely -- deliberately NOT inferring later Phase 3 items:
42
+ - This module identifies the rename mapping only. Whether/how to
43
+ re-key the corresponding `File`/`Symbol`/`Embedding` DB rows and
44
+ search-index doc_ids in place (rather than delete+recreate) is
45
+ `IndexingEngine`'s job (`codepulse/indexer/engine.py`), not this
46
+ module's.
47
+ - Symbol-level `body_hash` comparison to skip unchanged embeddings
48
+ within a changed (or renamed-with-modification) file (§37 item 3.3)
49
+ is not implemented here; this module only determines which *files*
50
+ changed/moved, not which *symbols within* a file actually differ.
51
+ - Call-graph confidence scoring (3.4) and local BFS incremental graph
52
+ updates (3.5) -- including correcting *other* files' call-graph
53
+ edges that reference a renamed file's old path -- are unrelated to
54
+ this module.
55
+
56
+ Uses the system `git` binary via `subprocess` -- no new dependency was
57
+ added; `git` is already a prerequisite for this project to even be a
58
+ git repository, and no Python git library (GitPython etc.) is declared
59
+ anywhere in `pyproject.toml`.
60
+ """
61
+ from __future__ import annotations
62
+
63
+ import subprocess
64
+ from dataclasses import dataclass, field
65
+
66
+
67
+ class GitDiffError(Exception):
68
+ """Raised when git-diff-based change detection cannot be performed --
69
+ e.g. the path is not a git repository, the given commit no longer
70
+ exists (a rebase/force-push invalidated it), or the `git` binary
71
+ itself is unavailable. Callers are expected to catch this and fall
72
+ back to a full (non-incremental) re-index, exactly matching §9's own
73
+ "when a full re-index is still required" list."""
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ChangeSet:
78
+ """The result of `detect_file_changes`: disjoint sets of
79
+ repository-root-relative, forward-slash-normalized paths, plus
80
+ (Phase 3.2) a list of rename/move `(old_path, new_path)` pairs."""
81
+
82
+ added: list[str] = field(default_factory=list)
83
+ modified: list[str] = field(default_factory=list)
84
+ deleted: list[str] = field(default_factory=list)
85
+ renamed: list[tuple[str, str]] = field(default_factory=list)
86
+
87
+ @property
88
+ def changed_or_added(self) -> list[str]:
89
+ """Every path that needs (re-)parsing -- added and modified
90
+ files, plus a rename/move's NEW path (its content, even if
91
+ byte-identical to before the move, must still be read and
92
+ parsed at least once under its new path so every in-memory/
93
+ durable structure that keys off `file_path` -- chunks, doc_ids,
94
+ the call graph -- reflects the new path). Does not include
95
+ `deleted` (needs cleanup, not parsing) or a rename's OLD path
96
+ (superseded, not itself parsed)."""
97
+ return self.added + self.modified + [new for _, new in self.renamed]
98
+
99
+ @property
100
+ def total_changed(self) -> int:
101
+ return len(self.added) + len(self.modified) + len(self.deleted) + len(self.renamed)
102
+
103
+
104
+ def _run_git(args: list[str], cwd: str) -> str:
105
+ try:
106
+ result = subprocess.run(
107
+ ["git", *args],
108
+ cwd=cwd,
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=30,
112
+ )
113
+ except (OSError, subprocess.TimeoutExpired) as e:
114
+ raise GitDiffError(f"failed to invoke git in {cwd!r}: {e}") from e
115
+ if result.returncode != 0:
116
+ raise GitDiffError(
117
+ f"git {' '.join(args)} failed in {cwd!r} (exit {result.returncode}): {result.stderr.strip()}"
118
+ )
119
+ return result.stdout
120
+
121
+
122
+ def is_git_repository(path: str) -> bool:
123
+ """Whether `path` is inside a git working tree. A real, current
124
+ check via `git rev-parse` -- not a mere `.git` directory existence
125
+ check, so a path inside a subdirectory of a repo (or a worktree)
126
+ is correctly recognized too."""
127
+ try:
128
+ output = _run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
129
+ except GitDiffError:
130
+ return False
131
+ return output.strip() == "true"
132
+
133
+
134
+ def get_head_commit_sha(path: str) -> str | None:
135
+ """The current `HEAD` commit SHA for the git repository at `path`,
136
+ or `None` if `path` is not a git repository or has no commits yet
137
+ (a freshly-`git init`'d, empty repo)."""
138
+ if not is_git_repository(path):
139
+ return None
140
+ try:
141
+ output = _run_git(["rev-parse", "HEAD"], cwd=path)
142
+ except GitDiffError:
143
+ return None
144
+ sha = output.strip()
145
+ return sha or None
146
+
147
+
148
+ def detect_file_changes(repo_path: str, since_commit: str) -> ChangeSet:
149
+ """
150
+ Returns the set of files added, modified, deleted, or renamed/moved
151
+ between `since_commit` and the current `HEAD`, using `git diff -M
152
+ --name-status` (§9's own literal recommendation for rename
153
+ detection, combined with its own literal `--name-status` recipe for
154
+ the base change-detection mechanism).
155
+
156
+ Raises `GitDiffError` if `repo_path` is not a git repository, if
157
+ `since_commit` no longer resolves (e.g. history was rewritten), or
158
+ if `git` itself fails for any other reason -- callers must catch
159
+ this and fall back to a full re-index; this function never silently
160
+ returns an empty/wrong result on failure.
161
+ """
162
+ if not is_git_repository(repo_path):
163
+ raise GitDiffError(f"{repo_path!r} is not a git repository")
164
+
165
+ output = _run_git(
166
+ ["diff", "-M", "--name-status", f"{since_commit}..HEAD"],
167
+ cwd=repo_path,
168
+ )
169
+
170
+ added: list[str] = []
171
+ modified: list[str] = []
172
+ deleted: list[str] = []
173
+ renamed: list[tuple[str, str]] = []
174
+
175
+ for line in output.splitlines():
176
+ if not line.strip():
177
+ continue
178
+ parts = line.split("\t")
179
+ if len(parts) < 2:
180
+ continue
181
+ status = parts[0]
182
+
183
+ if status.startswith("R"):
184
+ # Rename/move: "R<similarity>\t<old_path>\t<new_path>" --
185
+ # three tab-separated fields, unlike every other status.
186
+ if len(parts) < 3:
187
+ continue
188
+ old_path = parts[1].replace("\\", "/")
189
+ new_path = parts[2].replace("\\", "/")
190
+ renamed.append((old_path, new_path))
191
+ continue
192
+
193
+ rel_path = parts[1].replace("\\", "/")
194
+ if status.startswith("A"):
195
+ added.append(rel_path)
196
+ elif status.startswith("D"):
197
+ deleted.append(rel_path)
198
+ else: # M, T, or any other single-letter status
199
+ modified.append(rel_path)
200
+
201
+ return ChangeSet(added=added, modified=modified, deleted=deleted, renamed=renamed)