codepulse-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|