coderag-ai 0.3.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 (73) hide show
  1. coderag/__init__.py +12 -0
  2. coderag/analyzers/__init__.py +0 -0
  3. coderag/analyzers/base.py +36 -0
  4. coderag/analyzers/flake8_adapter.py +43 -0
  5. coderag/analyzers/pylint_adapter.py +50 -0
  6. coderag/analyzers/workflow.py +107 -0
  7. coderag/api/__init__.py +0 -0
  8. coderag/api/app.py +351 -0
  9. coderag/api/dashboard.py +237 -0
  10. coderag/api/schemas.py +181 -0
  11. coderag/cli/__init__.py +0 -0
  12. coderag/cli/main.py +359 -0
  13. coderag/context/__init__.py +0 -0
  14. coderag/context/builder.py +206 -0
  15. coderag/context/package.py +100 -0
  16. coderag/core/__init__.py +0 -0
  17. coderag/core/config.py +100 -0
  18. coderag/core/logging.py +58 -0
  19. coderag/core/text.py +80 -0
  20. coderag/core/tokens.py +66 -0
  21. coderag/db/__init__.py +0 -0
  22. coderag/db/base.py +58 -0
  23. coderag/db/migrations/env.py +77 -0
  24. coderag/db/migrations/script.py.mako +26 -0
  25. coderag/db/migrations/versions/.gitkeep +0 -0
  26. coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
  27. coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
  28. coderag/db/models.py +275 -0
  29. coderag/embeddings/__init__.py +0 -0
  30. coderag/embeddings/base.py +53 -0
  31. coderag/embeddings/hashing.py +43 -0
  32. coderag/embeddings/pipeline.py +87 -0
  33. coderag/embeddings/registry.py +38 -0
  34. coderag/embeddings/sentence_transformer.py +47 -0
  35. coderag/evaluation/__init__.py +0 -0
  36. coderag/evaluation/datasets.py +36 -0
  37. coderag/evaluation/harness.py +246 -0
  38. coderag/git/__init__.py +0 -0
  39. coderag/git/repo.py +139 -0
  40. coderag/indexing/__init__.py +0 -0
  41. coderag/indexing/ignore.py +101 -0
  42. coderag/indexing/indexer.py +445 -0
  43. coderag/llm/__init__.py +0 -0
  44. coderag/llm/anthropic.py +117 -0
  45. coderag/llm/base.py +60 -0
  46. coderag/llm/null.py +16 -0
  47. coderag/llm/registry.py +17 -0
  48. coderag/localdb.py +100 -0
  49. coderag/mcp_server.py +209 -0
  50. coderag/parsing/__init__.py +0 -0
  51. coderag/parsing/base.py +76 -0
  52. coderag/parsing/python.py +294 -0
  53. coderag/parsing/registry.py +40 -0
  54. coderag/retrieval/__init__.py +0 -0
  55. coderag/retrieval/base.py +60 -0
  56. coderag/retrieval/engine.py +176 -0
  57. coderag/retrieval/fusion.py +41 -0
  58. coderag/retrieval/graph.py +139 -0
  59. coderag/retrieval/lexical.py +57 -0
  60. coderag/retrieval/reranking.py +99 -0
  61. coderag/retrieval/semantic.py +53 -0
  62. coderag/retrieval/symbol.py +91 -0
  63. coderag/security/__init__.py +0 -0
  64. coderag/security/authz.py +35 -0
  65. coderag/security/secrets.py +110 -0
  66. coderag/service.py +160 -0
  67. coderag/telemetry.py +72 -0
  68. coderag_ai-0.3.0.dist-info/METADATA +364 -0
  69. coderag_ai-0.3.0.dist-info/RECORD +73 -0
  70. coderag_ai-0.3.0.dist-info/WHEEL +4 -0
  71. coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
  72. coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. coderag_ai-0.3.0.dist-info/licenses/NOTICE +5 -0
coderag/core/config.py ADDED
@@ -0,0 +1,100 @@
1
+ """Central configuration.
2
+
3
+ All tunable behaviour (ranking weights, budgets, model names, provider
4
+ selection) lives here so there are no magic constants scattered through the
5
+ code. Values are read from the environment / a ``.env`` file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import lru_cache
11
+
12
+ from pydantic import Field
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ model_config = SettingsConfigDict(
18
+ env_prefix="CODERAG_",
19
+ env_file=".env",
20
+ env_file_encoding="utf-8",
21
+ extra="ignore",
22
+ )
23
+
24
+ # ---- Database -------------------------------------------------------
25
+ # A libpq URL. In dev/test the pgserver fixture overrides this.
26
+ database_url: str = Field(
27
+ default="postgresql+psycopg://coderag:coderag@localhost:5432/coderag"
28
+ )
29
+ db_echo: bool = False
30
+
31
+ # Default repository name used when a request doesn't name one (handy for the
32
+ # MCP server / CLI when several repositories are indexed).
33
+ default_repository: str | None = None
34
+
35
+ # ---- Embeddings -----------------------------------------------------
36
+ # "hashing" -> deterministic, dependency-free (default; offline, great for tests)
37
+ # "sentence_transformer" -> local SentenceTransformers model (needs `embeddings` extra)
38
+ embedding_provider: str = "hashing"
39
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
40
+ embedding_device: str = "cpu"
41
+ embedding_batch_size: int = 32
42
+ # Dimension of the hashing embedder (the ST provider reports its own).
43
+ embedding_dimension: int = 384
44
+
45
+ # ---- Token counting -------------------------------------------------
46
+ # "heuristic" (offline, deterministic) or "tiktoken" (needs `tokens` extra).
47
+ token_counter: str = "heuristic"
48
+ tiktoken_encoding: str = "cl100k_base"
49
+
50
+ # ---- Retrieval / fusion --------------------------------------------
51
+ # Per-source candidate limits (how many each retriever returns).
52
+ symbol_limit: int = 10
53
+ lexical_limit: int = 30
54
+ semantic_limit: int = 30
55
+ # Reciprocal Rank Fusion constant and per-source weights.
56
+ rrf_k: int = 60
57
+ weight_symbol: float = 2.0
58
+ weight_lexical: float = 1.0
59
+ weight_semantic: float = 1.0
60
+ weight_graph: float = 0.7
61
+ # Max merged candidates carried into ranking.
62
+ max_candidates: int = 60
63
+
64
+ # ---- Graph expansion (bounded) -------------------------------------
65
+ graph_enabled: bool = True
66
+ graph_max_depth: int = 1
67
+ graph_max_candidates: int = 20
68
+ graph_max_tokens: int = 6000
69
+
70
+ # ---- Reranking (optional) ------------------------------------------
71
+ reranker_enabled: bool = False
72
+ reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
73
+
74
+ # ---- Context builder ------------------------------------------------
75
+ max_context_tokens: int = 12000
76
+ # Reserve tokens for the scaffolding/instructions of the prompt.
77
+ context_overhead_tokens: int = 600
78
+
79
+ # ---- LLM provider ---------------------------------------------------
80
+ llm_provider: str = "anthropic" # "anthropic" | "null"
81
+ anthropic_api_key: str | None = None
82
+ anthropic_base_url: str = "https://api.anthropic.com"
83
+ anthropic_model: str = "claude-sonnet-5"
84
+ anthropic_version: str = "2023-06-01"
85
+ llm_max_output_tokens: int = 1024
86
+ llm_timeout_seconds: float = 60.0
87
+
88
+ # ---- Analyzers (Phase 10) ------------------------------------------
89
+ max_fix_attempts: int = 2
90
+
91
+ # ---- Security -------------------------------------------------------
92
+ # Extra path fragments to ignore during indexing (comma-separated env).
93
+ extra_ignore: list[str] = Field(default_factory=list)
94
+ # If True, structured logs may include short code snippets (never full files).
95
+ log_code_snippets: bool = False
96
+
97
+
98
+ @lru_cache
99
+ def get_settings() -> Settings:
100
+ return Settings()
@@ -0,0 +1,58 @@
1
+ """Structured logging.
2
+
3
+ Security rule (SECURITY.md): we NEVER log full source code, full prompts,
4
+ secrets, or credentials. Log events carry identifiers (symbol id, path,
5
+ counts) — not payloads. Helpers here make the safe path the easy path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+
13
+ import structlog
14
+
15
+
16
+ class _DynamicStderrFactory:
17
+ """Create a PrintLogger bound to the *current* sys.stderr on each call.
18
+
19
+ Resolving the stream dynamically (with caching disabled) avoids writing to a
20
+ stale/closed stream — important under pytest, which swaps sys.stderr per test.
21
+ """
22
+
23
+ def __call__(self, *args: object) -> structlog.PrintLogger:
24
+ return structlog.PrintLogger(file=sys.stderr)
25
+
26
+
27
+ def configure_logging(level: str = "INFO") -> None:
28
+ structlog.configure(
29
+ processors=[
30
+ structlog.contextvars.merge_contextvars,
31
+ structlog.processors.add_log_level,
32
+ structlog.processors.TimeStamper(fmt="iso"),
33
+ structlog.processors.StackInfoRenderer(),
34
+ structlog.processors.format_exc_info,
35
+ structlog.dev.ConsoleRenderer(colors=False),
36
+ ],
37
+ wrapper_class=structlog.make_filtering_bound_logger(
38
+ getattr(logging, level.upper(), logging.INFO)
39
+ ),
40
+ logger_factory=_DynamicStderrFactory(),
41
+ cache_logger_on_first_use=False,
42
+ )
43
+
44
+
45
+ def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
46
+ return structlog.get_logger(name)
47
+
48
+
49
+ def preview(text: str, limit: int = 80) -> str:
50
+ """Return a short, safe single-line preview of possibly-sensitive text.
51
+
52
+ Use this instead of logging raw code/queries. Truncates and collapses
53
+ whitespace so full payloads never reach the logs.
54
+ """
55
+ flat = " ".join(text.split())
56
+ if len(flat) <= limit:
57
+ return flat
58
+ return flat[:limit] + "…"
coderag/core/text.py ADDED
@@ -0,0 +1,80 @@
1
+ """Identifier tokenization shared by parsing and lexical retrieval.
2
+
3
+ Full-text search over code works far better if identifiers are also indexed as
4
+ their component words: ``retry_payment`` -> ``retry_payment retry payment`` and
5
+ ``PaymentService`` -> ``PaymentService payment service``. The same expansion is
6
+ applied to queries so ``retry payment`` matches ``retry_payment``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
14
+ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+|[0-9]+")
15
+
16
+ # Common language keywords we don't want dominating the search document.
17
+ _STOP = frozenset(
18
+ {
19
+ "self", "cls", "def", "class", "return", "import", "from", "as", "if",
20
+ "else", "elif", "for", "while", "try", "except", "finally", "with",
21
+ "pass", "raise", "none", "true", "false", "and", "or", "not", "in", "is",
22
+ "lambda", "yield", "await", "async", "global", "nonlocal", "del", "assert",
23
+ }
24
+ )
25
+
26
+
27
+ def split_identifier(identifier: str) -> list[str]:
28
+ """Return the identifier plus its snake/camel component words (lowercased)."""
29
+ out: list[str] = [identifier]
30
+ for part in identifier.split("_"):
31
+ for w in _CAMEL_RE.findall(part):
32
+ if w:
33
+ out.append(w.lower())
34
+ # de-dup preserving order
35
+ seen: set[str] = set()
36
+ result: list[str] = []
37
+ for t in out:
38
+ tl = t.lower()
39
+ if tl and tl not in seen:
40
+ seen.add(tl)
41
+ result.append(t if t == identifier else tl)
42
+ return result
43
+
44
+
45
+ def extract_identifiers(code: str, limit: int = 200) -> list[str]:
46
+ """Unique identifiers appearing in a code snippet (order-preserving, capped)."""
47
+ seen: set[str] = set()
48
+ out: list[str] = []
49
+ for m in _IDENT_RE.finditer(code):
50
+ tok = m.group(0)
51
+ if tok in seen or tok.lower() in _STOP:
52
+ continue
53
+ seen.add(tok)
54
+ out.append(tok)
55
+ if len(out) >= limit:
56
+ break
57
+ return out
58
+
59
+
60
+ def build_search_terms(*groups: list[str], limit: int = 300) -> list[str]:
61
+ """Merge identifier groups into a de-duplicated, word-expanded term list."""
62
+ seen: set[str] = set()
63
+ out: list[str] = []
64
+ for group in groups:
65
+ for ident in group:
66
+ for term in split_identifier(ident):
67
+ key = term.lower()
68
+ if key in seen:
69
+ continue
70
+ seen.add(key)
71
+ out.append(term)
72
+ if len(out) >= limit:
73
+ return out
74
+ return out
75
+
76
+
77
+ def expand_query_terms(query: str) -> list[str]:
78
+ """Tokenize a free-text query into word-expanded search terms."""
79
+ idents = _IDENT_RE.findall(query)
80
+ return build_search_terms(idents)
coderag/core/tokens.py ADDED
@@ -0,0 +1,66 @@
1
+ """Token counting.
2
+
3
+ Token accounting is a primary product feature, so counting must be:
4
+ * deterministic and offline by default (HeuristicTokenCounter), and
5
+ * pluggable (TiktokenCounter) for closer estimates.
6
+
7
+ IMPORTANT: these are *estimates* used for budgeting before we call the LLM.
8
+ The authoritative token counts always come back from the LLM provider's usage
9
+ report and are stored in ``llm_requests``. We never present an estimate as the
10
+ real billed number.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Protocol
16
+
17
+ from coderag.core.config import Settings
18
+
19
+
20
+ class TokenCounter(Protocol):
21
+ def count(self, text: str) -> int: ...
22
+
23
+ @property
24
+ def name(self) -> str: ...
25
+
26
+
27
+ class HeuristicTokenCounter:
28
+ """Offline estimator.
29
+
30
+ Uses a blend of a per-character ratio and a whitespace token count, which
31
+ tracks BPE tokenisation of source code reasonably well without any network
32
+ access or model download.
33
+ """
34
+
35
+ name = "heuristic"
36
+
37
+ def count(self, text: str) -> int:
38
+ if not text:
39
+ return 0
40
+ char_estimate = len(text) / 4.0
41
+ word_estimate = len(text.split()) * 1.3
42
+ return max(1, round((char_estimate + word_estimate) / 2))
43
+
44
+
45
+ class TiktokenCounter:
46
+ """Closer estimate using tiktoken (optional dependency, may need network)."""
47
+
48
+ def __init__(self, encoding: str = "cl100k_base") -> None:
49
+ import tiktoken # noqa: PLC0415 (lazy: optional dependency)
50
+
51
+ self._enc = tiktoken.get_encoding(encoding)
52
+ self.name = f"tiktoken:{encoding}"
53
+
54
+ def count(self, text: str) -> int:
55
+ if not text:
56
+ return 0
57
+ return len(self._enc.encode(text, disallowed_special=()))
58
+
59
+
60
+ def get_token_counter(settings: Settings) -> TokenCounter:
61
+ if settings.token_counter == "tiktoken":
62
+ try:
63
+ return TiktokenCounter(settings.tiktoken_encoding)
64
+ except Exception: # pragma: no cover - graceful offline fallback
65
+ return HeuristicTokenCounter()
66
+ return HeuristicTokenCounter()
coderag/db/__init__.py ADDED
File without changes
coderag/db/base.py ADDED
@@ -0,0 +1,58 @@
1
+ """Engine / session management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+
8
+ from sqlalchemy import Engine, create_engine
9
+ from sqlalchemy.orm import Session, sessionmaker
10
+
11
+ from coderag.core.config import Settings, get_settings
12
+
13
+ _engine: Engine | None = None
14
+ _SessionFactory: sessionmaker[Session] | None = None
15
+
16
+
17
+ def make_engine(settings: Settings | None = None) -> Engine:
18
+ settings = settings or get_settings()
19
+ return create_engine(
20
+ settings.database_url,
21
+ echo=settings.db_echo,
22
+ pool_pre_ping=True,
23
+ future=True,
24
+ )
25
+
26
+
27
+ def get_engine() -> Engine:
28
+ global _engine
29
+ if _engine is None:
30
+ _engine = make_engine()
31
+ return _engine
32
+
33
+
34
+ def get_session_factory() -> sessionmaker[Session]:
35
+ global _SessionFactory
36
+ if _SessionFactory is None:
37
+ _SessionFactory = sessionmaker(bind=get_engine(), expire_on_commit=False, future=True)
38
+ return _SessionFactory
39
+
40
+
41
+ def configure_engine(engine: Engine) -> None:
42
+ """Install a specific engine (used by tests / the pgserver fixture)."""
43
+ global _engine, _SessionFactory
44
+ _engine = engine
45
+ _SessionFactory = sessionmaker(bind=engine, expire_on_commit=False, future=True)
46
+
47
+
48
+ @contextmanager
49
+ def session_scope() -> Iterator[Session]:
50
+ session = get_session_factory()()
51
+ try:
52
+ yield session
53
+ session.commit()
54
+ except Exception:
55
+ session.rollback()
56
+ raise
57
+ finally:
58
+ session.close()
@@ -0,0 +1,77 @@
1
+ """Alembic environment.
2
+
3
+ The database URL comes from CODERAG settings / the environment (never hardcoded),
4
+ and the pgvector extension is created before any migration runs so that the
5
+ ``vector`` type resolves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from alembic import context
11
+ from sqlalchemy import text
12
+
13
+ from coderag.core.config import get_settings
14
+ from coderag.db.base import make_engine
15
+ from coderag.db.models import Base
16
+
17
+ config = context.config
18
+ target_metadata = Base.metadata
19
+
20
+
21
+ def render_item(type_, obj, autogen_context):
22
+ """Ensure pgvector's Vector type renders with its import in migrations."""
23
+ import pgvector.sqlalchemy
24
+
25
+ if type_ == "type" and isinstance(obj, pgvector.sqlalchemy.Vector):
26
+ autogen_context.imports.add("import pgvector.sqlalchemy")
27
+ dim = getattr(obj, "dim", None)
28
+ return f"pgvector.sqlalchemy.Vector({dim if dim else ''})"
29
+ return False
30
+
31
+
32
+ def _url() -> str:
33
+ # Allow an explicit override (used by tests), else fall back to settings.
34
+ return config.get_main_option("sqlalchemy.url") or get_settings().database_url
35
+
36
+
37
+ def run_migrations_offline() -> None:
38
+ context.configure(
39
+ url=_url(),
40
+ target_metadata=target_metadata,
41
+ literal_binds=True,
42
+ dialect_opts={"paramstyle": "named"},
43
+ compare_type=True,
44
+ render_item=render_item,
45
+ )
46
+ with context.begin_transaction():
47
+ context.run_migrations()
48
+
49
+
50
+ def run_migrations_online() -> None:
51
+ engine = make_engine_from_url(_url())
52
+ with engine.connect() as connection:
53
+ connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
54
+ connection.commit()
55
+ context.configure(
56
+ connection=connection,
57
+ target_metadata=target_metadata,
58
+ compare_type=True,
59
+ render_item=render_item,
60
+ )
61
+ with context.begin_transaction():
62
+ context.run_migrations()
63
+
64
+
65
+ def make_engine_from_url(url: str):
66
+ from sqlalchemy import create_engine
67
+
68
+ return create_engine(url, future=True)
69
+
70
+
71
+ # Keep import used even if make_engine is unused directly.
72
+ _ = make_engine
73
+
74
+ if context.is_offline_mode():
75
+ run_migrations_offline()
76
+ else:
77
+ run_migrations_online()
@@ -0,0 +1,26 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+ ${imports if imports else ""}
14
+
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: str | None = ${repr(down_revision)}
17
+ branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
18
+ depends_on: str | Sequence[str] | None = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
File without changes
@@ -0,0 +1,41 @@
1
+ """measure baseline tokens
2
+
3
+ Revision ID: 9a973932b94d
4
+ Revises: c8a40feeda41
5
+ Create Date: 2026-09-01 23:20:53.372206
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ revision: str = '9a973932b94d'
16
+ down_revision: str | None = 'c8a40feeda41'
17
+ branch_labels: str | Sequence[str] | None = None
18
+ depends_on: str | Sequence[str] | None = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # server_default backfills existing rows (these are NOT NULL); the default is
23
+ # then dropped so the ORM default governs new inserts. Without this, upgrading
24
+ # a database that already has queries/source_files rows would fail.
25
+ op.add_column('queries', sa.Column('baseline_tokens', sa.Integer(),
26
+ nullable=False, server_default='0'))
27
+ op.add_column('queries', sa.Column('baseline_files', sa.Integer(),
28
+ nullable=False, server_default='0'))
29
+ op.add_column('source_files', sa.Column('token_count', sa.Integer(),
30
+ nullable=False, server_default='0'))
31
+ op.alter_column('queries', 'baseline_tokens', server_default=None)
32
+ op.alter_column('queries', 'baseline_files', server_default=None)
33
+ op.alter_column('source_files', 'token_count', server_default=None)
34
+
35
+
36
+ def downgrade() -> None:
37
+ # ### commands auto generated by Alembic - please adjust! ###
38
+ op.drop_column('source_files', 'token_count')
39
+ op.drop_column('queries', 'baseline_files')
40
+ op.drop_column('queries', 'baseline_tokens')
41
+ # ### end Alembic commands ###