codepulse-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,172 @@
1
+ """
2
+ Phase 1.6 — Structured error response format across all endpoints
3
+ (roadmap §37 item 1.6; §21 API Strategy: "Errors follow a single shape:
4
+ `{"error": {"code": "...", "message": "...", "details": {...}}}`").
5
+
6
+ Scope, precisely (do not infer requirements from later milestones):
7
+ - Every error response this REST API returns -- from every existing
8
+ `HTTPException` raise site (401/403/404/409/429/501), from FastAPI's
9
+ own automatic request-validation failures (422), from the rate-limit
10
+ middleware's own 429 (Phase 1.4, constructed directly rather than
11
+ raised -- see below for why it needs its own explicit call), and from
12
+ a genuinely unhandled exception (500) -- is reshaped into the single
13
+ §21 envelope, via handlers registered ONCE on `app` (the same "one
14
+ central choke point, not re-implemented per endpoint" principle
15
+ already established for authorization in Phase 1.2 and rate limiting
16
+ in Phase 1.4). No individual route/endpoint function's own
17
+ `raise HTTPException(...)` call sites needed to change to achieve
18
+ this -- reshaping happens centrally, after the fact.
19
+ - `code` is derived deterministically from the HTTP status via Python's
20
+ own `http.HTTPStatus` enum (e.g. 404 -> "not_found", 429 ->
21
+ "too_many_requests", 501 -> "not_implemented") -- not an invented
22
+ string table, so every status code already in use in this app (and
23
+ any added later) maps correctly without this module needing a
24
+ per-code entry.
25
+ - `message` is the existing error text, unchanged -- no error-message
26
+ wording anywhere in the app was rewritten by this milestone.
27
+ - `details` carries real, already-computed structured information where
28
+ it exists (currently: the rate limiter's real `retry_after_seconds`
29
+ for 429s -- the exact same value already used for the `Retry-After`
30
+ header, roadmap-required since Phase 1.4 -- and the raw Pydantic
31
+ validation error list for 422s) and is an empty `{}` otherwise --
32
+ never a fabricated/estimated value, per this project's "no fabricated
33
+ metrics" invariant applied to error payloads too.
34
+ - A genuinely unhandled exception (500) is logged server-side
35
+ (`logger.exception`, so it is never silently swallowed) and reported
36
+ to the client with a generic, non-leaking `message`
37
+ ("Internal server error") and empty `details` -- never the raw
38
+ exception text or a traceback, to avoid an information-disclosure
39
+ regression while still adopting the new envelope shape.
40
+
41
+ Why the rate-limit middleware needs its own explicit call to
42
+ `error_json_response` rather than just `raise HTTPException(...)`:
43
+ Starlette's `ExceptionMiddleware` (which dispatches to `@app.exception_
44
+ handler(...)` registrations) sits INSIDE the app's user-added middleware
45
+ stack -- closer to routing, not wrapping it. An exception raised from
46
+ inside a custom `@app.middleware("http")` function (like the Phase 1.4
47
+ rate limiter) is never seen by `ExceptionMiddleware` at all, so a plain
48
+ `raise HTTPException(...)` there would never reach these handlers. Both
49
+ the middleware and the exception handlers below call the exact same
50
+ `error_json_response` helper instead, so the shaping logic itself is
51
+ never duplicated between the two call sites.
52
+
53
+ Deliberately NOT in scope for Phase 1.6 (do not infer these belong here):
54
+ - Changing any error's HTTP status code or message wording -- this
55
+ milestone reshapes the existing responses; it does not redesign what
56
+ they say.
57
+ - MCP's own distinct error shape (roadmap §13: a flat
58
+ `{"error": "code_string", "message": "..."}`, no "details") -- a
59
+ different section's requirement for a surface (MCP, Phase 5) that
60
+ does not exist yet; this module implements only §21's REST shape.
61
+ - A security test suite (Phase 1.7).
62
+
63
+ This module lives in `codepulse/api/` (not a top-level module like
64
+ `auth.py`/`jobs.py`/`rate_limit.py`) deliberately: unlike those, this is a
65
+ REST-HTTP-response-shape concern specifically (§21's envelope), not a
66
+ capability a future CLI or MCP surface would reuse -- MCP has its own,
67
+ different error shape per §13, and the CLI never returns HTTP responses
68
+ at all.
69
+ """
70
+ from __future__ import annotations
71
+
72
+ import logging
73
+ from http import HTTPStatus
74
+ from typing import Any
75
+
76
+ from fastapi import FastAPI, Request
77
+ from fastapi.exceptions import RequestValidationError
78
+ from fastapi.responses import JSONResponse
79
+
80
+ # Registered on Starlette's BASE HTTPException (not `fastapi.HTTPException`,
81
+ # which is a subclass): Starlette's own router raises the base class for an
82
+ # unmatched path (404) or wrong method (405), so a handler registered only on
83
+ # the subclass never matched those and they bypassed the §21 envelope
84
+ # (2026-10-02 audit). Exception handlers are looked up through the MRO, so
85
+ # this ALSO still catches every `fastapi.HTTPException` raised by the routes.
86
+ from starlette.exceptions import HTTPException
87
+
88
+ logger = logging.getLogger(__name__)
89
+
90
+
91
+ def status_code_to_error_code(status_code: int) -> str:
92
+ """
93
+ Derives a stable, machine-parseable error `code` from an HTTP status
94
+ code via Python's own `http.HTTPStatus` enum -- e.g. 404 -> "not_found",
95
+ 429 -> "too_many_requests" -- rather than an invented string table.
96
+ Falls back to the generic "error" for a status code `HTTPStatus`
97
+ itself doesn't recognize.
98
+ """
99
+ try:
100
+ return HTTPStatus(status_code).name.lower()
101
+ except ValueError:
102
+ return "error"
103
+
104
+
105
+ def error_json_response(
106
+ status_code: int,
107
+ message: str,
108
+ details: dict[str, Any] | None = None,
109
+ headers: dict[str, str] | None = None,
110
+ ) -> JSONResponse:
111
+ """
112
+ Builds the single §21 error envelope as a real `JSONResponse`:
113
+ `{"error": {"code", "message", "details"}}`. The one place this
114
+ shape is actually constructed -- every call site below (and the
115
+ rate-limit middleware) goes through this, never duplicating the
116
+ shaping logic.
117
+ """
118
+ return JSONResponse(
119
+ status_code=status_code,
120
+ content={
121
+ "error": {
122
+ "code": status_code_to_error_code(status_code),
123
+ "message": message,
124
+ "details": details or {},
125
+ }
126
+ },
127
+ headers=headers,
128
+ )
129
+
130
+
131
+ def install_error_handlers(app: FastAPI) -> None:
132
+ """
133
+ Registers the structured-error-response handlers on `app`. Called
134
+ once at app construction time in `codepulse/api/app.py`.
135
+ """
136
+
137
+ @app.exception_handler(HTTPException)
138
+ async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
139
+ details: dict[str, Any] = {}
140
+ retry_after = exc.headers.get("Retry-After") if exc.headers else None
141
+ if retry_after is not None:
142
+ details["retry_after_seconds"] = int(retry_after)
143
+ # A router-level 404/405 has no matched endpoint in scope. Counting
144
+ # those under the raw URL would let anything probing random paths
145
+ # mint an unbounded number of Prometheus series, so they share one
146
+ # fixed label instead.
147
+ endpoint = request.url.path if "endpoint" in request.scope else "unmatched"
148
+ _record_api_error(request, exc.status_code, endpoint=endpoint)
149
+ return error_json_response(exc.status_code, str(exc.detail), details, headers=exc.headers)
150
+
151
+ @app.exception_handler(RequestValidationError)
152
+ async def _validation_exception_handler(
153
+ request: Request, exc: RequestValidationError
154
+ ) -> JSONResponse:
155
+ _record_api_error(request, HTTPStatus.UNPROCESSABLE_ENTITY)
156
+ return error_json_response(
157
+ HTTPStatus.UNPROCESSABLE_ENTITY,
158
+ "Request validation failed",
159
+ {"errors": exc.errors()},
160
+ )
161
+
162
+ @app.exception_handler(Exception)
163
+ async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
164
+ logger.exception("Unhandled exception in request %s %s", request.method, request.url.path)
165
+ _record_api_error(request, HTTPStatus.INTERNAL_SERVER_ERROR)
166
+ return error_json_response(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error")
167
+
168
+
169
+ def _record_api_error(request: Request, status_code: int, endpoint: str | None = None) -> None:
170
+ """Phase 7.4 (roadmap §37 item 7.4; §18: "api_errors_total{endpoint,status_code}") -- one call site, reused by every handler above and the rate-limit middleware (codepulse/api/app.py)."""
171
+ from codepulse.observability import api_errors_total
172
+ api_errors_total.labels(endpoint=endpoint or request.url.path, status_code=str(int(status_code))).inc()
@@ -0,0 +1,189 @@
1
+ """
2
+ Phase 1.2 — Central authorization dependency (roadmap §37 item 1.2; see also
3
+ §15 Security Architecture, §21 API Strategy, §30 Multi-Tenancy, and backlog
4
+ item B8: "API key auth + central authorization").
5
+
6
+ Definition of Done extracted from the roadmap (do not infer beyond this):
7
+ - §37 item 1.2, literally: "Central authorization dependency (FastAPI
8
+ dependency injected into every route)".
9
+ - B8's DoD: "No endpoint callable without a valid, scoped key."
10
+ - §21: "Every endpoint requires an API key (§15) except `/health/*`." No
11
+ `/health/*` endpoint exists yet (a later milestone), so this currently
12
+ means every one of the 12 existing endpoints.
13
+ - §15/§30: repository-scoped isolation must be enforced "in one central
14
+ authorization function all endpoints must call, not re-implemented per
15
+ endpoint" -- exactly what `require_api_key` below is, and exactly the
16
+ "search ignores repository_id" class of bug this closes.
17
+
18
+ This module provides that one central function. `codepulse/api/app.py`
19
+ injects it into every route via `Depends(require_api_key)`; nothing
20
+ duplicates its logic per-endpoint.
21
+
22
+ Scope, precisely -- deliberately NOT inferring later milestones:
23
+ - Reject a request with a missing, unknown, malformed, or revoked API key
24
+ (HTTP 401). Reuses `codepulse.auth.verify_api_key` (Phase 1.1) as the
25
+ sole verification mechanism -- this module does not implement a second
26
+ authentication mechanism.
27
+ - For a request that names a specific repository (a path parameter, a
28
+ body field, or a symbol/file resolved to its owning repository), reject
29
+ it (HTTP 403) unless the authenticated key is explicitly scoped to that
30
+ repository. An API key created with no repository grants at all (Phase
31
+ 1.1 permits this) is authenticated but authorized for NO
32
+ repository-scoped operation: fail-closed, least-privilege, matching the
33
+ roadmap's literal "valid, SCOPED key" wording (not "valid key" alone).
34
+ There is no wildcard/superuser key in this milestone.
35
+
36
+ Deliberately NOT in scope for Phase 1.2 (reserved for later roadmap
37
+ milestones -- do not infer these belong here):
38
+ - Role-based permissions (viewer/indexer/admin, roadmap §15) -- Phase 7 /
39
+ B25 (backlog: "APIKey/authorization extension", depends on B8).
40
+ - Rate limiting -- Phase 1.4.
41
+ - Ingestion-specific safety (path allow-listing, SSRF checks, size caps)
42
+ -- Phase 1.5 / B10.
43
+ - A structured `{"error": {"code", "message", "details"}}` response
44
+ envelope for every endpoint -- Phase 1.6. This module raises plain
45
+ `HTTPException`s with a `detail` string, matching every other error
46
+ path already in `api/app.py`; reshaping error responses project-wide is
47
+ 1.6's job.
48
+ - A dedicated security test suite -- Phase 1.7. This milestone's own
49
+ focused tests cover only its own behavior.
50
+
51
+ This module lives in `codepulse/api/` -- the API boundary -- deliberately,
52
+ per CLAUDE.md §3 ("authentication belongs at the API boundary; the
53
+ intelligence core must remain independent of HTTP/auth concerns").
54
+ `codepulse/indexer/engine.py` is not imported here and is not modified by
55
+ this milestone.
56
+ """
57
+ from __future__ import annotations
58
+
59
+ from dataclasses import dataclass, field
60
+
61
+ from fastapi import Header, HTTPException, status
62
+
63
+ from codepulse.auth import ROLE_RANK, verify_api_key
64
+ from codepulse.storage.db import APIKeyRepositoryModel
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AuthorizedKey:
69
+ """
70
+ Authorization context for one authenticated request.
71
+
72
+ `repository_ids` is the exact, complete set of repository ids the
73
+ authenticated key is scoped to (Phase 1.1's `api_key_repositories`
74
+ grant table) -- empty if the key was created with no repository grants
75
+ at all. There is no sentinel meaning "all repositories": every
76
+ repository-scoped endpoint must check membership in this set via
77
+ `authorize_repository`/`authorize_search_scope`.
78
+
79
+ Phase 7.2 (roadmap §37 item 7.2): `repository_roles` maps each
80
+ granted `repository_id` to its own `role` (`"viewer"`/`"indexer"`/
81
+ `"admin"`) -- per §15's "enforced at the repository-permission
82
+ level, not globally," a single key can hold DIFFERENT roles for
83
+ different repositories. `authorize_role` is the role-aware
84
+ counterpart to `authorize_repository`; membership-only endpoints
85
+ keep using `authorize_repository`/`authorize_search_scope`
86
+ unchanged (every role implies at least "viewer," so a plain
87
+ membership check remains correct for read-only endpoints -- it is
88
+ not a security gap, just a coarser check than `authorize_role`
89
+ would make for those routes).
90
+ """
91
+
92
+ api_key_id: int
93
+ repository_ids: frozenset[int]
94
+ repository_roles: dict[int, str] = field(default_factory=dict)
95
+
96
+ def authorize_repository(self, repository_id: int) -> None:
97
+ """Raises HTTP 403 unless this key is scoped to `repository_id`."""
98
+ if repository_id not in self.repository_ids:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_403_FORBIDDEN,
101
+ detail=f"API key is not scoped to repository {repository_id}",
102
+ )
103
+
104
+ def authorize_role(self, repository_id: int, required_role: str) -> None:
105
+ """
106
+ Raises HTTP 403 unless this key is scoped to `repository_id`
107
+ AND its role there is AT LEAST `required_role` (§15's ordering:
108
+ `viewer` < `indexer` < `admin`). Always checks membership first
109
+ (via `authorize_repository`, reused unmodified) so an
110
+ unauthorized-repository request gets the same 403 it always
111
+ has, before any role-specific detail is considered.
112
+ """
113
+ self.authorize_repository(repository_id)
114
+ held_role = self.repository_roles.get(repository_id)
115
+ if ROLE_RANK.get(held_role, -1) < ROLE_RANK[required_role]:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_403_FORBIDDEN,
118
+ detail=(
119
+ f"API key has role {held_role!r} for repository {repository_id}, "
120
+ f"but {required_role!r} (or higher) is required for this operation."
121
+ ),
122
+ )
123
+
124
+ def authorize_search_scope(self, repository_id: int | None) -> None:
125
+ """
126
+ Authorizes a search-style request whose `repository_id` may
127
+ legitimately be omitted (`None`), meaning "search across every
128
+ repository" -- an existing feature of `IndexingEngine.search`
129
+ (Phase 0.6). A `None` repository_id cannot be authorized against
130
+ this key's specific repository grants without either (a) unsafely
131
+ letting it reach repositories outside its scope -- exactly the
132
+ cross-repo leakage class this milestone exists to close (roadmap
133
+ §30) -- or (b) teaching `IndexingEngine.search` to filter by a
134
+ *set* of repository ids, which would couple authorization logic
135
+ into the intelligence core (explicitly out of scope for this
136
+ milestone). So a `None` repository_id is rejected outright here;
137
+ the caller must name the specific repository it wants to search.
138
+ """
139
+ if repository_id is None:
140
+ raise HTTPException(
141
+ status_code=status.HTTP_403_FORBIDDEN,
142
+ detail=(
143
+ "A specific repository_id is required to authorize this "
144
+ "request; unscoped/global search cannot be authorized "
145
+ "against a scoped API key."
146
+ ),
147
+ )
148
+ self.authorize_repository(repository_id)
149
+
150
+
151
+ def require_api_key(
152
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
153
+ ) -> AuthorizedKey:
154
+ """
155
+ FastAPI dependency: verifies the `X-API-Key` request header and returns
156
+ the caller's `AuthorizedKey` context, or raises HTTP 401.
157
+
158
+ Resolves `codepulse.api.app`'s CURRENT `storage_manager` at call time
159
+ via a deferred import (not a module-level one), so tests that
160
+ monkeypatch `codepulse.api.app.storage_manager` to an isolated database
161
+ -- the established pattern since Phase 0.7's
162
+ `test_no_fabricated_metrics.py` -- are honored here exactly as they
163
+ already are for `indexing_engine`. This dependency does not open a
164
+ second, independent database connection, and never touches the real
165
+ `codepulse.db` from a test.
166
+ """
167
+ if not x_api_key:
168
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key")
169
+
170
+ import codepulse.api.app as app_module # deferred: avoids a circular import; re-reads the module's current storage_manager on every call
171
+
172
+ api_key_id = verify_api_key(app_module.storage_manager, x_api_key)
173
+ if api_key_id is None:
174
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or revoked API key")
175
+
176
+ session = app_module.storage_manager.get_session()
177
+ try:
178
+ grants = {
179
+ row.repo_id: row.role
180
+ for row in session.query(APIKeyRepositoryModel)
181
+ .filter(APIKeyRepositoryModel.api_key_id == api_key_id)
182
+ .all()
183
+ }
184
+ finally:
185
+ session.close()
186
+
187
+ return AuthorizedKey(
188
+ api_key_id=api_key_id, repository_ids=frozenset(grants.keys()), repository_roles=grants
189
+ )