Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ stall the server. `statement_timeout` bounds the database, not the rescan.
| `semantic_search` | `query`, `limit=50`, `branch=None` | ranked chunks with `rrf_score` |
| `list_repos` | — | indexed repos with per-branch last-indexed metadata |
| `get_file` | `repo`, `path`, `branch=None` | full file content, or `found: false` |
| `find_references` | `symbol`, `limit=200`, `branch=None` | ranked candidate reference (call) sites with enclosing symbols |
| `list_imports` | `repo=None`, `target=None`, `direction=imports`, `branch=None`, `limit=200` | import edge sites; `repo` required for `imports`, `target` required for `imported_by` |

Every tool returns a JSON string. `limit` is clamped server-side: a non-positive value
falls back to 200, and anything above 1000 is capped there.
Expand Down Expand Up @@ -279,6 +281,23 @@ the embedder. The target Lakebase project's managed preload including
`lakebase_vector,lakebase_text` is a stated project assumption — see
[`docs/runbooks/semantic-enablement.md`](docs/runbooks/semantic-enablement.md).

`find_references` and `list_imports` serve the knowledge-graph reference edges. They are
**candidate-set, not compiler-precise** (grep-not-LSP): a call site is name-resolved to the
`symbols` definitions its callee name could plausibly mean, ranked (`same_repo`/`same_file`/
`kind_match`) but never collapsed to a single binding — `resolution` is `unique` (1
candidate), `ambiguous` (2+), or `unresolved` (0), and the true pre-cap `candidate_count`
survives capping. `list_imports` has two directions: `imports` enumerates a repo's import
sites (`repo` required) and `imported_by` finds who imports a given dotted `target`
corpus-wide (`target` required); invalid input comes back as a structured payload
(`unsupported_direction`/`missing_repo`/`missing_target` with a `reason`), never an error, and
import edges keep the full dotted path (so most read `unresolved` = external, by design).

**"What tests cover symbol X"** needs no dedicated tool: call `find_references(X)` and
client-side filter `sites` by your test-path convention (e.g. `file` starting with `tests/`);
each surviving site's `enclosing_symbol` names the covering test. Two follow-ups are
deliberately **deferred** past #87: repo/kind-scoped `find_references` filters, and per-file
forward imports ("what does file F import").

Two HTTP routes sit alongside the MCP mount: `GET /health` is liveness and never touches
the database, and `GET /ready` runs `SELECT 1 FROM repos LIMIT 1` so that a role holding
connect-but-not-select fails as 503 instead of shipping green.
Expand All @@ -300,6 +319,19 @@ open the app URL in a browser. See
rebuilding the frontend (`make webui-build`), and the wheel-packaging mechanism that lets
webui import `app.*` without duplicating it.

The **Graph** tab exposes the same knowledge-graph reference edges as the MCP
`find_references`/`list_imports` tools, via `GET /api/references` and `GET /api/imports` —
thin passthroughs over the SAME `app/service.py` builders the MCP tools wrap (no duplicated
graph logic; see [`docs/runbooks/webui.md`](docs/runbooks/webui.md) for the parity contract),
presented as ranked candidate sets rather than raw rows. The edge model behind both surfaces:
raw `call`/`import` edges are recorded at index time without resolving them, and each query
resolves a name against `symbols` on the fly (query-time candidate-set resolution, not a
build-time link step) — because this is grep-not-LSP name matching, a name can't always be
collapsed to one binding, so results come back as ranked candidate sets (`unique`/`ambiguous`/
`unresolved`) instead of a single "go to definition" answer. See
[`docs/runbooks/reference-edges.md`](docs/runbooks/reference-edges.md) for the edge schema and
resolver details.

## Deploy

`make deploy` (see [Quick start](#quick-start)) runs `scripts/deploy.sh full`, which
Expand Down Expand Up @@ -596,6 +628,9 @@ Run the server locally with `make run` (binds `DATABRICKS_APP_PORT`, else 8000).
semantic search (default-on): the preload assumption, opt-out, embeddings, memory notes
- [`docs/runbooks/indexing-parallelism.md`](docs/runbooks/indexing-parallelism.md) —
parallel indexing: worker sizing, skip-if-unchanged, compare-and-set stamping
- [`docs/runbooks/reference-edges.md`](docs/runbooks/reference-edges.md) — the raw
call/import edge schema (`reference_edges`, migration `0005`): what it stores, the
no-symbol-FK design, and the grant-coupling this migration introduces
- [`docs/runbooks/ci-lakebase.md`](docs/runbooks/ci-lakebase.md) — the integration CI
gate: ephemeral Lakebase branches, prerequisites
- [`docs/runbooks/webui.md`](docs/runbooks/webui.md) — the web UI app: auth, grants,
Expand Down
9 changes: 5 additions & 4 deletions app/alembic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# app/alembic

## Purpose
The Alembic migration environment and the single linear core revision chain (0001 → 0004) for the code-search schema on Lakebase Postgres. `env.py` resolves its connection in strict priority order: an injected connection from `scripts/migrate.py` (which owns the Lakebase OAuth engine) wins; else `LAKEBASE_ENDPOINT`/`PGHOST` builds one via `create_db_engine()` (the `make migration` autogenerate path against a disposable Lakebase branch); else it raises — there is no implicit default. Autogenerate diffs against `app.db.models.Base.metadata`, with the semantic `chunks` surface filtered out entirely. `alembic.ini` at the repo root points `script_location` here.
The Alembic migration environment and the single linear core revision chain (0001 → 0005) for the code-search schema on Lakebase Postgres. `env.py` resolves its connection in strict priority order: an injected connection from `scripts/migrate.py` (which owns the Lakebase OAuth engine) wins; else `LAKEBASE_ENDPOINT`/`PGHOST` builds one via `create_db_engine()` (the `make migration` autogenerate path against a disposable Lakebase branch); else it raises — there is no implicit default. Autogenerate diffs against `app.db.models.Base.metadata`, with the semantic `chunks` surface filtered out entirely. `alembic.ini` at the repo root points `script_location` here.

## Key Files
| File | Description |
Expand All @@ -20,11 +20,12 @@ The Alembic migration environment and the single linear core revision chain (000
| `0002_index_semantics_version.py` | Adds + backfills `repos.index_semantics_version`. Backfill value is FROZEN as `_BACKFILL_VERSION = 1` — deliberately never imports the live `INDEX_SEMANTICS_VERSION`. Backfills by cadence (rows touched in the last 48h); untouched rows stay NULL → re-index once |
| `0003_multi_branch.py` | Multi-branch content dedup: adds `files.content_sha` (backfilled in-DB via `pgcrypto` `digest(...,'sha256')`, proven byte-identical to `indexer.hashing.content_sha`) and GIN-indexed `files.branches`; swaps `uq_files_repo_id_path` → `uq_files_repo_path_sha`; creates `repo_branches` seeded from the legacy `repos` stamp. Downgrade guards FIRST: refuses if any path has multiple content versions |
| `0004_semantic_chunks.py` | The semantic surface in the core chain (supersedes the retired gated `0002sem`/`versions_semantic`): extensions `lakebase_tokenizer` → `lakebase_vector` → `lakebase_text` with `CASCADE` (load-bearing: `lakebase_vector` declares a dependency on base `vector`), the `chunks` table (embedding dim from `app.config.SEMANTIC_EMBEDDING_DIM`, generated `ts` tsvector, `uq_chunks_file_id_chunk_index` — also the write-path index for per-file DELETE and CASCADE), `ix_chunks_embedding_ann` (`lakebase_ann` with explicit non-default `vector_cosine_ops`; rejects hnsw-style WITH params) and `ix_chunks_ts_bm25`. Idempotency guard: if `to_regclass('chunks')` exists (old gated path), only add `start_line`/`end_line` and drop the orphaned `alembic_version_semantic` |
| `0005_reference_edges.py` | Adds `reference_edges` (epic #82): raw unresolved call/import edges, `edge_kind IN ('call','import')` CHECK, FKs to `repos`/`files` only (both `ON DELETE CASCADE`) — deliberately NO FK to `symbols` (query-time name-join resolution instead). Autogenerate-shaped (`op.create_table`/`op.create_index`, no raw SQL, no new extension — `pg_trgm` already exists since 0001). Four indexes: btree `target_name`, GIN trgm `target_name`, btree `file_id` (write-path/cascade), btree `(repo_id, edge_kind)`. Grant-coupled like 0003/0004 before it — see `docs/runbooks/reference-edges.md` |

## For AI Agents

### Working In This Directory
- **Chain ordering is strictly linear**: `0001 <- 0002 <- 0003 <- 0004`. A new revision sets `down_revision` to the current head; never branch the chain.
- **Chain ordering is strictly linear**: `0001 <- 0002 <- 0003 <- 0004 <- 0005`. A new revision sets `down_revision` to the current head; never branch the chain.
- **Migrations are historical facts.** They must never import mutable app constants — `0002` freezes its backfill value locally, and `models.py` explicitly forbids migrations importing `INDEX_SEMANTICS_VERSION`. The one sanctioned exception is `0004`'s use of `SEMANTIC_EMBEDDING_DIM`, which exists precisely so DDL and the `app/db/semantic.py` table can never drift.
- **Keep the `chunks` blindness intact.** `include_object` in `env.py` plus `chunks` living outside `Base.metadata` are two halves of one protection; removing either makes `make migration` emit destructive drops.
- **Extension-before-index ordering** is load-bearing in 0001 and 0004; downgrades never drop extensions (database-wide, potentially shared, and the 0004 preload prerequisite is irreversible).
Expand All @@ -33,8 +34,8 @@ The Alembic migration environment and the single linear core revision chain (000
- Run migrations via `make migrate` (`scripts/migrate.py`, injected connection, optional `ARGS=--apply-grants`); autogenerate via `make migration MSG="..."` against a disposable Lakebase branch (`scripts/ci_branch.py up`) — never against production. `env.py` deliberately raises rather than guessing a target.

### Testing Requirements
- `make test`: `tests/unit/test_migration_source.py` / `test_migration_source_semantic.py` (source-level revision-chain and no-app-import checks), `test_semantics_version_tripwire.py`.
- `make test-integration`: `tests/integration/test_migrations.py` (upgrade/downgrade against real Postgres; models ↔ chain parity), `test_content_sha_parity.py` (pgcrypto digest ≡ Python `content_sha`).
- `make test`: `tests/unit/test_migration_source.py` / `test_migration_source_semantic.py` (source-level revision-chain and no-app-import checks, including 0005's no-symbol-FK check), `test_semantics_version_tripwire.py`, `test_reference_edge_model.py` (ORM-metadata tripwires for `reference_edges`).
- `make test-integration`: `tests/integration/test_migrations.py` (upgrade/downgrade against real Postgres; models ↔ chain parity; the `reference_edges` shape/cascade/ADP/EXPLAIN tests run on stock Postgres too via `migrated_edges_capable`), `test_content_sha_parity.py` (pgcrypto digest ≡ Python `content_sha`).

### Common Patterns
- Raw `op.execute()` for anything SQLAlchemy can't declare portably (extensions, generated columns, lakebase index access methods, backfill UPDATEs); `op.create_table`/`op.create_index` for the declarable rest.
Expand Down
101 changes: 101 additions & 0 deletions app/alembic/versions/0005_reference_edges.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""reference edges (raw, unresolved call/import edges)

Revision ID: 0005
Revises: 0004
Create Date: 2026-07-23 00:00:00.000000

Adds ``reference_edges``: one row per raw (unresolved) call/import site found by
the extractor (epic #82). Deliberately NO FK to ``symbols`` -- symbol ids churn
on every per-file delete-and-reinsert, and an FK would couple the two rewrite
orders inside the indexing transaction for no query benefit. Resolution from
``target_name`` to a concrete symbol happens at query time by name-join (a
later child of #82), not here. The enclosing symbol is denormalized onto the
row instead (``enclosing_*``, all nullable -- NULL means module/top-level
scope). No ``branches`` column: branch membership rides ``files.branches`` at
query time, exactly as ``symbols`` does.

``pg_trgm`` already exists (created by 0001, database-wide) so no
``CREATE EXTENSION`` is needed here; extension-before-index ordering is
satisfied by the chain itself.

**Grant coupling (not schema-only for an already-deployed target):** the
schema-wide grant builders in ``app/db/grants.py`` (``GRANT ... ON ALL
TABLES IN SCHEMA`` + ``ALTER DEFAULT PRIVILEGES``) cover this new table
automatically ONLY when the same identity that ran the original grants also
runs this migration (Postgres ADP binds to the executing role). A different
identity running a schema-only ``make migrate`` needs an explicit re-grant.
See ``docs/runbooks/reference-edges.md`` for the verification query and the
re-grant command.
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "0005"
down_revision: str | None = "0004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"reference_edges",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("repo_id", sa.Integer(), nullable=False),
sa.Column("file_id", sa.Integer(), nullable=False),
sa.Column("edge_kind", sa.Text(), nullable=False),
sa.Column("target_name", sa.Text(), nullable=False),
sa.Column("line", sa.Integer(), nullable=False),
sa.Column("enclosing_name", sa.Text(), nullable=True),
sa.Column("enclosing_kind", sa.Text(), nullable=True),
sa.Column("enclosing_start_line", sa.Integer(), nullable=True),
sa.Column("enclosing_end_line", sa.Integer(), nullable=True),
sa.CheckConstraint("edge_kind IN ('call', 'import')", name="ck_reference_edges_edge_kind"),
sa.ForeignKeyConstraint(["repo_id"], ["repos.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["file_id"], ["files.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_reference_edges_target_name",
"reference_edges",
["target_name"],
unique=False,
)
op.create_index(
"ix_reference_edges_target_trgm",
"reference_edges",
["target_name"],
unique=False,
postgresql_using="gin",
postgresql_ops={"target_name": "gin_trgm_ops"},
)
# Load-bearing for write performance, not just integrity: Postgres does NOT
# auto-index a foreign key, and file_id is the hot lookup for both the
# per-file delete-and-reinsert writer and the ON DELETE CASCADE fired by
# store.py's mark-and-sweep -- same rationale as ix_symbols' analog on
# symbols.file_id (there via the implicit FK) and uq_chunks_file_id_chunk_index.
op.create_index(
"ix_reference_edges_file_id",
"reference_edges",
["file_id"],
unique=False,
)
op.create_index(
"ix_reference_edges_repo_kind",
"reference_edges",
["repo_id", "edge_kind"],
unique=False,
)


def downgrade() -> None:
op.drop_index("ix_reference_edges_repo_kind", table_name="reference_edges")
op.drop_index("ix_reference_edges_file_id", table_name="reference_edges")
op.drop_index("ix_reference_edges_target_trgm", table_name="reference_edges")
op.drop_index("ix_reference_edges_target_name", table_name="reference_edges")
op.drop_table("reference_edges")
Loading
Loading