codepulse-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
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()