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/api/app.py
ADDED
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, BackgroundTasks, Request
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from codepulse.api.errors import _record_api_error, error_json_response, install_error_handlers
|
|
14
|
+
from codepulse.api.security import AuthorizedKey, require_api_key
|
|
15
|
+
from codepulse.auth import (
|
|
16
|
+
ROLE_RANK,
|
|
17
|
+
create_api_key,
|
|
18
|
+
create_repository_owned_by,
|
|
19
|
+
get_api_key_repository_ids,
|
|
20
|
+
list_api_keys_for_repositories,
|
|
21
|
+
revoke_api_key,
|
|
22
|
+
)
|
|
23
|
+
from codepulse.config import get_settings
|
|
24
|
+
from codepulse.embeddings.embedder import EmbeddingUnavailableError
|
|
25
|
+
from codepulse.indexer.engine import IndexingEngine
|
|
26
|
+
from codepulse.ingestion_safety import IngestionSafetyError, validate_git_url, validate_local_path
|
|
27
|
+
from codepulse.repository_identity import canonical_repository_url, find_repository
|
|
28
|
+
from codepulse.jobs import create_job, get_active_job, get_job, mark_failed, mark_running, mark_succeeded
|
|
29
|
+
from codepulse.rate_limit import get_limiter
|
|
30
|
+
from codepulse.storage.db import (
|
|
31
|
+
CallEdge,
|
|
32
|
+
FileModel,
|
|
33
|
+
RepositoryModel,
|
|
34
|
+
StorageManager,
|
|
35
|
+
SymbolModel,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
settings = get_settings()
|
|
41
|
+
|
|
42
|
+
app = FastAPI(
|
|
43
|
+
title="CodePulse API",
|
|
44
|
+
description="Production-grade Semantic Codebase Intelligence Engine",
|
|
45
|
+
version="1.0.0",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Phase 1.6 / roadmap §21: "Errors follow a single shape:
|
|
49
|
+
# {"error": {"code", "message", "details"}}" -- registered once, here,
|
|
50
|
+
# so every HTTPException/validation-error/unhandled-exception response
|
|
51
|
+
# across every endpoint is reshaped centrally. See
|
|
52
|
+
# codepulse/api/errors.py's module docstring for the full design.
|
|
53
|
+
install_error_handlers(app)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -------------------------------------------------------------
|
|
57
|
+
# Phase 1.4: in-process token-bucket rate limiter (roadmap §37 item 1.4;
|
|
58
|
+
# backlog B11). Registered BEFORE CORSMiddleware below so that, per
|
|
59
|
+
# Starlette's "last middleware added wraps outermost" rule, CORSMiddleware
|
|
60
|
+
# ends up as the OUTERMOST layer -- a 429 rate-limit rejection generated
|
|
61
|
+
# here still passes back out through CORSMiddleware and gets real CORS
|
|
62
|
+
# headers attached, exactly as a 401/403 auth rejection already does
|
|
63
|
+
# (raised even deeper, inside a route's own dependency). See
|
|
64
|
+
# codepulse/rate_limit.py's module docstring for the full design
|
|
65
|
+
# rationale (identity = raw API key header or client IP, in-process only,
|
|
66
|
+
# no Redis, not a per-role/plan differentiated limit).
|
|
67
|
+
# -------------------------------------------------------------
|
|
68
|
+
@app.middleware("http")
|
|
69
|
+
async def _rate_limit_middleware(request: Request, call_next):
|
|
70
|
+
# Reads settings fresh on every request (not the module-level
|
|
71
|
+
# `settings` captured once at import time below, which CORSMiddleware
|
|
72
|
+
# itself is stuck with by construction) so tests can reconfigure rate
|
|
73
|
+
# limits via `codepulse.config.set_settings` + `reset_limiter()`.
|
|
74
|
+
current_settings = get_settings()
|
|
75
|
+
if not current_settings.rate_limit_enabled:
|
|
76
|
+
return await call_next(request)
|
|
77
|
+
|
|
78
|
+
identity = request.headers.get("X-API-Key") or (
|
|
79
|
+
request.client.host if request.client else "unknown"
|
|
80
|
+
)
|
|
81
|
+
allowed, retry_after = get_limiter().check(identity)
|
|
82
|
+
if not allowed:
|
|
83
|
+
# Phase 1.6: built via the same shared error_json_response helper
|
|
84
|
+
# the app's global exception handlers use -- see
|
|
85
|
+
# codepulse/api/errors.py's module docstring for why this
|
|
86
|
+
# middleware can't just `raise HTTPException(...)` and rely on
|
|
87
|
+
# those handlers to catch it.
|
|
88
|
+
retry_after_seconds = math.ceil(retry_after)
|
|
89
|
+
_record_api_error(request, 429)
|
|
90
|
+
return error_json_response(
|
|
91
|
+
429,
|
|
92
|
+
"Rate limit exceeded. Try again later.",
|
|
93
|
+
{"retry_after_seconds": retry_after_seconds},
|
|
94
|
+
headers={"Retry-After": str(retry_after_seconds)},
|
|
95
|
+
)
|
|
96
|
+
return await call_next(request)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
app.add_middleware(
|
|
100
|
+
CORSMiddleware,
|
|
101
|
+
allow_origins=settings.cors_origins,
|
|
102
|
+
allow_credentials=settings.cors_allow_credentials,
|
|
103
|
+
allow_methods=["*"],
|
|
104
|
+
allow_headers=["*"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Global in-memory engine and storage instances
|
|
108
|
+
storage_manager = StorageManager()
|
|
109
|
+
indexing_engine = IndexingEngine(storage=storage_manager)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# -------------------------------------------------------------
|
|
113
|
+
# 0. Health + Metrics (roadmap §37 item 7.4; §18 Observability
|
|
114
|
+
# Architecture; §21: "Every endpoint requires an API key (§15) except
|
|
115
|
+
# `/health/*`" -- these three, plus /metrics for the same reason
|
|
116
|
+
# real Prometheus scraping is never per-repository-scoped, carry NO
|
|
117
|
+
# `Depends(require_api_key)`.
|
|
118
|
+
# -------------------------------------------------------------
|
|
119
|
+
@app.get("/health/live")
|
|
120
|
+
def health_live():
|
|
121
|
+
"""Process is up. No dependency checks -- a failure here means the process itself is unresponsive, not that some downstream is degraded."""
|
|
122
|
+
return {"status": "live"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.get("/health/ready")
|
|
126
|
+
def health_ready():
|
|
127
|
+
"""DB reachable, embedding model loaded, index storage writable -- §18's own exact three checks. A "ready" failure should stop new traffic/jobs, not restart the process, which is why this is a separate endpoint from /health/live."""
|
|
128
|
+
from sqlalchemy import text as _text
|
|
129
|
+
|
|
130
|
+
checks: dict[str, bool] = {}
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
session = storage_manager.get_session()
|
|
134
|
+
try:
|
|
135
|
+
session.execute(_text("SELECT 1"))
|
|
136
|
+
finally:
|
|
137
|
+
session.close()
|
|
138
|
+
checks["database_reachable"] = True
|
|
139
|
+
except Exception:
|
|
140
|
+
checks["database_reachable"] = False
|
|
141
|
+
|
|
142
|
+
checks["embedding_model_loaded"] = indexing_engine.embedder is not None and getattr(
|
|
143
|
+
indexing_engine.embedder, "_model", None
|
|
144
|
+
) is not None
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
db_dir = os.path.dirname(os.path.abspath(storage_manager.db_url.replace("sqlite:///", ""))) or "."
|
|
148
|
+
checks["index_storage_writable"] = os.access(db_dir, os.W_OK)
|
|
149
|
+
except Exception:
|
|
150
|
+
checks["index_storage_writable"] = False
|
|
151
|
+
|
|
152
|
+
ready = all(checks.values())
|
|
153
|
+
status_code = 200 if ready else 503
|
|
154
|
+
from fastapi.responses import JSONResponse as _JSONResponse
|
|
155
|
+
return _JSONResponse(status_code=status_code, content={"status": "ready" if ready else "not_ready", "checks": checks})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.get("/metrics")
|
|
159
|
+
def metrics():
|
|
160
|
+
"""Real, current Prometheus exposition-format metrics -- §18's own named counters/histograms/gauge, generated fresh on every scrape."""
|
|
161
|
+
from fastapi.responses import Response as _Response
|
|
162
|
+
from codepulse.observability import job_queue_depth, render_metrics
|
|
163
|
+
from codepulse.storage.db import IndexJobModel
|
|
164
|
+
|
|
165
|
+
# Refresh the gauge (a point-in-time CURRENT value, not a running
|
|
166
|
+
# total) right before serializing, from a real query -- never a
|
|
167
|
+
# cached/stale count.
|
|
168
|
+
try:
|
|
169
|
+
session = storage_manager.get_session()
|
|
170
|
+
try:
|
|
171
|
+
depth = session.query(IndexJobModel).filter(
|
|
172
|
+
IndexJobModel.status.in_(["queued", "running"])
|
|
173
|
+
).count()
|
|
174
|
+
finally:
|
|
175
|
+
session.close()
|
|
176
|
+
job_queue_depth.set(depth)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
body, content_type = render_metrics()
|
|
181
|
+
return _Response(content=body, media_type=content_type)
|
|
182
|
+
|
|
183
|
+
# Request / Response Schemas
|
|
184
|
+
class SearchRequest(BaseModel):
|
|
185
|
+
repository_id: Optional[int] = 1
|
|
186
|
+
query: str
|
|
187
|
+
limit: int = Field(default=20, ge=1, le=100)
|
|
188
|
+
# Phase 2.3 (roadmap §37 item 2.3): "rrf" added -- the already-
|
|
189
|
+
# implemented Reciprocal Rank Fusion path, now selectable here.
|
|
190
|
+
search_type: str = Field(default="hybrid", pattern="^(hybrid|keyword|semantic|rrf)$")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class IndexRepoRequest(BaseModel):
|
|
194
|
+
# Backend-hardening cleanup (not a roadmap sprint item): a
|
|
195
|
+
# `repository_id` field used to be declared here but was never read
|
|
196
|
+
# anywhere in `index_repository`'s body -- grep-confirmed dead before
|
|
197
|
+
# removal. The route always resolves the real repository id itself,
|
|
198
|
+
# via `_resolve_repo_id(repo_name, req.url)`, keyed on `url` (the
|
|
199
|
+
# same natural-key identity `IndexingEngine.index_directory` uses
|
|
200
|
+
# internally) -- a caller has never needed to (and could not
|
|
201
|
+
# meaningfully) supply this id up front.
|
|
202
|
+
source: str = "local" # "local", "github", "gitlab"
|
|
203
|
+
url: str # local path or git URL
|
|
204
|
+
branch: str = "main"
|
|
205
|
+
index_dependencies: bool = True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class SimilarCodeRequest(BaseModel):
|
|
209
|
+
limit: int = 10
|
|
210
|
+
threshold: float = 0.75
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ImpactAnalysisRequest(BaseModel):
|
|
214
|
+
depth: int = 3
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class AgentContextRequest(BaseModel):
|
|
218
|
+
# Phase 5.2 (roadmap §12/§37 item 5.2): the roadmap's own literal
|
|
219
|
+
# path is `/v1/agent/context` -- no `{symbol_id}` path placeholder
|
|
220
|
+
# -- so the target symbol is identified in the request body instead
|
|
221
|
+
# of the URL, unlike every other symbol-scoped endpoint in this API.
|
|
222
|
+
symbol_id: int
|
|
223
|
+
include_body: bool = False
|
|
224
|
+
max_related: int = 10
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class IdeSearchRequest(BaseModel):
|
|
228
|
+
repository_id: Optional[int] = 1
|
|
229
|
+
selected_text: str
|
|
230
|
+
file_context: Optional[str] = None
|
|
231
|
+
limit: int = 5
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class FeedbackRequest(BaseModel):
|
|
235
|
+
search_id: str
|
|
236
|
+
result_id: int
|
|
237
|
+
feedback: str # "relevant", "irrelevant", "perfect"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class CreateAPIKeyRequest(BaseModel):
|
|
241
|
+
name: str
|
|
242
|
+
# Empty list = an unscoped key (authenticated but authorized for no
|
|
243
|
+
# repository-scoped operation, matching Phase 1.2's own "no
|
|
244
|
+
# wildcard/superuser key" design) -- see create_key's own
|
|
245
|
+
# authorization comment for why this is the one case that doesn't
|
|
246
|
+
# require admin on anything.
|
|
247
|
+
repository_ids: list[int] = Field(default_factory=list)
|
|
248
|
+
role: str = "viewer"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# -------------------------------------------------------------
|
|
252
|
+
# 1. Search Endpoint (POST /v1/search)
|
|
253
|
+
# -------------------------------------------------------------
|
|
254
|
+
@app.post("/v1/search")
|
|
255
|
+
def search(req: SearchRequest, auth: AuthorizedKey = Depends(require_api_key)):
|
|
256
|
+
# Phase 1.2: repository_id may legitimately be omitted (None) to mean
|
|
257
|
+
# "search every repository" (an existing Phase 0.6 IndexingEngine
|
|
258
|
+
# feature) -- but that can never be authorized against a specific
|
|
259
|
+
# key's repository grants, so it's rejected here. See
|
|
260
|
+
# AuthorizedKey.authorize_search_scope's docstring.
|
|
261
|
+
auth.authorize_search_scope(req.repository_id)
|
|
262
|
+
t0 = time.time()
|
|
263
|
+
# Backend-hardening addition (not a roadmap sprint item): "hybrid"/
|
|
264
|
+
# "rrf" already gracefully degrade to keyword-only results inside
|
|
265
|
+
# IndexingEngine.search() itself if the embedding model is
|
|
266
|
+
# unavailable (see that method's own docstring). Only a pure
|
|
267
|
+
# "semantic" request has no fallback and can still raise here --
|
|
268
|
+
# caught explicitly so it surfaces as a clear, honest 503 rather
|
|
269
|
+
# than the generic, opaque 500 an uncaught exception would produce.
|
|
270
|
+
try:
|
|
271
|
+
results = indexing_engine.search(
|
|
272
|
+
req.query,
|
|
273
|
+
limit=req.limit,
|
|
274
|
+
search_type=req.search_type,
|
|
275
|
+
repository_id=req.repository_id,
|
|
276
|
+
)
|
|
277
|
+
except EmbeddingUnavailableError as e:
|
|
278
|
+
raise HTTPException(
|
|
279
|
+
status_code=503,
|
|
280
|
+
detail=(
|
|
281
|
+
f"The embedding model is currently unavailable, so a "
|
|
282
|
+
f"search_type='{req.search_type}' request cannot be "
|
|
283
|
+
f"completed as asked: {e}. Try search_type='keyword' instead."
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
elapsed = time.time() - t0
|
|
287
|
+
exec_time_ms = int(elapsed * 1000)
|
|
288
|
+
|
|
289
|
+
# Phase 7.4 (roadmap §37 item 7.4; §18: "search_requests_total
|
|
290
|
+
# {search_type}", "search_latency_seconds", "search_zero_result_total").
|
|
291
|
+
from codepulse.observability import search_latency_seconds, search_requests_total, search_zero_result_total
|
|
292
|
+
search_requests_total.labels(search_type=req.search_type).inc()
|
|
293
|
+
search_latency_seconds.observe(elapsed)
|
|
294
|
+
if not results:
|
|
295
|
+
search_zero_result_total.inc()
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"results": [r.to_dict() for r in results],
|
|
299
|
+
"execution_time_ms": exec_time_ms,
|
|
300
|
+
"query": req.query,
|
|
301
|
+
"search_type": req.search_type,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# -------------------------------------------------------------
|
|
306
|
+
# 2. Index Repository (POST /v1/repositories/index)
|
|
307
|
+
# -------------------------------------------------------------
|
|
308
|
+
@app.post("/v1/repositories/index", status_code=202)
|
|
309
|
+
def index_repository(
|
|
310
|
+
req: IndexRepoRequest, bg_tasks: BackgroundTasks, auth: AuthorizedKey = Depends(require_api_key)
|
|
311
|
+
):
|
|
312
|
+
# Phase 1.2: this endpoint creates/re-indexes a repository -- there is
|
|
313
|
+
# no *existing* repository scope to check a not-yet-indexed path
|
|
314
|
+
# against (the roadmap's repository-scoping model authorizes access to
|
|
315
|
+
# already-known repositories). So only authentication (a valid,
|
|
316
|
+
# non-revoked key) is required here; no `auth.authorize_repository(...)`
|
|
317
|
+
# call. Unchanged by Phase 1.3/1.5.
|
|
318
|
+
|
|
319
|
+
# Phase 1.5 / roadmap §15: git-URL ingestion safety (SSRF check).
|
|
320
|
+
# This codebase has no git-clone implementation at all (grep-confirmed
|
|
321
|
+
# -- see codepulse/ingestion_safety.py's module docstring for the full
|
|
322
|
+
# rationale): `req.source` has always been accepted but never actually
|
|
323
|
+
# branched on, so a non-"local" source previously fell through to
|
|
324
|
+
# silently mis-treating a git URL as a local path (indexing zero files
|
|
325
|
+
# while claiming "queued"). This milestone's job is the SSRF check
|
|
326
|
+
# itself -- a real, independently-tested safety primitive -- not
|
|
327
|
+
# inventing git-URL fetching, which no sprint item assigns to any
|
|
328
|
+
# milestone. So an unsafe URL is rejected here (400) even though the
|
|
329
|
+
# feature isn't implemented; a *safe* URL still gets an honest 501
|
|
330
|
+
# rather than being silently mishandled.
|
|
331
|
+
if req.source != "local":
|
|
332
|
+
try:
|
|
333
|
+
validate_git_url(req.url)
|
|
334
|
+
except IngestionSafetyError as e:
|
|
335
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
336
|
+
raise HTTPException(
|
|
337
|
+
status_code=501,
|
|
338
|
+
detail=(
|
|
339
|
+
f"Ingestion source '{req.source}' is not implemented; only "
|
|
340
|
+
f"source='local' is currently supported."
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Phase 7.2 (roadmap §37 item 7.2; §15: "indexer (viewer + trigger
|
|
345
|
+
# indexing)"): if `req.url` already names a KNOWN repository, this
|
|
346
|
+
# call is a RE-index of something a key may already be scoped to at
|
|
347
|
+
# only "viewer" -- require at least "indexer" role for that
|
|
348
|
+
# specific repository.
|
|
349
|
+
#
|
|
350
|
+
# 2026-10-14 (FUTURE_CHANGES item 18, user-approved owner semantics): a
|
|
351
|
+
# genuinely NEW repository used to be open to ANY authenticated key, which
|
|
352
|
+
# then could not poll its own job or ever see the repository (the VS Code
|
|
353
|
+
# extension's Index Workspace on a new folder). Now creating one requires
|
|
354
|
+
# the `indexer` role (or higher) on at least one EXISTING repository, and
|
|
355
|
+
# the creating key is made `admin` of it (below).
|
|
356
|
+
#
|
|
357
|
+
# All of this is decided BEFORE the path is validated, so a key that may
|
|
358
|
+
# not index this repository cannot use the 400-vs-403 difference to probe
|
|
359
|
+
# which paths exist on the host.
|
|
360
|
+
_session = storage_manager.get_session()
|
|
361
|
+
try:
|
|
362
|
+
_existing_repo = find_repository(_session, req.url)
|
|
363
|
+
_existing_repo_id = _existing_repo.id if _existing_repo else None
|
|
364
|
+
finally:
|
|
365
|
+
_session.close()
|
|
366
|
+
if _existing_repo_id is not None:
|
|
367
|
+
auth.authorize_role(_existing_repo_id, "indexer")
|
|
368
|
+
elif not any(ROLE_RANK.get(role, -1) >= ROLE_RANK["indexer"] for role in auth.repository_roles.values()):
|
|
369
|
+
raise HTTPException(
|
|
370
|
+
status_code=403,
|
|
371
|
+
detail=(
|
|
372
|
+
"Creating a new repository requires the 'indexer' role (or higher) on at "
|
|
373
|
+
"least one existing repository."
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Phase 1.5 / roadmap §15: local-path ingestion safety -- allow-list
|
|
378
|
+
# containment + `..`-traversal/symlink-escape rejection. See
|
|
379
|
+
# codepulse.ingestion_safety.validate_local_path's docstring; an empty
|
|
380
|
+
# (default) ingestion_allowed_roots means unrestricted, preserving the
|
|
381
|
+
# local-first zero-configuration experience.
|
|
382
|
+
try:
|
|
383
|
+
resolved_path = validate_local_path(req.url, get_settings().ingestion_allowed_roots)
|
|
384
|
+
except IngestionSafetyError as e:
|
|
385
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
386
|
+
|
|
387
|
+
repo_name = os.path.basename(req.url.rstrip("/\\")) or os.path.basename(req.url)
|
|
388
|
+
# One identity per repository however its path is spelled (FUTURE_CHANGES
|
|
389
|
+
# item 2): the lookup above finds an existing repository under any spelling,
|
|
390
|
+
# and a new one is stored -- and indexed -- under the canonical form.
|
|
391
|
+
canonical_url = canonical_repository_url(req.url)
|
|
392
|
+
# Phase 1.3: resolve (or create) the real repository id up front, so the
|
|
393
|
+
# new IndexJob row can be linked to a real repository id and the
|
|
394
|
+
# one-active-job-per-repository check below has something to check
|
|
395
|
+
# against, before indexing itself has actually run. Deliberately still
|
|
396
|
+
# keyed on req.url (not resolved_path) -- unchanged natural-key identity
|
|
397
|
+
# from Phase 1.3; only the actual filesystem walk below uses the
|
|
398
|
+
# safety-verified, canonicalized resolved_path.
|
|
399
|
+
if _existing_repo_id is not None:
|
|
400
|
+
repo_id = _existing_repo_id
|
|
401
|
+
else:
|
|
402
|
+
# The row and the creator's admin grant are written in one
|
|
403
|
+
# transaction, and only the call that actually creates the
|
|
404
|
+
# repository becomes its owner.
|
|
405
|
+
repo_id, created = create_repository_owned_by(storage_manager, repo_name, canonical_url, auth.api_key_id)
|
|
406
|
+
if not created:
|
|
407
|
+
# Another request created this url first: no grant here; apply
|
|
408
|
+
# the normal existing-repository role check.
|
|
409
|
+
auth.authorize_role(repo_id, "indexer")
|
|
410
|
+
|
|
411
|
+
# Phase 1.3 / roadmap §8: "one active job per repository (advisory
|
|
412
|
+
# lock on repo_id) to avoid concurrent conflicting writes." See
|
|
413
|
+
# codepulse.jobs.get_active_job's docstring for why this is an
|
|
414
|
+
# application-level check, not a hard DB constraint.
|
|
415
|
+
active = get_active_job(storage_manager, repo_id)
|
|
416
|
+
if active is not None:
|
|
417
|
+
raise HTTPException(
|
|
418
|
+
status_code=409,
|
|
419
|
+
detail=(
|
|
420
|
+
f"Repository {repo_id} already has an active indexing job "
|
|
421
|
+
f"(job_{active.id}, status={active.status})"
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
job = create_job(storage_manager, repo_id)
|
|
426
|
+
|
|
427
|
+
def run_indexing():
|
|
428
|
+
# Phase 1.3: drives the job through the roadmap §8 state machine
|
|
429
|
+
# (queued [already set by create_job above] -> running ->
|
|
430
|
+
# succeeded|failed) around the actual index_directory() call.
|
|
431
|
+
# IndexingEngine itself has no knowledge of jobs -- this
|
|
432
|
+
# orchestration lives entirely here, at the API boundary (see
|
|
433
|
+
# codepulse/jobs.py's module docstring).
|
|
434
|
+
mark_running(storage_manager, job.id)
|
|
435
|
+
# Phase 7.4 (roadmap §37 item 7.4; §18: "indexing_duration_seconds",
|
|
436
|
+
# "files_indexed_total", "symbols_indexed_total").
|
|
437
|
+
from codepulse.observability import files_indexed_total, indexing_duration_seconds, symbols_indexed_total
|
|
438
|
+
_t0 = time.time()
|
|
439
|
+
try:
|
|
440
|
+
result = indexing_engine.index_directory(
|
|
441
|
+
repo_path=resolved_path,
|
|
442
|
+
repo_name=repo_name,
|
|
443
|
+
repo_url=canonical_url,
|
|
444
|
+
)
|
|
445
|
+
indexing_duration_seconds.observe(time.time() - _t0)
|
|
446
|
+
files_indexed_total.inc(result.get("total_files", 0))
|
|
447
|
+
symbols_indexed_total.inc(result.get("total_symbols", 0))
|
|
448
|
+
mark_succeeded(storage_manager, job.id, result)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
# Phase 1.5: this also catches IngestionSafetyError raised
|
|
451
|
+
# inside index_directory() for the "max files per repo" cap
|
|
452
|
+
# (discoverable only after the walk, so it can't be checked
|
|
453
|
+
# synchronously up front the way the allow-list/SSRF checks
|
|
454
|
+
# above are) -- surfaced as a real "failed" job with the real
|
|
455
|
+
# error message, never a fabricated success.
|
|
456
|
+
logger.exception("Indexing job job_%s failed", job.id)
|
|
457
|
+
mark_failed(storage_manager, job.id, str(e))
|
|
458
|
+
|
|
459
|
+
bg_tasks.add_task(run_indexing)
|
|
460
|
+
|
|
461
|
+
# Phase 0.7: no `estimated_time_seconds` here -- indexing duration is
|
|
462
|
+
# not measured or predicted anywhere in the system (no timing model,
|
|
463
|
+
# no historical job data), so a fixed guess would be a fabricated
|
|
464
|
+
# metric.
|
|
465
|
+
# Phase 1.3: `job_id`/`status` are now real and backed by the IndexJob
|
|
466
|
+
# row just created above (pollable via GET /v1/jobs/{job_id} through
|
|
467
|
+
# to actual completion) -- replacing the previous fake
|
|
468
|
+
# `f"job_{int(time.time())}"` with no backing state at all.
|
|
469
|
+
return {
|
|
470
|
+
"job_id": f"job_{job.id}",
|
|
471
|
+
"repository_id": repo_id,
|
|
472
|
+
"status": job.status,
|
|
473
|
+
"url": canonical_url,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# -------------------------------------------------------------
|
|
478
|
+
# 3. Get Index Status (GET /v1/repositories/{id}/index-status)
|
|
479
|
+
# -------------------------------------------------------------
|
|
480
|
+
@app.get("/v1/repositories/{repo_id}/index-status")
|
|
481
|
+
def get_index_status(repo_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
482
|
+
auth.authorize_repository(repo_id)
|
|
483
|
+
session = storage_manager.get_session()
|
|
484
|
+
try:
|
|
485
|
+
repo = session.query(RepositoryModel).filter(RepositoryModel.id == repo_id).first()
|
|
486
|
+
# Phase 0.7: no `index_size_mb` in either branch below -- no on-disk
|
|
487
|
+
# or in-memory index-footprint measurement exists anywhere in the
|
|
488
|
+
# system, so a fixed number would be a fabricated metric with no
|
|
489
|
+
# authoritative source. `embedding_model` is kept: it's a
|
|
490
|
+
# configured value (the actual model name in use), not a metric.
|
|
491
|
+
if not repo:
|
|
492
|
+
# Fallback stats from current memory engine
|
|
493
|
+
return {
|
|
494
|
+
"repository_id": repo_id,
|
|
495
|
+
"indexed_files": len(indexing_engine.parse_results),
|
|
496
|
+
"total_files": len(indexing_engine.parse_results),
|
|
497
|
+
"indexed_symbols": len(indexing_engine.symbols_by_id),
|
|
498
|
+
"last_indexed": "live",
|
|
499
|
+
"embedding_model": indexing_engine.embedder.model_name,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
"repository_id": repo.id,
|
|
504
|
+
"indexed_files": repo.file_count,
|
|
505
|
+
"total_files": repo.file_count,
|
|
506
|
+
"indexed_symbols": repo.symbol_count,
|
|
507
|
+
"last_indexed": repo.last_sync.isoformat() if repo.last_sync else "",
|
|
508
|
+
"embedding_model": indexing_engine.embedder.model_name,
|
|
509
|
+
}
|
|
510
|
+
finally:
|
|
511
|
+
session.close()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# -------------------------------------------------------------
|
|
515
|
+
# 4. Get Symbol Details (GET /v1/symbols/{id})
|
|
516
|
+
# -------------------------------------------------------------
|
|
517
|
+
@app.get("/v1/symbols/{symbol_id}")
|
|
518
|
+
def get_symbol_details(symbol_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
519
|
+
session = storage_manager.get_session()
|
|
520
|
+
try:
|
|
521
|
+
sym = session.query(SymbolModel).filter(SymbolModel.id == symbol_id).first()
|
|
522
|
+
if not sym:
|
|
523
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
524
|
+
# Phase 1.2: a symbol is not requested by repository_id directly,
|
|
525
|
+
# but every symbol belongs to exactly one repository (via its
|
|
526
|
+
# file's non-nullable repo_id FK) -- resolve and authorize it
|
|
527
|
+
# before returning any content, per §15/§30's "every query (search,
|
|
528
|
+
# symbol lookup, graph traversal) is filtered by repository_id and
|
|
529
|
+
# the caller's permitted repository set."
|
|
530
|
+
if not sym.file:
|
|
531
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
532
|
+
auth.authorize_repository(sym.file.repo_id)
|
|
533
|
+
|
|
534
|
+
# Query call graph relations. Repository-isolation fix: scoped to
|
|
535
|
+
# `sym.file.repo_id` -- previously filtered by symbol name alone,
|
|
536
|
+
# a confirmed cross-repository data leak (a caller authorized for
|
|
537
|
+
# one repository could receive another, unrelated repository's
|
|
538
|
+
# call-graph edges whenever symbol names happened to collide).
|
|
539
|
+
# `CallEdge.repo_id` is nullable (a legacy row that predates this
|
|
540
|
+
# fix and could not be unambiguously backfilled) -- such a row
|
|
541
|
+
# simply never matches this filter, so it's invisible here rather
|
|
542
|
+
# than leaking; see CallEdge's own docstring.
|
|
543
|
+
#
|
|
544
|
+
# Phase 4.1 compatibility correction: Phase 4.1 started persisting
|
|
545
|
+
# inheritance relationships (`relationship_type="inherits"`) into
|
|
546
|
+
# this SAME `CallEdge` table. Without this filter, a base class's
|
|
547
|
+
# `called_by` (or a subclass's `calls`) would silently include its
|
|
548
|
+
# subclasses (or base classes), mislabeled as ordinary call
|
|
549
|
+
# relationships -- `called_by`/`calls` are documented, historical,
|
|
550
|
+
# calls-only fields; this endpoint's contract was never widened to
|
|
551
|
+
# include inheritance. Explicitly restricting to `"calls"` keeps
|
|
552
|
+
# this endpoint's semantics exactly what they were before Phase
|
|
553
|
+
# 4.1 introduced the second relationship type into this table.
|
|
554
|
+
called_by = (
|
|
555
|
+
session.query(CallEdge)
|
|
556
|
+
.filter(CallEdge.target_symbol_name == sym.name)
|
|
557
|
+
.filter(CallEdge.repo_id == sym.file.repo_id)
|
|
558
|
+
.filter(CallEdge.relationship_type == "calls")
|
|
559
|
+
.all()
|
|
560
|
+
)
|
|
561
|
+
calls = (
|
|
562
|
+
session.query(CallEdge)
|
|
563
|
+
.filter(CallEdge.source_symbol_name == sym.name)
|
|
564
|
+
.filter(CallEdge.repo_id == sym.file.repo_id)
|
|
565
|
+
.filter(CallEdge.relationship_type == "calls")
|
|
566
|
+
.all()
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
"symbol_id": sym.id,
|
|
571
|
+
"name": sym.name,
|
|
572
|
+
"type": sym.type,
|
|
573
|
+
"file": sym.file.path if sym.file else "",
|
|
574
|
+
"line_start": sym.start_line,
|
|
575
|
+
"line_end": sym.end_line,
|
|
576
|
+
"docstring": sym.docstring,
|
|
577
|
+
"source_code": sym.body,
|
|
578
|
+
# Phase 3.4 (roadmap §11, §42): "confidence scoring surfaced
|
|
579
|
+
# to callers, not hidden" -- `d.confidence` is `None` for any
|
|
580
|
+
# row persisted before this milestone's migration (see
|
|
581
|
+
# CallEdge's own docstring), never fabricated to something
|
|
582
|
+
# else just to fill the field.
|
|
583
|
+
"called_by": [
|
|
584
|
+
{"symbol": d.source_symbol_name, "file": d.source_file, "confidence": d.confidence}
|
|
585
|
+
for d in called_by
|
|
586
|
+
],
|
|
587
|
+
"calls": [
|
|
588
|
+
{"symbol": d.target_symbol_name, "file": d.target_file, "confidence": d.confidence}
|
|
589
|
+
for d in calls
|
|
590
|
+
],
|
|
591
|
+
"complexity": sym.complexity,
|
|
592
|
+
}
|
|
593
|
+
finally:
|
|
594
|
+
session.close()
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# -------------------------------------------------------------
|
|
598
|
+
# 5. Find Similar Code (POST /v1/symbols/{id}/similar)
|
|
599
|
+
# -------------------------------------------------------------
|
|
600
|
+
@app.post("/v1/symbols/{symbol_id}/similar")
|
|
601
|
+
def find_similar_code(
|
|
602
|
+
symbol_id: int, req: SimilarCodeRequest, auth: AuthorizedKey = Depends(require_api_key)
|
|
603
|
+
):
|
|
604
|
+
session = storage_manager.get_session()
|
|
605
|
+
try:
|
|
606
|
+
sym = session.query(SymbolModel).filter(SymbolModel.id == symbol_id).first()
|
|
607
|
+
if not sym:
|
|
608
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
609
|
+
if not sym.file:
|
|
610
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
611
|
+
auth.authorize_repository(sym.file.repo_id)
|
|
612
|
+
|
|
613
|
+
# Phase 5.1 (roadmap §12 "related_symbols"): moved into the
|
|
614
|
+
# shared `IndexingEngine.find_similar_symbols` intelligence-core
|
|
615
|
+
# method so §12's context-bundle assembler can reuse it instead
|
|
616
|
+
# of duplicating semantic-similarity logic a second time. Fixed
|
|
617
|
+
# a real, pre-existing repository-isolation gap while extracting
|
|
618
|
+
# it: the vector search here was NOT scoped to `repo_id` at all
|
|
619
|
+
# (unlike `IndexingEngine.search()`'s own "semantic" branch,
|
|
620
|
+
# which has been repo-scoped since Phase 0.6) -- a caller could
|
|
621
|
+
# receive another repository's "similar" symbols. The shared
|
|
622
|
+
# method now filters by `repo_id` exactly like `search()` does.
|
|
623
|
+
query_text = f"{sym.signature}\n{sym.docstring or ''}\n{sym.body[:500]}"
|
|
624
|
+
similar_items = indexing_engine.find_similar_symbols(
|
|
625
|
+
query_text, sym.name, sym.file.repo_id, threshold=req.threshold, limit=req.limit
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
"query_symbol": {"id": sym.id, "name": sym.name},
|
|
630
|
+
"similar": similar_items,
|
|
631
|
+
}
|
|
632
|
+
finally:
|
|
633
|
+
session.close()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# -------------------------------------------------------------
|
|
637
|
+
# 6. Analyze Impact (POST /v1/symbols/{id}/impact-analysis)
|
|
638
|
+
# -------------------------------------------------------------
|
|
639
|
+
@app.post("/v1/symbols/{symbol_id}/impact-analysis")
|
|
640
|
+
def analyze_impact(
|
|
641
|
+
symbol_id: int, req: ImpactAnalysisRequest, auth: AuthorizedKey = Depends(require_api_key)
|
|
642
|
+
):
|
|
643
|
+
session = storage_manager.get_session()
|
|
644
|
+
try:
|
|
645
|
+
sym = session.query(SymbolModel).filter(SymbolModel.id == symbol_id).first()
|
|
646
|
+
if not sym:
|
|
647
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
648
|
+
if not sym.file:
|
|
649
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
650
|
+
auth.authorize_repository(sym.file.repo_id)
|
|
651
|
+
|
|
652
|
+
# Phase 4.2 (roadmap §37 item 4.2): graph analysis moved out of
|
|
653
|
+
# this route and into the shared `IndexingEngine.analyze_impact`
|
|
654
|
+
# intelligence-core method (this milestone's own audit found the
|
|
655
|
+
# bucketing logic embedded directly in this handler, duplicated a
|
|
656
|
+
# third time in the CLI). `sym.file.repo_id` -- resolved from the
|
|
657
|
+
# REQUESTED symbol's own file, never a client-suppliable
|
|
658
|
+
# parameter -- is passed through explicitly so the shared method
|
|
659
|
+
# looks up the correctly-scoped `indexing_engine.call_graphs`
|
|
660
|
+
# (per-repo) rather than the singular, "most recently indexed"
|
|
661
|
+
# `indexing_engine.call_graph` alias (the same repository-
|
|
662
|
+
# isolation fix already applied here since the Phase 3.4 audit).
|
|
663
|
+
return indexing_engine.analyze_impact(
|
|
664
|
+
sym.name, sym.file.path if sym.file else "", sym.file.repo_id, max_depth=req.depth
|
|
665
|
+
)
|
|
666
|
+
finally:
|
|
667
|
+
session.close()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# -------------------------------------------------------------
|
|
671
|
+
# 6b. References (GET /v1/symbols/{id}/references)
|
|
672
|
+
# -------------------------------------------------------------
|
|
673
|
+
@app.get("/v1/symbols/{symbol_id}/references")
|
|
674
|
+
def get_symbol_references(symbol_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
675
|
+
session = storage_manager.get_session()
|
|
676
|
+
try:
|
|
677
|
+
sym = session.query(SymbolModel).filter(SymbolModel.id == symbol_id).first()
|
|
678
|
+
if not sym:
|
|
679
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
680
|
+
if not sym.file:
|
|
681
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
682
|
+
auth.authorize_repository(sym.file.repo_id)
|
|
683
|
+
|
|
684
|
+
# Phase 4.4 (roadmap §37 item 4.4; §21's `references` endpoint
|
|
685
|
+
# group, kept explicitly SEPARATE from `graph (callers/callees/
|
|
686
|
+
# impact)`): thin adapter over the shared
|
|
687
|
+
# `IndexingEngine.analyze_references` intelligence-core method --
|
|
688
|
+
# same repository-isolation pattern as `analyze_impact` above
|
|
689
|
+
# (`sym.file.repo_id` resolved from the requested symbol's own
|
|
690
|
+
# file, never a client-suppliable parameter).
|
|
691
|
+
return indexing_engine.analyze_references(
|
|
692
|
+
sym.name, sym.file.path if sym.file else "", sym.file.repo_id
|
|
693
|
+
)
|
|
694
|
+
finally:
|
|
695
|
+
session.close()
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# -------------------------------------------------------------
|
|
699
|
+
# 6c. Agent Context Bundle (POST /v1/agent/context)
|
|
700
|
+
# -------------------------------------------------------------
|
|
701
|
+
@app.post("/v1/agent/context")
|
|
702
|
+
def get_agent_context(req: AgentContextRequest, auth: AuthorizedKey = Depends(require_api_key)):
|
|
703
|
+
session = storage_manager.get_session()
|
|
704
|
+
try:
|
|
705
|
+
sym = session.query(SymbolModel).filter(SymbolModel.id == req.symbol_id).first()
|
|
706
|
+
if not sym:
|
|
707
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
708
|
+
if not sym.file:
|
|
709
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
710
|
+
auth.authorize_repository(sym.file.repo_id)
|
|
711
|
+
|
|
712
|
+
# Phase 5.2 (roadmap §37 item 5.2; §12 "Agent-Native Retrieval /
|
|
713
|
+
# Context API"): thin adapter over the shared
|
|
714
|
+
# `IndexingEngine.get_context_bundle` intelligence-core method
|
|
715
|
+
# (Phase 5.1) -- same repository-isolation pattern as every
|
|
716
|
+
# other symbol-scoped route (`sym.file.repo_id` resolved from
|
|
717
|
+
# the requested symbol's own file, never a client-suppliable
|
|
718
|
+
# parameter).
|
|
719
|
+
return indexing_engine.get_context_bundle(
|
|
720
|
+
sym.name,
|
|
721
|
+
sym.file.path if sym.file else "",
|
|
722
|
+
sym.file.repo_id,
|
|
723
|
+
signature=sym.signature,
|
|
724
|
+
docstring=sym.docstring,
|
|
725
|
+
body=sym.body,
|
|
726
|
+
include_body=req.include_body,
|
|
727
|
+
max_related=req.max_related,
|
|
728
|
+
)
|
|
729
|
+
finally:
|
|
730
|
+
session.close()
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
# -------------------------------------------------------------
|
|
734
|
+
# 7. Autocomplete (GET /v1/autocomplete)
|
|
735
|
+
# -------------------------------------------------------------
|
|
736
|
+
@app.get("/v1/autocomplete")
|
|
737
|
+
def autocomplete(
|
|
738
|
+
prefix: str = Query(..., min_length=1),
|
|
739
|
+
limit: int = 10,
|
|
740
|
+
auth: AuthorizedKey = Depends(require_api_key),
|
|
741
|
+
):
|
|
742
|
+
# Phase 1.2: authentication only, no required repository_id parameter
|
|
743
|
+
# -- deliberately kept as a "no repository concept" endpoint (like
|
|
744
|
+
# feedback/ingestion), not redesigned into a repository_id-scoped
|
|
745
|
+
# route the way /v1/search is. An UNSCOPED key's behavior is
|
|
746
|
+
# therefore unchanged from Phase 1.2: it still sees suggestions
|
|
747
|
+
# blended across every repository currently in memory, matching the
|
|
748
|
+
# tested convention `tests/test_api_authorization.py` documents
|
|
749
|
+
# ("endpoints with no repository concept... succeed with an
|
|
750
|
+
# unscoped key").
|
|
751
|
+
#
|
|
752
|
+
# Backend-hardening fix (not a roadmap sprint item): a key that IS
|
|
753
|
+
# scoped to specific repositories used to leak suggestions from
|
|
754
|
+
# EVERY repository regardless -- a real, previously-undisclosed
|
|
755
|
+
# cross-repository data leak (the same class of bug Phase 3.4/5.1
|
|
756
|
+
# each found and fixed for other endpoints, just never revisited
|
|
757
|
+
# here). Now filtered so a scoped key only ever sees suggestions
|
|
758
|
+
# from repositories it actually holds a grant for.
|
|
759
|
+
prefix_lower = prefix.lower()
|
|
760
|
+
matches = []
|
|
761
|
+
|
|
762
|
+
for doc_id, sym in indexing_engine.symbols_by_id.items():
|
|
763
|
+
if auth.repository_ids and indexing_engine.symbol_repo_map.get(doc_id) not in auth.repository_ids:
|
|
764
|
+
continue
|
|
765
|
+
if sym.name.lower().startswith(prefix_lower):
|
|
766
|
+
matches.append({
|
|
767
|
+
"name": sym.name,
|
|
768
|
+
"type": sym.type.value if hasattr(sym.type, "value") else str(sym.type),
|
|
769
|
+
"file": sym.file_path,
|
|
770
|
+
"line": sym.start_line,
|
|
771
|
+
"signature": sym.signature,
|
|
772
|
+
})
|
|
773
|
+
if len(matches) >= limit:
|
|
774
|
+
break
|
|
775
|
+
|
|
776
|
+
return {"suggestions": matches}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# -------------------------------------------------------------
|
|
780
|
+
# 8. Search within Function (POST /v1/symbols/{id}/search)
|
|
781
|
+
# -------------------------------------------------------------
|
|
782
|
+
@app.post("/v1/symbols/{symbol_id}/search")
|
|
783
|
+
def search_within_symbol(
|
|
784
|
+
symbol_id: int,
|
|
785
|
+
query: str = Query(...),
|
|
786
|
+
search_in: str = "all",
|
|
787
|
+
auth: AuthorizedKey = Depends(require_api_key),
|
|
788
|
+
):
|
|
789
|
+
session = storage_manager.get_session()
|
|
790
|
+
try:
|
|
791
|
+
sym = session.query(SymbolModel).filter(SymbolModel.id == symbol_id).first()
|
|
792
|
+
if not sym:
|
|
793
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
794
|
+
if not sym.file:
|
|
795
|
+
raise HTTPException(status_code=404, detail="Symbol not found")
|
|
796
|
+
auth.authorize_repository(sym.file.repo_id)
|
|
797
|
+
|
|
798
|
+
target_text = ""
|
|
799
|
+
if search_in == "docstring":
|
|
800
|
+
target_text = sym.docstring or ""
|
|
801
|
+
elif search_in == "source":
|
|
802
|
+
target_text = sym.body or ""
|
|
803
|
+
else:
|
|
804
|
+
target_text = f"{sym.signature}\n{sym.docstring or ''}\n{sym.body or ''}"
|
|
805
|
+
|
|
806
|
+
found = query.lower() in target_text.lower()
|
|
807
|
+
matches = []
|
|
808
|
+
if found:
|
|
809
|
+
for idx, line in enumerate(target_text.splitlines(), start=sym.start_line):
|
|
810
|
+
if query.lower() in line.lower():
|
|
811
|
+
matches.append({"line": idx, "snippet": line.strip()})
|
|
812
|
+
|
|
813
|
+
return {"found": found, "matches": matches}
|
|
814
|
+
finally:
|
|
815
|
+
session.close()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# -------------------------------------------------------------
|
|
819
|
+
# 9. List Symbols in File (GET /v1/files/{file_id}/symbols)
|
|
820
|
+
# -------------------------------------------------------------
|
|
821
|
+
@app.get("/v1/files/{file_id}/symbols")
|
|
822
|
+
def list_file_symbols(file_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
823
|
+
session = storage_manager.get_session()
|
|
824
|
+
try:
|
|
825
|
+
f = session.query(FileModel).filter(FileModel.id == file_id).first()
|
|
826
|
+
if not f:
|
|
827
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
828
|
+
auth.authorize_repository(f.repo_id)
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
"file_id": f.id,
|
|
832
|
+
"file_path": f.path,
|
|
833
|
+
"symbols": [
|
|
834
|
+
{
|
|
835
|
+
"symbol_id": s.id,
|
|
836
|
+
"name": s.name,
|
|
837
|
+
"type": s.type,
|
|
838
|
+
"line_start": s.start_line,
|
|
839
|
+
"line_end": s.end_line,
|
|
840
|
+
"complexity": s.complexity,
|
|
841
|
+
}
|
|
842
|
+
for s in f.symbols
|
|
843
|
+
],
|
|
844
|
+
}
|
|
845
|
+
finally:
|
|
846
|
+
session.close()
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
# -------------------------------------------------------------
|
|
850
|
+
# 10. Get Repository Stats (GET /v1/repositories/{id}/stats)
|
|
851
|
+
# -------------------------------------------------------------
|
|
852
|
+
@app.get("/v1/repositories/{repo_id}/stats")
|
|
853
|
+
def get_repository_stats(repo_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
854
|
+
auth.authorize_repository(repo_id)
|
|
855
|
+
session = storage_manager.get_session()
|
|
856
|
+
try:
|
|
857
|
+
repo = session.query(RepositoryModel).filter(RepositoryModel.id == repo_id).first()
|
|
858
|
+
file_count = repo.file_count if repo else len(indexing_engine.parse_results)
|
|
859
|
+
symbol_count = repo.symbol_count if repo else len(indexing_engine.symbols_by_id)
|
|
860
|
+
|
|
861
|
+
# Language distribution.
|
|
862
|
+
#
|
|
863
|
+
# Backend-hardening fix (not a roadmap sprint item): this used to
|
|
864
|
+
# iterate `indexing_engine.parse_results.values()` unfiltered --
|
|
865
|
+
# a real, previously-undisclosed cross-repository leak, since
|
|
866
|
+
# that dict holds every currently-indexed repository's parsed
|
|
867
|
+
# files. A caller authorized for THIS repo_id (the check above)
|
|
868
|
+
# would get a language/file/symbol breakdown blended with every
|
|
869
|
+
# OTHER repository this engine has ever parsed in-process. Now
|
|
870
|
+
# scoped by the same `"{repo_id}::"` key-prefix filter
|
|
871
|
+
# `analyze_module_structure` already established for the exact
|
|
872
|
+
# same dict.
|
|
873
|
+
prefix = f"{repo_id}::"
|
|
874
|
+
langs: dict[str, dict[str, int]] = {}
|
|
875
|
+
for key, res in indexing_engine.parse_results.items():
|
|
876
|
+
if not key.startswith(prefix):
|
|
877
|
+
continue
|
|
878
|
+
l = res.language
|
|
879
|
+
if l not in langs:
|
|
880
|
+
langs[l] = {"files": 0, "symbols": 0}
|
|
881
|
+
langs[l]["files"] += 1
|
|
882
|
+
langs[l]["symbols"] += len(res.symbols)
|
|
883
|
+
|
|
884
|
+
# Phase 0.7: no `total_queries` / `avg_search_latency_ms` here --
|
|
885
|
+
# no query log or latency-aggregation table exists anywhere in the
|
|
886
|
+
# system (SearchFeedback/query-logging is a later roadmap
|
|
887
|
+
# milestone), so fixed numbers would be fabricated metrics with no
|
|
888
|
+
# authoritative source. Building that tracking infrastructure now
|
|
889
|
+
# would be scope creep beyond removing fabricated values.
|
|
890
|
+
return {
|
|
891
|
+
"repository_id": repo_id,
|
|
892
|
+
"total_files": file_count,
|
|
893
|
+
"total_symbols": symbol_count,
|
|
894
|
+
"languages": langs,
|
|
895
|
+
}
|
|
896
|
+
finally:
|
|
897
|
+
session.close()
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# -------------------------------------------------------------
|
|
901
|
+
# 11. IDE Plugin Search (POST /v1/ide-search)
|
|
902
|
+
# -------------------------------------------------------------
|
|
903
|
+
@app.post("/v1/ide-search")
|
|
904
|
+
def ide_search(req: IdeSearchRequest, auth: AuthorizedKey = Depends(require_api_key)):
|
|
905
|
+
auth.authorize_search_scope(req.repository_id)
|
|
906
|
+
t0 = time.time()
|
|
907
|
+
results = indexing_engine.search(
|
|
908
|
+
req.selected_text,
|
|
909
|
+
limit=req.limit,
|
|
910
|
+
search_type="hybrid",
|
|
911
|
+
repository_id=req.repository_id,
|
|
912
|
+
)
|
|
913
|
+
exec_time_ms = int((time.time() - t0) * 1000)
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
"results": [
|
|
917
|
+
{
|
|
918
|
+
"symbol_name": r.symbol_name,
|
|
919
|
+
"type": r.symbol_type,
|
|
920
|
+
"file": r.file_path,
|
|
921
|
+
"line": r.start_line,
|
|
922
|
+
"relevance": r.final_score,
|
|
923
|
+
"signature": r.signature,
|
|
924
|
+
}
|
|
925
|
+
for r in results
|
|
926
|
+
],
|
|
927
|
+
"execution_time_ms": exec_time_ms,
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
# -------------------------------------------------------------
|
|
932
|
+
# 12. Feedback (POST /v1/search/feedback)
|
|
933
|
+
# -------------------------------------------------------------
|
|
934
|
+
@app.post("/v1/search/feedback")
|
|
935
|
+
def submit_feedback(req: FeedbackRequest, auth: AuthorizedKey = Depends(require_api_key)):
|
|
936
|
+
# Phase 1.2: authentication only -- this endpoint has no repository_id
|
|
937
|
+
# or resolvable-to-a-repository field (search_id/result_id are opaque),
|
|
938
|
+
# so no repository-scope check is possible or required here.
|
|
939
|
+
# Phase 0.7: this endpoint does not persist feedback anywhere (no
|
|
940
|
+
# SearchFeedback table/row is written) and does not adjust ranking
|
|
941
|
+
# weights. The previous response claimed both ("status": "recorded",
|
|
942
|
+
# "...Weights updated.") -- a fabricated success/effect claim. Real
|
|
943
|
+
# persistence is a later roadmap milestone (B28); until then, the
|
|
944
|
+
# response must not claim an effect that didn't happen.
|
|
945
|
+
return {
|
|
946
|
+
"status": "received",
|
|
947
|
+
"search_id": req.search_id,
|
|
948
|
+
"result_id": req.result_id,
|
|
949
|
+
"message": "Feedback received. Not yet persisted or used to adjust ranking.",
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _parse_job_id(raw: str) -> Optional[int]:
|
|
954
|
+
"""
|
|
955
|
+
Phase 1.3: accepts either the `"job_<id>"` form POST
|
|
956
|
+
/v1/repositories/index returns, or a bare integer, for
|
|
957
|
+
GET /v1/jobs/{job_id}. Returns None if unparseable.
|
|
958
|
+
"""
|
|
959
|
+
s = raw[len("job_"):] if raw.startswith("job_") else raw
|
|
960
|
+
try:
|
|
961
|
+
return int(s)
|
|
962
|
+
except (TypeError, ValueError):
|
|
963
|
+
return None
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
# -------------------------------------------------------------
|
|
967
|
+
# 13. Get Job Status (GET /v1/jobs/{job_id})
|
|
968
|
+
# -------------------------------------------------------------
|
|
969
|
+
@app.get("/v1/jobs/{job_id}")
|
|
970
|
+
def get_job_status(job_id: str, auth: AuthorizedKey = Depends(require_api_key)):
|
|
971
|
+
"""
|
|
972
|
+
Phase 1.3 (roadmap §37 item 1.3; §21: "jobs (new -- real job status,
|
|
973
|
+
replacing the fake job_id)"). Polls a real IndexJob row through to its
|
|
974
|
+
actual completion -- `status` is one of the roadmap §8 state machine's
|
|
975
|
+
values (`queued`, `running`, `succeeded`, `failed`; `cancelled` is a
|
|
976
|
+
valid schema value but unreachable in this milestone -- no cancel flow
|
|
977
|
+
exists yet, matching how Phase 1.1 left `revoked_at` schema-only).
|
|
978
|
+
`result` is populated only once `status == "succeeded"`, from the
|
|
979
|
+
real, measured return value of `IndexingEngine.index_directory()` --
|
|
980
|
+
never fabricated/estimated.
|
|
981
|
+
"""
|
|
982
|
+
numeric_id = _parse_job_id(job_id)
|
|
983
|
+
if numeric_id is None:
|
|
984
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
985
|
+
|
|
986
|
+
job = get_job(storage_manager, numeric_id)
|
|
987
|
+
if job is None:
|
|
988
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
989
|
+
# Phase 1.3: a job belongs to exactly one repository -- resolve and
|
|
990
|
+
# authorize it before returning any content, the same established
|
|
991
|
+
# Phase 1.2 pattern already used for symbol/file endpoints that
|
|
992
|
+
# resolve their owning repository via a FK rather than a direct
|
|
993
|
+
# repository_id parameter.
|
|
994
|
+
auth.authorize_repository(job.repo_id)
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
"job_id": f"job_{job.id}",
|
|
998
|
+
"repository_id": job.repo_id,
|
|
999
|
+
"status": job.status,
|
|
1000
|
+
"created_at": job.created_at.isoformat() if job.created_at else None,
|
|
1001
|
+
"started_at": job.started_at.isoformat() if job.started_at else None,
|
|
1002
|
+
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
|
|
1003
|
+
"error": job.error_message,
|
|
1004
|
+
"result": job.result,
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# -------------------------------------------------------------
|
|
1009
|
+
# 14. API Key Management (POST/GET /v1/admin/keys, DELETE /v1/admin/keys/{id})
|
|
1010
|
+
#
|
|
1011
|
+
# Backend-hardening addition (not a roadmap sprint item) -- gives §15's
|
|
1012
|
+
# `admin` role ("all + manage keys/repos") its first real REST surface;
|
|
1013
|
+
# before this, `create_api_key`/`revoke_api_key` were Python-function-only
|
|
1014
|
+
# operations (disclosed as a known gap since Phase 7.2). Thin wrappers
|
|
1015
|
+
# over codepulse.auth's key-management primitives, matching this API's
|
|
1016
|
+
# established "shared logic lives outside app.py" pattern.
|
|
1017
|
+
#
|
|
1018
|
+
# Authorization model, deliberately simple and symmetric (no separate
|
|
1019
|
+
# "key owner" concept exists in the schema -- adding one would need its
|
|
1020
|
+
# own migration, out of this addition's scope): creating a key scoped to
|
|
1021
|
+
# specific repositories, or revoking a key that holds any grant, both
|
|
1022
|
+
# require the caller to hold "admin" role on EVERY repository involved --
|
|
1023
|
+
# never on a subset, so one admin can never silently create/revoke access
|
|
1024
|
+
# to a repository a DIFFERENT admin, not the caller, actually administers.
|
|
1025
|
+
# An unscoped key (no repository_ids) has no specific repository to check
|
|
1026
|
+
# admin-on; for that one case, the caller must hold "admin" somewhere
|
|
1027
|
+
# (proving it is genuinely an admin-role key, not merely authenticated).
|
|
1028
|
+
# -------------------------------------------------------------
|
|
1029
|
+
def _require_admin_somewhere(auth: AuthorizedKey, action: str) -> None:
|
|
1030
|
+
if not any(role == "admin" for role in auth.repository_roles.values()):
|
|
1031
|
+
raise HTTPException(
|
|
1032
|
+
status_code=403,
|
|
1033
|
+
detail=f"{action} an unscoped API key requires admin role on at least one repository.",
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
@app.post("/v1/admin/keys", status_code=201)
|
|
1038
|
+
def create_key(req: CreateAPIKeyRequest, auth: AuthorizedKey = Depends(require_api_key)):
|
|
1039
|
+
if req.repository_ids:
|
|
1040
|
+
for repository_id in req.repository_ids:
|
|
1041
|
+
auth.authorize_role(repository_id, "admin")
|
|
1042
|
+
else:
|
|
1043
|
+
_require_admin_somewhere(auth, "Creating")
|
|
1044
|
+
|
|
1045
|
+
try:
|
|
1046
|
+
created = create_api_key(
|
|
1047
|
+
storage_manager, req.name, repository_ids=req.repository_ids or None, role=req.role
|
|
1048
|
+
)
|
|
1049
|
+
except ValueError as e:
|
|
1050
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
"id": created.id,
|
|
1054
|
+
"name": created.name,
|
|
1055
|
+
"key_prefix": created.key_prefix,
|
|
1056
|
+
"created_at": created.created_at.isoformat(),
|
|
1057
|
+
# The raw key is returned exactly once, here -- it is never
|
|
1058
|
+
# persisted and cannot be recovered by any later call (matches
|
|
1059
|
+
# codepulse.auth.create_api_key's own documented lifecycle).
|
|
1060
|
+
"api_key": created.raw_key,
|
|
1061
|
+
"repository_ids": req.repository_ids,
|
|
1062
|
+
"role": req.role,
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
@app.get("/v1/admin/keys")
|
|
1067
|
+
def list_keys(auth: AuthorizedKey = Depends(require_api_key)):
|
|
1068
|
+
admin_repository_ids = {
|
|
1069
|
+
repository_id for repository_id, role in auth.repository_roles.items() if role == "admin"
|
|
1070
|
+
}
|
|
1071
|
+
if not admin_repository_ids:
|
|
1072
|
+
raise HTTPException(
|
|
1073
|
+
status_code=403, detail="Listing API keys requires admin role on at least one repository."
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
summaries = list_api_keys_for_repositories(storage_manager, admin_repository_ids)
|
|
1077
|
+
return {
|
|
1078
|
+
"keys": [
|
|
1079
|
+
{
|
|
1080
|
+
"id": s.id,
|
|
1081
|
+
"name": s.name,
|
|
1082
|
+
"key_prefix": s.key_prefix,
|
|
1083
|
+
"created_at": s.created_at.isoformat(),
|
|
1084
|
+
"revoked_at": s.revoked_at.isoformat() if s.revoked_at else None,
|
|
1085
|
+
# Only the grants the CALLER administers are disclosed --
|
|
1086
|
+
# a key visible here because it shares one admin
|
|
1087
|
+
# repository with the caller may hold OTHER grants on
|
|
1088
|
+
# repositories the caller has no rights over; those are
|
|
1089
|
+
# never included, deliberately.
|
|
1090
|
+
"grants": [
|
|
1091
|
+
{"repository_id": g.repository_id, "role": g.role}
|
|
1092
|
+
for g in s.grants
|
|
1093
|
+
if g.repository_id in admin_repository_ids
|
|
1094
|
+
],
|
|
1095
|
+
}
|
|
1096
|
+
for s in summaries
|
|
1097
|
+
]
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@app.delete("/v1/admin/keys/{key_id}")
|
|
1102
|
+
def revoke_key(key_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
1103
|
+
key_repository_ids = get_api_key_repository_ids(storage_manager, key_id)
|
|
1104
|
+
if key_repository_ids is None:
|
|
1105
|
+
raise HTTPException(status_code=404, detail="API key not found")
|
|
1106
|
+
|
|
1107
|
+
if key_repository_ids:
|
|
1108
|
+
for repository_id in key_repository_ids:
|
|
1109
|
+
auth.authorize_role(repository_id, "admin")
|
|
1110
|
+
else:
|
|
1111
|
+
_require_admin_somewhere(auth, "Revoking")
|
|
1112
|
+
|
|
1113
|
+
summary = revoke_api_key(storage_manager, key_id)
|
|
1114
|
+
return {
|
|
1115
|
+
"id": summary.id,
|
|
1116
|
+
"revoked_at": summary.revoked_at.isoformat() if summary.revoked_at else None,
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
# -------------------------------------------------------------
|
|
1121
|
+
# 15. Module Structure (GET /v1/repositories/{repo_id}/modules)
|
|
1122
|
+
#
|
|
1123
|
+
# Backend-hardening addition (not a roadmap sprint item) -- gives Phase
|
|
1124
|
+
# 4.3's `ModuleGraphBuilder`/`IndexingEngine.analyze_module_structure`
|
|
1125
|
+
# its first query surface. That milestone's own explicit approved scope
|
|
1126
|
+
# deliberately built the engine capability with NO REST/CLI/MCP caller
|
|
1127
|
+
# ("library/shared-core only... a future one can, through this exact
|
|
1128
|
+
# method, without duplicating any of ModuleGraphBuilder's own
|
|
1129
|
+
# construction logic" -- see that method's own docstring); this is that
|
|
1130
|
+
# future caller. Thin wrapper only: no clustering/ranking logic lives
|
|
1131
|
+
# here, exactly matching every other REST route's relationship to the
|
|
1132
|
+
# shared intelligence core.
|
|
1133
|
+
# -------------------------------------------------------------
|
|
1134
|
+
@app.get("/v1/repositories/{repo_id}/modules")
|
|
1135
|
+
def get_repository_modules(repo_id: int, auth: AuthorizedKey = Depends(require_api_key)):
|
|
1136
|
+
"""
|
|
1137
|
+
Computed fresh on every call from the current in-memory parse-result
|
|
1138
|
+
snapshot (unchanged from `analyze_module_structure`'s own documented
|
|
1139
|
+
behavior -- no persistence, no incremental machinery). Returns an
|
|
1140
|
+
empty graph, not an error, for a repository with no cached parse
|
|
1141
|
+
results this process lifetime (e.g. before its first index, or
|
|
1142
|
+
after a restart) -- the same restart-behavior contract every other
|
|
1143
|
+
in-memory-graph-backed endpoint (impact-analysis, references)
|
|
1144
|
+
already has.
|
|
1145
|
+
"""
|
|
1146
|
+
auth.authorize_repository(repo_id)
|
|
1147
|
+
module_graph = indexing_engine.analyze_module_structure(repo_id)
|
|
1148
|
+
return {
|
|
1149
|
+
"repository_id": repo_id,
|
|
1150
|
+
"clusters": module_graph.clusters,
|
|
1151
|
+
# §11's own literal heuristic wording, kept as raw, unlabeled
|
|
1152
|
+
# degree data -- see ModuleGraphBuilder.rank_by_inbound/outbound's
|
|
1153
|
+
# own docstrings for why no "entry point"/"utility" label is
|
|
1154
|
+
# attached here.
|
|
1155
|
+
"ranked_by_inbound": module_graph.rank_by_inbound(),
|
|
1156
|
+
"ranked_by_outbound": module_graph.rank_by_outbound(),
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
# -------------------------------------------------------------
|
|
1161
|
+
# 16. List Repositories (GET /v1/repositories)
|
|
1162
|
+
#
|
|
1163
|
+
# Backend-hardening addition (not a roadmap sprint item) -- Phase 0 of
|
|
1164
|
+
# `CODEPULSE_CLIENT_STRATEGY.md`'s VS Code extension plan (and the Web
|
|
1165
|
+
# UI plan's own Phase 0): every existing repository-scoped route needs
|
|
1166
|
+
# a `repo_id` the caller already knows -- there was no way to enumerate
|
|
1167
|
+
# which repositories a key is even authorized for. A client opening an
|
|
1168
|
+
# unfamiliar workspace (the extension) or building its first dashboard
|
|
1169
|
+
# screen (the Web UI) both need exactly this.
|
|
1170
|
+
# -------------------------------------------------------------
|
|
1171
|
+
@app.get("/v1/repositories")
|
|
1172
|
+
def list_repositories(auth: AuthorizedKey = Depends(require_api_key)):
|
|
1173
|
+
"""
|
|
1174
|
+
Lists every repository the CALLING KEY actually holds a grant for --
|
|
1175
|
+
never every repository in the database. An unscoped key (no grants
|
|
1176
|
+
at all) gets an empty list, not an error and not every repository
|
|
1177
|
+
in the system: matches Phase 1.2's own "no wildcard/superuser key"
|
|
1178
|
+
design (an unscoped key is authenticated but authorized for NO
|
|
1179
|
+
repository-scoped operation) exactly, the same rule every other
|
|
1180
|
+
repository-scoped route already enforces via `authorize_repository`.
|
|
1181
|
+
"""
|
|
1182
|
+
if not auth.repository_ids:
|
|
1183
|
+
return {"repositories": []}
|
|
1184
|
+
|
|
1185
|
+
session = storage_manager.get_session()
|
|
1186
|
+
try:
|
|
1187
|
+
repos = (
|
|
1188
|
+
session.query(RepositoryModel)
|
|
1189
|
+
.filter(RepositoryModel.id.in_(auth.repository_ids))
|
|
1190
|
+
.order_by(RepositoryModel.id)
|
|
1191
|
+
.all()
|
|
1192
|
+
)
|
|
1193
|
+
return {
|
|
1194
|
+
"repositories": [
|
|
1195
|
+
{
|
|
1196
|
+
"repository_id": r.id,
|
|
1197
|
+
"name": r.name,
|
|
1198
|
+
"url": r.url,
|
|
1199
|
+
"branch": r.branch,
|
|
1200
|
+
"file_count": r.file_count,
|
|
1201
|
+
"symbol_count": r.symbol_count,
|
|
1202
|
+
"last_sync": r.last_sync.isoformat() if r.last_sync else None,
|
|
1203
|
+
}
|
|
1204
|
+
for r in repos
|
|
1205
|
+
]
|
|
1206
|
+
}
|
|
1207
|
+
finally:
|
|
1208
|
+
session.close()
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
# -------------------------------------------------------------
|
|
1212
|
+
# 17. Resolve Symbol at Location (GET /v1/repositories/{repo_id}/resolve)
|
|
1213
|
+
#
|
|
1214
|
+
# Backend-hardening addition (not a roadmap sprint item) -- the second
|
|
1215
|
+
# Phase 0 prerequisite from `CODEPULSE_CLIENT_STRATEGY.md`'s VS Code
|
|
1216
|
+
# extension plan specifically: a hover provider or CodeLens needs to
|
|
1217
|
+
# answer "what symbol contains line N of this file," and neither
|
|
1218
|
+
# `GET /v1/symbols/{id}` (needs a symbol id) nor
|
|
1219
|
+
# `GET /v1/files/{id}/symbols` (needs a file id, returns every symbol
|
|
1220
|
+
# in the file, not just the one at a specific line) answers that
|
|
1221
|
+
# directly. Resolving by (repo_id, path, line) server-side, in one
|
|
1222
|
+
# call, keeps "which symbol contains this line" logic in the shared
|
|
1223
|
+
# intelligence core rather than duplicated client-side -- CLAUDE.md's
|
|
1224
|
+
# own "REST/CLI/MCP call the same underlying functions" rule, applied
|
|
1225
|
+
# to a brand-new capability rather than an existing engine method.
|
|
1226
|
+
# -------------------------------------------------------------
|
|
1227
|
+
@app.get("/v1/repositories/{repo_id}/resolve")
|
|
1228
|
+
def resolve_symbol_at_location(
|
|
1229
|
+
repo_id: int,
|
|
1230
|
+
path: str = Query(..., min_length=1),
|
|
1231
|
+
line: int = Query(..., ge=1),
|
|
1232
|
+
auth: AuthorizedKey = Depends(require_api_key),
|
|
1233
|
+
):
|
|
1234
|
+
"""
|
|
1235
|
+
Finds the symbol in `path` (a file path relative to the repository
|
|
1236
|
+
root, exactly as stored in `FileModel.path`) that CONTAINS `line`
|
|
1237
|
+
(1-indexed, inclusive of `start_line`/`end_line`). When a line falls
|
|
1238
|
+
inside more than one symbol's range at once (e.g. a method's own
|
|
1239
|
+
line range is nested inside its enclosing class's range), the
|
|
1240
|
+
NARROWEST-range match wins -- the most specific symbol actually at
|
|
1241
|
+
that line, matching what a hover/CodeLens caller actually wants
|
|
1242
|
+
(the method, not the whole class). Returns `{"symbol": null}` (200,
|
|
1243
|
+
not 404) for a real file with no symbol at that exact line (e.g. a
|
|
1244
|
+
blank line, an import, or a comment) -- a real, valid outcome, not
|
|
1245
|
+
an error. 404 is reserved for the file itself not being found in
|
|
1246
|
+
this repository.
|
|
1247
|
+
"""
|
|
1248
|
+
auth.authorize_repository(repo_id)
|
|
1249
|
+
session = storage_manager.get_session()
|
|
1250
|
+
try:
|
|
1251
|
+
file_record = (
|
|
1252
|
+
session.query(FileModel)
|
|
1253
|
+
.filter(FileModel.repo_id == repo_id, FileModel.path == path)
|
|
1254
|
+
.first()
|
|
1255
|
+
)
|
|
1256
|
+
if file_record is None:
|
|
1257
|
+
raise HTTPException(status_code=404, detail=f"File not found in this repository: {path!r}")
|
|
1258
|
+
|
|
1259
|
+
candidates = (
|
|
1260
|
+
session.query(SymbolModel)
|
|
1261
|
+
.filter(
|
|
1262
|
+
SymbolModel.file_id == file_record.id,
|
|
1263
|
+
SymbolModel.start_line <= line,
|
|
1264
|
+
SymbolModel.end_line >= line,
|
|
1265
|
+
)
|
|
1266
|
+
.all()
|
|
1267
|
+
)
|
|
1268
|
+
if not candidates:
|
|
1269
|
+
return {"repository_id": repo_id, "file": path, "line": line, "symbol": None}
|
|
1270
|
+
|
|
1271
|
+
narrowest = min(candidates, key=lambda s: (s.end_line - s.start_line, s.start_line))
|
|
1272
|
+
return {
|
|
1273
|
+
"repository_id": repo_id,
|
|
1274
|
+
"file": path,
|
|
1275
|
+
"line": line,
|
|
1276
|
+
"symbol": {
|
|
1277
|
+
"symbol_id": narrowest.id,
|
|
1278
|
+
"name": narrowest.name,
|
|
1279
|
+
"type": narrowest.type,
|
|
1280
|
+
"line_start": narrowest.start_line,
|
|
1281
|
+
"line_end": narrowest.end_line,
|
|
1282
|
+
"docstring": narrowest.docstring,
|
|
1283
|
+
"complexity": narrowest.complexity,
|
|
1284
|
+
},
|
|
1285
|
+
}
|
|
1286
|
+
finally:
|
|
1287
|
+
session.close()
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
@app.get("/v1/repositories/{repo_id}/file-symbols")
|
|
1291
|
+
def list_symbols_for_file_path(
|
|
1292
|
+
repo_id: int,
|
|
1293
|
+
path: str = Query(..., min_length=1),
|
|
1294
|
+
auth: AuthorizedKey = Depends(require_api_key),
|
|
1295
|
+
):
|
|
1296
|
+
"""
|
|
1297
|
+
Lists every symbol declared in `path` (relative to the repository
|
|
1298
|
+
root), ordered by `start_line`. Mirrors `GET /v1/files/{file_id}/symbols`
|
|
1299
|
+
but keyed by `(repo_id, path)` like `resolve` above, so a client that
|
|
1300
|
+
only knows a workspace-relative path (e.g. a CodeLens provider scanning
|
|
1301
|
+
an open document) doesn't need a separate file_id lookup round-trip.
|
|
1302
|
+
"""
|
|
1303
|
+
auth.authorize_repository(repo_id)
|
|
1304
|
+
session = storage_manager.get_session()
|
|
1305
|
+
try:
|
|
1306
|
+
file_record = (
|
|
1307
|
+
session.query(FileModel)
|
|
1308
|
+
.filter(FileModel.repo_id == repo_id, FileModel.path == path)
|
|
1309
|
+
.first()
|
|
1310
|
+
)
|
|
1311
|
+
if file_record is None:
|
|
1312
|
+
raise HTTPException(status_code=404, detail=f"File not found in this repository: {path!r}")
|
|
1313
|
+
|
|
1314
|
+
symbols = (
|
|
1315
|
+
session.query(SymbolModel)
|
|
1316
|
+
.filter(SymbolModel.file_id == file_record.id)
|
|
1317
|
+
.order_by(SymbolModel.start_line)
|
|
1318
|
+
.all()
|
|
1319
|
+
)
|
|
1320
|
+
return {
|
|
1321
|
+
"repository_id": repo_id,
|
|
1322
|
+
"file": path,
|
|
1323
|
+
"symbols": [
|
|
1324
|
+
{
|
|
1325
|
+
"symbol_id": s.id,
|
|
1326
|
+
"name": s.name,
|
|
1327
|
+
"type": s.type,
|
|
1328
|
+
"line_start": s.start_line,
|
|
1329
|
+
"line_end": s.end_line,
|
|
1330
|
+
"complexity": s.complexity,
|
|
1331
|
+
}
|
|
1332
|
+
for s in symbols
|
|
1333
|
+
],
|
|
1334
|
+
}
|
|
1335
|
+
finally:
|
|
1336
|
+
session.close()
|