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
codepulse/config.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tomllib
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
|
9
|
+
|
|
10
|
+
from codepulse.data_location import default_database_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodePulseConfig(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
Central configuration model for CodePulse.
|
|
16
|
+
|
|
17
|
+
Precedence order:
|
|
18
|
+
CLI flags > Environment variables (CODEPULSE_*) > codepulse.toml > Built-in defaults
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Storage & Database
|
|
22
|
+
db_url: str = Field(
|
|
23
|
+
default_factory=default_database_url,
|
|
24
|
+
description=(
|
|
25
|
+
"Database connection URL (e.g., sqlite:///codepulse.db). Unset, it is ONE database per user in the "
|
|
26
|
+
"operating system's per-user data directory (see codepulse.data_location), or the codepulse.db already "
|
|
27
|
+
"in the working directory if there is one (the previous default)."
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Embedding Settings
|
|
32
|
+
embedding_model: str = Field(
|
|
33
|
+
default="all-MiniLM-L6-v2",
|
|
34
|
+
description="SentenceTransformers model name for dense code embeddings",
|
|
35
|
+
)
|
|
36
|
+
embedding_device: str = Field(
|
|
37
|
+
default="cpu",
|
|
38
|
+
description="Inference compute device for embedding model (cpu, cuda, mps)",
|
|
39
|
+
)
|
|
40
|
+
embedding_dimension: int = Field(
|
|
41
|
+
default=384,
|
|
42
|
+
description="Dense vector embedding dimension",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Chunker Settings
|
|
46
|
+
chunk_max_tokens: int = Field(
|
|
47
|
+
default=300,
|
|
48
|
+
description="Approximate max tokens per code chunk",
|
|
49
|
+
)
|
|
50
|
+
chunk_overlap_lines: int = Field(
|
|
51
|
+
default=3,
|
|
52
|
+
description="Line overlap between sequential body chunks",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Search & Ranking Settings
|
|
56
|
+
weight_keyword: float = Field(
|
|
57
|
+
default=0.40,
|
|
58
|
+
description="Weight for BM25 keyword score in hybrid search fusion",
|
|
59
|
+
)
|
|
60
|
+
weight_vector: float = Field(
|
|
61
|
+
default=0.40,
|
|
62
|
+
description="Weight for vector similarity score in hybrid search fusion",
|
|
63
|
+
)
|
|
64
|
+
weight_graph: float = Field(
|
|
65
|
+
default=0.20,
|
|
66
|
+
description="Weight for call graph centrality score in hybrid search fusion",
|
|
67
|
+
)
|
|
68
|
+
exact_match_boost: float = Field(
|
|
69
|
+
default=0.15,
|
|
70
|
+
description="Score boost added when query exactly matches symbol name",
|
|
71
|
+
)
|
|
72
|
+
bm25_k1: float = Field(
|
|
73
|
+
default=1.5,
|
|
74
|
+
description="BM25 k1 term frequency saturation parameter",
|
|
75
|
+
)
|
|
76
|
+
bm25_b: float = Field(
|
|
77
|
+
default=0.75,
|
|
78
|
+
description="BM25 b document length normalization parameter",
|
|
79
|
+
)
|
|
80
|
+
rrf_k: int = Field(
|
|
81
|
+
default=60,
|
|
82
|
+
description=(
|
|
83
|
+
"Reciprocal Rank Fusion (RRF) smoothing constant (roadmap §37 "
|
|
84
|
+
"item 2.3: RRF fusion path wired to be selectable via API "
|
|
85
|
+
"search_type/config). Formula: RRF_score(d) = sum(1 / (k + "
|
|
86
|
+
"rank_i(d))). 60 is the conventional default used in the "
|
|
87
|
+
"original RRF paper and most implementations -- not a "
|
|
88
|
+
"benchmarked figure for this project specifically; tune via "
|
|
89
|
+
"config/env for a given workload."
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
vector_floor_rank: int = Field(
|
|
93
|
+
default=20,
|
|
94
|
+
ge=0,
|
|
95
|
+
description=(
|
|
96
|
+
"How the semantic score is scaled before hybrid fusion. The "
|
|
97
|
+
"lexical score is relative (the best keyword hit is always 1.0) "
|
|
98
|
+
"but a raw cosine similarity is absolute and sits in a narrow "
|
|
99
|
+
"band, so the two were weighted as if on one scale. The vector "
|
|
100
|
+
"score is now (sim - floor) / (top - floor), clipped to 0..1, "
|
|
101
|
+
"where top is the best similarity for the query and floor is the "
|
|
102
|
+
"similarity of the hit at this rank. It does not depend on how "
|
|
103
|
+
"many hits were fetched beyond this rank, so scores never change "
|
|
104
|
+
"with the requested limit. 0 restores the old absolute "
|
|
105
|
+
"(cosine + 1) / 2 scaling. 20 was chosen on paraphrase queries "
|
|
106
|
+
"over two codebases (FUTURE_CHANGES item 20); not a universal "
|
|
107
|
+
"constant."
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Server / API Settings
|
|
112
|
+
server_host: str = Field(
|
|
113
|
+
default="127.0.0.1",
|
|
114
|
+
description="API server bind host",
|
|
115
|
+
)
|
|
116
|
+
server_port: int = Field(
|
|
117
|
+
default=8000,
|
|
118
|
+
description="API server bind port",
|
|
119
|
+
)
|
|
120
|
+
cors_origins: list[str] = Field(
|
|
121
|
+
default_factory=list,
|
|
122
|
+
description=(
|
|
123
|
+
"Allowed CORS origin list. Empty by default (deny all cross-origin "
|
|
124
|
+
"browser requests) -- Phase 0.9 replaces the prior '*' wildcard "
|
|
125
|
+
"default, which combined with allow_credentials=True was both an "
|
|
126
|
+
"unconsidered default and, per the CORS spec, a combination "
|
|
127
|
+
"browsers reject outright for credentialed requests. Set explicit "
|
|
128
|
+
"origin(s) via codepulse.toml's [server] section or the "
|
|
129
|
+
"CODEPULSE_CORS_ORIGINS env var for deployments that need "
|
|
130
|
+
"browser-based cross-origin access (e.g. a future Web UI)."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
cors_allow_credentials: bool = Field(
|
|
134
|
+
default=False,
|
|
135
|
+
description="Whether to allow credentials in CORS headers. Defaults to False (secure by default).",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Rate Limiting (Phase 1.4)
|
|
139
|
+
rate_limit_enabled: bool = Field(
|
|
140
|
+
default=True,
|
|
141
|
+
description="Whether the in-process token-bucket rate-limiting middleware is active.",
|
|
142
|
+
)
|
|
143
|
+
rate_limit_requests_per_second: float = Field(
|
|
144
|
+
default=5.0,
|
|
145
|
+
description=(
|
|
146
|
+
"In-process token-bucket rate limiter refill rate (tokens/second) "
|
|
147
|
+
"per client identity (the raw API key header value, or the "
|
|
148
|
+
"client's IP address as a fallback for unauthenticated "
|
|
149
|
+
"requests). A reasonable starting default for local-first, "
|
|
150
|
+
"single-developer use -- NOT a benchmarked/measured figure (see "
|
|
151
|
+
"roadmap §19's own caution against presenting unvalidated "
|
|
152
|
+
"numbers as fact); tune via config/env for a given workload."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
rate_limit_burst: int = Field(
|
|
156
|
+
default=20,
|
|
157
|
+
description="Token-bucket capacity (max burst size) for the in-process rate limiter.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Ingestion Safety (Phase 1.5)
|
|
161
|
+
ingestion_allowed_roots: list[str] = Field(
|
|
162
|
+
default_factory=list,
|
|
163
|
+
description=(
|
|
164
|
+
"Allow-list of root directories local-path ingestion (POST "
|
|
165
|
+
"/v1/repositories/index) is restricted to. Empty by default -- "
|
|
166
|
+
"meaning UNRESTRICTED -- deliberately preserving the roadmap's "
|
|
167
|
+
"own local-first default experience ('codepulse index .' works "
|
|
168
|
+
"with zero configuration): this endpoint acts on behalf of the "
|
|
169
|
+
"local user who already has filesystem access to their own "
|
|
170
|
+
"machine, so there is nothing to protect against until this API "
|
|
171
|
+
"is exposed beyond localhost with a key untrusted parties might "
|
|
172
|
+
"hold (roadmap §15's stated concern for this setting). Configure "
|
|
173
|
+
"one or more roots via codepulse.toml/the "
|
|
174
|
+
"CODEPULSE_INGESTION_ALLOWED_ROOTS env var to restrict ingestion "
|
|
175
|
+
"for a server/team deployment; when set, a request path that "
|
|
176
|
+
"resolves (after following symlinks and collapsing '..') outside "
|
|
177
|
+
"every configured root is rejected."
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
ingestion_max_files_per_repo: int = Field(
|
|
181
|
+
default=50000,
|
|
182
|
+
description=(
|
|
183
|
+
"Maximum number of discoverable files a single indexing run will "
|
|
184
|
+
"accept before refusing to proceed (roadmap §15: 'max files per "
|
|
185
|
+
"repo'). A generous starting ceiling, NOT a benchmarked figure -- "
|
|
186
|
+
"tune via config for your workload."
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
ingestion_max_file_size_bytes: int = Field(
|
|
190
|
+
default=5_000_000,
|
|
191
|
+
description=(
|
|
192
|
+
"Maximum size, in bytes, of a single file that will be read and "
|
|
193
|
+
"parsed during indexing (roadmap §15: 'max file size parsed'). A "
|
|
194
|
+
"larger file is skipped (logged, not fatal to the run -- the "
|
|
195
|
+
"same per-file error-tolerance already established for "
|
|
196
|
+
"malformed/unparseable files), not truncated. A reasonable "
|
|
197
|
+
"starting default, NOT a benchmarked figure."
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Incremental Indexing (Phase 3.1)
|
|
202
|
+
incremental_indexing_enabled: bool = Field(
|
|
203
|
+
default=True,
|
|
204
|
+
description=(
|
|
205
|
+
"Whether IndexingEngine.index_directory() attempts git-diff-based "
|
|
206
|
+
"incremental change detection (roadmap §37 item 3.1) before "
|
|
207
|
+
"falling back to a full re-index. Every §9 fallback condition "
|
|
208
|
+
"(not a git repo, no prior snapshot, embedding model changed, "
|
|
209
|
+
"too large a fraction of files changed) still applies even when "
|
|
210
|
+
"this is True; setting it False unconditionally forces a full "
|
|
211
|
+
"re-index every time, matching this project's pre-Phase-3.1 "
|
|
212
|
+
"behavior exactly."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
incremental_max_changed_file_fraction: float = Field(
|
|
216
|
+
default=0.5,
|
|
217
|
+
description=(
|
|
218
|
+
"If more than this fraction of a repository's previously-"
|
|
219
|
+
"indexed files changed since the last snapshot, a full "
|
|
220
|
+
"re-index is used instead of an incremental one (roadmap §9: "
|
|
221
|
+
"'more than some threshold (e.g., 50%) of files changed at "
|
|
222
|
+
"once (rebase, mass reformat) -- in which case a full "
|
|
223
|
+
"re-index is actually cheaper than incremental bookkeeping'). "
|
|
224
|
+
"0.5 is the roadmap's own literal example figure, not an "
|
|
225
|
+
"independently benchmarked one."
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
model_config = {
|
|
230
|
+
"extra": "ignore",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@model_validator(mode="after")
|
|
234
|
+
def _validate_cors_policy(self) -> "CodePulseConfig":
|
|
235
|
+
"""
|
|
236
|
+
Permanently rejects the invalid/insecure combination of a wildcard
|
|
237
|
+
CORS origin with credentials enabled (Phase 0.9) -- browsers
|
|
238
|
+
themselves refuse to honor credentialed requests against a '*'
|
|
239
|
+
origin, so allowing this combination to be configured at all would
|
|
240
|
+
only silently produce a broken (and misleadingly permissive-looking)
|
|
241
|
+
policy. This guards the setting itself, not just its default, so a
|
|
242
|
+
future config/env override can't reintroduce it unnoticed.
|
|
243
|
+
"""
|
|
244
|
+
if self.cors_allow_credentials and "*" in self.cors_origins:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"Invalid CORS configuration: cors_allow_credentials=True cannot be "
|
|
247
|
+
"combined with a wildcard ('*') in cors_origins -- browsers reject "
|
|
248
|
+
"this combination for credentialed requests. Configure specific "
|
|
249
|
+
"origin(s) in cors_origins, or set cors_allow_credentials=False."
|
|
250
|
+
)
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _normalize_toml_dict(raw: dict[str, Any]) -> dict[str, Any]:
|
|
255
|
+
"""
|
|
256
|
+
Normalizes a parsed TOML dictionary into CodePulseConfig field keys.
|
|
257
|
+
Supports both flat keys (e.g. `db_url = "..."`) and sectioned tables
|
|
258
|
+
(e.g. `[storage] db_url = "..."`).
|
|
259
|
+
"""
|
|
260
|
+
normalized: dict[str, Any] = {}
|
|
261
|
+
|
|
262
|
+
# 1. Flat top-level keys
|
|
263
|
+
for k, v in raw.items():
|
|
264
|
+
if not isinstance(v, dict):
|
|
265
|
+
normalized[k] = v
|
|
266
|
+
|
|
267
|
+
# 2. Sectioned tables
|
|
268
|
+
storage = raw.get("storage", {})
|
|
269
|
+
if isinstance(storage, dict):
|
|
270
|
+
if "db_url" in storage:
|
|
271
|
+
normalized["db_url"] = storage["db_url"]
|
|
272
|
+
|
|
273
|
+
embedding = raw.get("embedding", {})
|
|
274
|
+
if isinstance(embedding, dict):
|
|
275
|
+
if "model" in embedding or "embedding_model" in embedding or "model_name" in embedding:
|
|
276
|
+
normalized["embedding_model"] = (
|
|
277
|
+
embedding.get("embedding_model")
|
|
278
|
+
or embedding.get("model_name")
|
|
279
|
+
or embedding.get("model")
|
|
280
|
+
)
|
|
281
|
+
if "device" in embedding or "embedding_device" in embedding:
|
|
282
|
+
normalized["embedding_device"] = embedding.get("embedding_device") or embedding.get("device")
|
|
283
|
+
if "dimension" in embedding or "embedding_dimension" in embedding:
|
|
284
|
+
normalized["embedding_dimension"] = (
|
|
285
|
+
embedding.get("embedding_dimension") or embedding.get("dimension")
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
chunker = raw.get("chunker", {})
|
|
289
|
+
if isinstance(chunker, dict):
|
|
290
|
+
if "max_tokens" in chunker or "chunk_max_tokens" in chunker:
|
|
291
|
+
normalized["chunk_max_tokens"] = chunker.get("chunk_max_tokens") or chunker.get("max_tokens")
|
|
292
|
+
if "overlap_lines" in chunker or "chunk_overlap_lines" in chunker:
|
|
293
|
+
normalized["chunk_overlap_lines"] = (
|
|
294
|
+
chunker.get("chunk_overlap_lines") or chunker.get("overlap_lines")
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
search = raw.get("search", {})
|
|
298
|
+
if isinstance(search, dict):
|
|
299
|
+
for key in (
|
|
300
|
+
"weight_keyword",
|
|
301
|
+
"weight_vector",
|
|
302
|
+
"weight_graph",
|
|
303
|
+
"exact_match_boost",
|
|
304
|
+
"bm25_k1",
|
|
305
|
+
"bm25_b",
|
|
306
|
+
"rrf_k",
|
|
307
|
+
"vector_floor_rank",
|
|
308
|
+
):
|
|
309
|
+
if key in search:
|
|
310
|
+
normalized[key] = search[key]
|
|
311
|
+
|
|
312
|
+
server = raw.get("server", {})
|
|
313
|
+
if isinstance(server, dict):
|
|
314
|
+
if "host" in server or "server_host" in server:
|
|
315
|
+
normalized["server_host"] = server.get("server_host") or server.get("host")
|
|
316
|
+
if "port" in server or "server_port" in server:
|
|
317
|
+
normalized["server_port"] = server.get("server_port") or server.get("port")
|
|
318
|
+
if "cors_origins" in server:
|
|
319
|
+
normalized["cors_origins"] = server["cors_origins"]
|
|
320
|
+
if "cors_allow_credentials" in server:
|
|
321
|
+
normalized["cors_allow_credentials"] = server["cors_allow_credentials"]
|
|
322
|
+
|
|
323
|
+
return normalized
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# Mapping of environment variables to CodePulseConfig field names
|
|
327
|
+
_ENV_VAR_MAPPINGS: dict[str, str] = {
|
|
328
|
+
"CODEPULSE_DB_URL": "db_url",
|
|
329
|
+
"CODEPULSE_EMBEDDING_MODEL": "embedding_model",
|
|
330
|
+
"CODEPULSE_MODEL_NAME": "embedding_model",
|
|
331
|
+
"CODEPULSE_EMBEDDING_DEVICE": "embedding_device",
|
|
332
|
+
"CODEPULSE_DEVICE": "embedding_device",
|
|
333
|
+
"CODEPULSE_EMBEDDING_DIMENSION": "embedding_dimension",
|
|
334
|
+
"CODEPULSE_CHUNK_MAX_TOKENS": "chunk_max_tokens",
|
|
335
|
+
"CODEPULSE_CHUNK_OVERLAP_LINES": "chunk_overlap_lines",
|
|
336
|
+
"CODEPULSE_WEIGHT_KEYWORD": "weight_keyword",
|
|
337
|
+
"CODEPULSE_WEIGHT_VECTOR": "weight_vector",
|
|
338
|
+
"CODEPULSE_WEIGHT_GRAPH": "weight_graph",
|
|
339
|
+
"CODEPULSE_EXACT_MATCH_BOOST": "exact_match_boost",
|
|
340
|
+
"CODEPULSE_BM25_K1": "bm25_k1",
|
|
341
|
+
"CODEPULSE_BM25_B": "bm25_b",
|
|
342
|
+
"CODEPULSE_RRF_K": "rrf_k",
|
|
343
|
+
"CODEPULSE_VECTOR_FLOOR_RANK": "vector_floor_rank",
|
|
344
|
+
"CODEPULSE_SERVER_HOST": "server_host",
|
|
345
|
+
"CODEPULSE_HOST": "server_host",
|
|
346
|
+
"CODEPULSE_SERVER_PORT": "server_port",
|
|
347
|
+
"CODEPULSE_PORT": "server_port",
|
|
348
|
+
"CODEPULSE_CORS_ORIGINS": "cors_origins",
|
|
349
|
+
"CODEPULSE_CORS_ALLOW_CREDENTIALS": "cors_allow_credentials",
|
|
350
|
+
"CODEPULSE_RATE_LIMIT_ENABLED": "rate_limit_enabled",
|
|
351
|
+
"CODEPULSE_RATE_LIMIT_REQUESTS_PER_SECOND": "rate_limit_requests_per_second",
|
|
352
|
+
"CODEPULSE_RATE_LIMIT_BURST": "rate_limit_burst",
|
|
353
|
+
"CODEPULSE_INGESTION_ALLOWED_ROOTS": "ingestion_allowed_roots",
|
|
354
|
+
"CODEPULSE_INGESTION_MAX_FILES_PER_REPO": "ingestion_max_files_per_repo",
|
|
355
|
+
"CODEPULSE_INGESTION_MAX_FILE_SIZE_BYTES": "ingestion_max_file_size_bytes",
|
|
356
|
+
"CODEPULSE_INCREMENTAL_INDEXING_ENABLED": "incremental_indexing_enabled",
|
|
357
|
+
"CODEPULSE_INCREMENTAL_MAX_CHANGED_FILE_FRACTION": "incremental_max_changed_file_fraction",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _coerce_env_val(field_name: str, raw_val: str) -> Any:
|
|
362
|
+
"""Coerces a string environment variable value into its target field type."""
|
|
363
|
+
field_info = CodePulseConfig.model_fields.get(field_name)
|
|
364
|
+
if not field_info:
|
|
365
|
+
return raw_val
|
|
366
|
+
|
|
367
|
+
annotation = field_info.annotation
|
|
368
|
+
|
|
369
|
+
if annotation is int:
|
|
370
|
+
return int(raw_val)
|
|
371
|
+
elif annotation is float:
|
|
372
|
+
return float(raw_val)
|
|
373
|
+
elif annotation is bool:
|
|
374
|
+
return raw_val.strip().lower() in ("true", "1", "yes", "on")
|
|
375
|
+
elif annotation is list[str] or getattr(annotation, "__origin__", None) is list:
|
|
376
|
+
val_str = raw_val.strip()
|
|
377
|
+
if val_str.startswith("[") and val_str.endswith("]"):
|
|
378
|
+
try:
|
|
379
|
+
return json.loads(val_str)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
return [item.strip() for item in val_str.split(",") if item.strip()]
|
|
383
|
+
|
|
384
|
+
return raw_val
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _load_env_overrides() -> dict[str, Any]:
|
|
388
|
+
"""Extracts all matching CODEPULSE_* environment variables."""
|
|
389
|
+
overrides: dict[str, Any] = {}
|
|
390
|
+
for env_key, field_name in _ENV_VAR_MAPPINGS.items():
|
|
391
|
+
if env_key in os.environ:
|
|
392
|
+
val = os.environ[env_key]
|
|
393
|
+
overrides[field_name] = _coerce_env_val(field_name, val)
|
|
394
|
+
return overrides
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def load_config(
|
|
398
|
+
config_file: str | Path | None = None,
|
|
399
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
400
|
+
) -> CodePulseConfig:
|
|
401
|
+
"""
|
|
402
|
+
Loads CodePulse configuration following the strict precedence rule:
|
|
403
|
+
|
|
404
|
+
CLI flags > Environment variables > codepulse.toml > Built-in defaults
|
|
405
|
+
"""
|
|
406
|
+
# 1. Start with empty dict (will use model defaults)
|
|
407
|
+
merged: dict[str, Any] = {}
|
|
408
|
+
|
|
409
|
+
# 2. Locate and load codepulse.toml if present
|
|
410
|
+
toml_path: Optional[Path] = None
|
|
411
|
+
if config_file:
|
|
412
|
+
p = Path(config_file)
|
|
413
|
+
if p.is_file():
|
|
414
|
+
toml_path = p
|
|
415
|
+
elif "CODEPULSE_CONFIG" in os.environ:
|
|
416
|
+
p = Path(os.environ["CODEPULSE_CONFIG"])
|
|
417
|
+
if p.is_file():
|
|
418
|
+
toml_path = p
|
|
419
|
+
else:
|
|
420
|
+
# Check standard default locations
|
|
421
|
+
cwd_toml = Path("codepulse.toml")
|
|
422
|
+
if cwd_toml.is_file():
|
|
423
|
+
toml_path = cwd_toml
|
|
424
|
+
|
|
425
|
+
if toml_path and toml_path.is_file():
|
|
426
|
+
try:
|
|
427
|
+
with open(toml_path, "rb") as f:
|
|
428
|
+
raw_toml = tomllib.load(f)
|
|
429
|
+
toml_data = _normalize_toml_dict(raw_toml)
|
|
430
|
+
merged.update(toml_data)
|
|
431
|
+
except Exception as e:
|
|
432
|
+
import logging
|
|
433
|
+
logging.getLogger(__name__).warning("Failed to parse TOML config at %s: %s", toml_path, e)
|
|
434
|
+
|
|
435
|
+
# 3. Environment variable overrides (higher precedence than TOML)
|
|
436
|
+
env_overrides = _load_env_overrides()
|
|
437
|
+
merged.update(env_overrides)
|
|
438
|
+
|
|
439
|
+
# 4. CLI overrides (highest precedence)
|
|
440
|
+
if cli_overrides:
|
|
441
|
+
for k, v in cli_overrides.items():
|
|
442
|
+
if v is not None:
|
|
443
|
+
merged[k] = v
|
|
444
|
+
|
|
445
|
+
return CodePulseConfig(**merged)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
_GLOBAL_SETTINGS: Optional[CodePulseConfig] = None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def get_settings(
|
|
452
|
+
config_file: str | Path | None = None,
|
|
453
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
454
|
+
reload: bool = False,
|
|
455
|
+
) -> CodePulseConfig:
|
|
456
|
+
"""
|
|
457
|
+
Returns the active CodePulse configuration singleton.
|
|
458
|
+
If `reload=True` or explicit overrides are provided, reloads the configuration.
|
|
459
|
+
"""
|
|
460
|
+
global _GLOBAL_SETTINGS
|
|
461
|
+
if _GLOBAL_SETTINGS is None or reload or config_file is not None or cli_overrides is not None:
|
|
462
|
+
_GLOBAL_SETTINGS = load_config(config_file=config_file, cli_overrides=cli_overrides)
|
|
463
|
+
return _GLOBAL_SETTINGS
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def set_settings(config: CodePulseConfig) -> None:
|
|
467
|
+
"""Explicitly sets the global configuration instance (primarily for testing)."""
|
|
468
|
+
global _GLOBAL_SETTINGS
|
|
469
|
+
_GLOBAL_SETTINGS = config
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def reset_settings() -> None:
|
|
473
|
+
"""Resets the global configuration instance to uninitialized state."""
|
|
474
|
+
global _GLOBAL_SETTINGS
|
|
475
|
+
_GLOBAL_SETTINGS = None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Where CodePulse keeps its database by default.
|
|
3
|
+
|
|
4
|
+
The default used to be `sqlite:///codepulse.db`, resolved against the CURRENT WORKING DIRECTORY. Anything that starts
|
|
5
|
+
the server or the CLI from inside a project (the VS Code extension does exactly that) therefore dropped a
|
|
6
|
+
`codepulse.db` into that project, one database per folder you ever ran it in, and a CLI run from another folder used
|
|
7
|
+
yet another. Now there is ONE database per user, in the operating system's per-user data directory, with two guards:
|
|
8
|
+
|
|
9
|
+
- an explicit setting always wins (CLI flag, CODEPULSE_DB_URL, codepulse.toml: unchanged precedence);
|
|
10
|
+
- if the working directory already holds a `codepulse.db`, that one is kept (the previous default), so an existing
|
|
11
|
+
setup keeps working exactly as before and nobody's data is silently orphaned.
|
|
12
|
+
|
|
13
|
+
`CODEPULSE_DATA_DIR` overrides the directory (containers, tests, portable installs).
|
|
14
|
+
|
|
15
|
+
Pure functions (platform, environment, home and working directory are injectable) with no side effects, except
|
|
16
|
+
`ensure_database_directory`, which creates ONLY the per-user data directory, never a directory the user typed.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Mapping
|
|
24
|
+
|
|
25
|
+
DATABASE_FILENAME = "codepulse.db"
|
|
26
|
+
LEGACY_DATABASE_URL = f"sqlite:///{DATABASE_FILENAME}"
|
|
27
|
+
_SQLITE_PREFIX = "sqlite:///"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def user_data_dir(
|
|
31
|
+
*, platform: str | None = None, env: Mapping[str, str] | None = None, home: Path | None = None
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""The per-user directory CodePulse keeps its data in (not created here)."""
|
|
34
|
+
env = os.environ if env is None else env
|
|
35
|
+
platform = sys.platform if platform is None else platform
|
|
36
|
+
home = Path.home() if home is None else home
|
|
37
|
+
|
|
38
|
+
explicit = env.get("CODEPULSE_DATA_DIR")
|
|
39
|
+
if explicit:
|
|
40
|
+
return Path(explicit)
|
|
41
|
+
if platform == "win32":
|
|
42
|
+
base = Path(env["LOCALAPPDATA"]) if env.get("LOCALAPPDATA") else home / "AppData" / "Local"
|
|
43
|
+
return base / "CodePulse"
|
|
44
|
+
if platform == "darwin":
|
|
45
|
+
return home / "Library" / "Application Support" / "CodePulse"
|
|
46
|
+
base = Path(env["XDG_DATA_HOME"]) if env.get("XDG_DATA_HOME") else home / ".local" / "share"
|
|
47
|
+
return base / "codepulse"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_database_url(
|
|
51
|
+
*,
|
|
52
|
+
cwd: Path | None = None,
|
|
53
|
+
platform: str | None = None,
|
|
54
|
+
env: Mapping[str, str] | None = None,
|
|
55
|
+
home: Path | None = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""The database URL used when nothing was configured (see the module docstring)."""
|
|
58
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
59
|
+
if (cwd / DATABASE_FILENAME).is_file():
|
|
60
|
+
return LEGACY_DATABASE_URL
|
|
61
|
+
return _SQLITE_PREFIX + (user_data_dir(platform=platform, env=env, home=home) / DATABASE_FILENAME).as_posix()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ensure_database_directory(db_url: str) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Creates the per-user data directory if `db_url` is a SQLite file inside it and it does not exist yet.
|
|
67
|
+
Any other missing directory is left alone: a path the user typed that does not exist is still an error.
|
|
68
|
+
"""
|
|
69
|
+
if not db_url.startswith(_SQLITE_PREFIX):
|
|
70
|
+
return
|
|
71
|
+
raw = db_url[len(_SQLITE_PREFIX):].split("?", 1)[0]
|
|
72
|
+
if raw in ("", ":memory:"):
|
|
73
|
+
return
|
|
74
|
+
parent = Path(os.path.abspath(raw)).parent
|
|
75
|
+
if parent.exists():
|
|
76
|
+
return
|
|
77
|
+
if os.path.normcase(os.path.abspath(parent)) == os.path.normcase(os.path.abspath(user_data_dir())):
|
|
78
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
from codepulse.models import Symbol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CodeChunk:
|
|
8
|
+
"""Represents a chunk of code prepared for embedding."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
symbol_name: str,
|
|
13
|
+
file_path: str,
|
|
14
|
+
chunk_type: str, # 'signature_doc', 'body_chunk', 'full'
|
|
15
|
+
content: str,
|
|
16
|
+
start_line: int,
|
|
17
|
+
end_line: int,
|
|
18
|
+
chunk_index: int = 0,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.symbol_name = symbol_name
|
|
21
|
+
self.file_path = file_path
|
|
22
|
+
self.chunk_type = chunk_type
|
|
23
|
+
self.content = content
|
|
24
|
+
self.start_line = start_line
|
|
25
|
+
self.end_line = end_line
|
|
26
|
+
self.chunk_index = chunk_index
|
|
27
|
+
|
|
28
|
+
def __repr__(self) -> str:
|
|
29
|
+
return f"<CodeChunk {self.symbol_name} ({self.chunk_type}) lines={self.start_line}-{self.end_line}>"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SemanticChunker:
|
|
33
|
+
"""
|
|
34
|
+
AST-aware semantic chunker for code symbols.
|
|
35
|
+
|
|
36
|
+
Splits symbols based on logical boundaries (signature + docstring vs body chunks)
|
|
37
|
+
rather than naive arbitrary character/token splits.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
max_tokens: int | None = None,
|
|
43
|
+
chunk_overlap_lines: int | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
from codepulse.config import get_settings
|
|
46
|
+
settings = get_settings()
|
|
47
|
+
self.max_tokens = max_tokens if max_tokens is not None else settings.chunk_max_tokens
|
|
48
|
+
self.chunk_overlap_lines = (
|
|
49
|
+
chunk_overlap_lines if chunk_overlap_lines is not None else settings.chunk_overlap_lines
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def chunk_symbol(self, symbol: Symbol) -> list[CodeChunk]:
|
|
53
|
+
"""
|
|
54
|
+
Creates semantic chunks for an extracted code Symbol.
|
|
55
|
+
|
|
56
|
+
Strategy:
|
|
57
|
+
1. Always create a high-signal header chunk: Signature + Docstring.
|
|
58
|
+
2. If the body is short (< max_tokens roughly ~ 1200 chars), create a 'full' chunk.
|
|
59
|
+
3. If the body is large, create overlapping window chunks of the body.
|
|
60
|
+
"""
|
|
61
|
+
chunks: list[CodeChunk] = []
|
|
62
|
+
|
|
63
|
+
# 1. Signature + Docstring Chunk (highest semantic signal for query matching)
|
|
64
|
+
header_parts = []
|
|
65
|
+
if symbol.signature:
|
|
66
|
+
header_parts.append(symbol.signature)
|
|
67
|
+
if symbol.docstring:
|
|
68
|
+
header_parts.append(f'"""\n{symbol.docstring}\n"""')
|
|
69
|
+
|
|
70
|
+
header_content = "\n".join(header_parts).strip()
|
|
71
|
+
if header_content:
|
|
72
|
+
chunks.append(
|
|
73
|
+
CodeChunk(
|
|
74
|
+
symbol_name=symbol.name,
|
|
75
|
+
file_path=symbol.file_path,
|
|
76
|
+
chunk_type="signature_doc",
|
|
77
|
+
content=header_content,
|
|
78
|
+
start_line=symbol.start_line,
|
|
79
|
+
end_line=symbol.start_line + len(header_content.splitlines()),
|
|
80
|
+
chunk_index=0,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# If there is no body or body is very short, we are done
|
|
85
|
+
body_text = (symbol.body or "").strip()
|
|
86
|
+
if not body_text:
|
|
87
|
+
return chunks
|
|
88
|
+
|
|
89
|
+
# Estimate tokens (~4 chars per token for code)
|
|
90
|
+
estimated_tokens = len(body_text) // 4
|
|
91
|
+
|
|
92
|
+
if estimated_tokens <= self.max_tokens:
|
|
93
|
+
# Short body -> Single full chunk
|
|
94
|
+
chunks.append(
|
|
95
|
+
CodeChunk(
|
|
96
|
+
symbol_name=symbol.name,
|
|
97
|
+
file_path=symbol.file_path,
|
|
98
|
+
chunk_type="full",
|
|
99
|
+
content=body_text,
|
|
100
|
+
start_line=symbol.start_line,
|
|
101
|
+
end_line=symbol.end_line,
|
|
102
|
+
chunk_index=1 if chunks else 0,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
# Large body -> Break into overlapping line windows with context prefix
|
|
107
|
+
lines = body_text.splitlines()
|
|
108
|
+
# Approx 25 lines per chunk
|
|
109
|
+
lines_per_chunk = 25
|
|
110
|
+
stride = max(5, lines_per_chunk - self.chunk_overlap_lines)
|
|
111
|
+
|
|
112
|
+
chunk_idx = len(chunks)
|
|
113
|
+
for i in range(0, len(lines), stride):
|
|
114
|
+
chunk_lines = lines[i : i + lines_per_chunk]
|
|
115
|
+
if not chunk_lines:
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
chunk_content = f"{symbol.signature}\n" + "\n".join(chunk_lines)
|
|
119
|
+
actual_start = symbol.start_line + i
|
|
120
|
+
actual_end = min(symbol.end_line, symbol.start_line + i + len(chunk_lines))
|
|
121
|
+
|
|
122
|
+
chunks.append(
|
|
123
|
+
CodeChunk(
|
|
124
|
+
symbol_name=symbol.name,
|
|
125
|
+
file_path=symbol.file_path,
|
|
126
|
+
chunk_type="body_chunk",
|
|
127
|
+
content=chunk_content,
|
|
128
|
+
start_line=actual_start,
|
|
129
|
+
end_line=actual_end,
|
|
130
|
+
chunk_index=chunk_idx,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
chunk_idx += 1
|
|
134
|
+
|
|
135
|
+
if i + lines_per_chunk >= len(lines):
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
return chunks
|