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,347 @@
1
+ """
2
+ Schema migrations: the Alembic plumbing, plus the safe `codepulse migrate` logic
3
+ (CODEPULSE_FUTURE_CHANGES.md items 8 and 17 and finding K; user-approved 2026-10-14).
4
+
5
+ The migration scripts live INSIDE the package (`codepulse/storage/schema_migrations/`)
6
+ so they ship in a wheel, and the Alembic config is built in code instead of read from a
7
+ repository-root `alembic.ini`, so none of this depends on the checkout or the current
8
+ directory. (`alembic.ini` still exists for developers running `alembic upgrade head`
9
+ from the repo root; it points at the same scripts.) Building the config without an
10
+ ini file also means a migration run no longer applies the ini's logging section, which
11
+ used to disable every logger that already existed and reset the root handlers.
12
+
13
+ A database can be in one of these states (`get_schema_status`):
14
+ fresh no tables yet (or no file): nothing to upgrade, `migrate` creates it at head
15
+ current tracked by Alembic and at head
16
+ behind tracked, at an older revision this CodePulse knows: `migrate` upgrades it
17
+ unstamped has tables but was never tracked (created by `create_all`, as every
18
+ database CodePulse made before this change) AND its schema matches the
19
+ models, apart from tables it simply does not have yet (a database made
20
+ before a table existed, not opened by newer code since): `migrate`
21
+ creates those empty tables and stamps it at head
22
+ diverged unstamped and its schema DIFFERS from the models in any other way (a
23
+ missing or extra column, an extra table -- e.g. a legacy table that a later
24
+ migration renames): stamping it would claim a schema it does not have, so
25
+ `migrate` refuses and says what differs
26
+ unknown tracked, but at a revision this CodePulse does not have (a database from
27
+ a newer CodePulse): `migrate` refuses
28
+
29
+ `migrate_database` never changes a database it refuses, takes a consistent backup of a
30
+ SQLite file (the sqlite3 backup API, safe with a writer present) before it alters one,
31
+ and verifies the result. Looking at a database (`get_schema_status`, `check=True`) never
32
+ creates or changes it.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import sqlite3
37
+ from dataclasses import dataclass, field
38
+ from datetime import datetime
39
+ from functools import lru_cache
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ from alembic import command
44
+ from alembic.autogenerate import compare_metadata
45
+ from alembic.config import Config
46
+ from alembic.runtime.migration import MigrationContext
47
+ from alembic.script import ScriptDirectory
48
+ from sqlalchemy import create_engine, inspect, text
49
+ from sqlalchemy.engine import Engine, make_url
50
+ from sqlalchemy.exc import IntegrityError
51
+
52
+ from codepulse.config import get_settings
53
+ from codepulse.storage.db import Base
54
+
55
+ SCRIPT_LOCATION = Path(__file__).resolve().parent / "schema_migrations"
56
+
57
+ # Structural differences that make an unstamped database unsafe to stamp. Index, constraint, type and
58
+ # default differences between `create_all` and the migrations are deliberately NOT included: they do
59
+ # not change what data the tables can hold.
60
+ _STRUCTURAL_DIFFS = ("add_table", "remove_table", "add_column", "remove_column")
61
+
62
+
63
+ class MigrationRefused(Exception):
64
+ """`migrate` will not change this database (diverged or from a newer CodePulse); nothing was touched."""
65
+
66
+
67
+ def get_alembic_config(db_url: str | None = None) -> Config:
68
+ """
69
+ An Alembic `Config` built in code: the scripts are the packaged ones and no `alembic.ini` is needed.
70
+ """
71
+ cfg = Config()
72
+ cfg.set_main_option("script_location", str(SCRIPT_LOCATION))
73
+ # configparser treats % as interpolation; a percent-encoded URL would otherwise break.
74
+ cfg.set_main_option("sqlalchemy.url", (db_url or get_settings().db_url).replace("%", "%%"))
75
+ return cfg
76
+
77
+
78
+ @lru_cache(maxsize=1)
79
+ def head_revision() -> str:
80
+ """The newest revision this CodePulse knows (computed once per process)."""
81
+ head = ScriptDirectory.from_config(get_alembic_config("sqlite://")).get_current_head()
82
+ assert head, "no migration revisions found"
83
+ return head
84
+
85
+
86
+ def run_migrations(db_url: str | None = None, revision: str = "head") -> None:
87
+ """Applies Alembic migrations up to the specified revision (default: head)."""
88
+ command.upgrade(get_alembic_config(db_url=db_url), revision)
89
+
90
+
91
+ def stamp_database(db_url: str | None = None, revision: str = "head") -> None:
92
+ """
93
+ Stamps the database at the specified revision without running migration scripts.
94
+ Only correct for a database whose schema already matches that revision;
95
+ `migrate_database` checks that before it calls this.
96
+ """
97
+ command.stamp(get_alembic_config(db_url=db_url), revision)
98
+
99
+
100
+ def downgrade_migrations(db_url: str | None = None, revision: str = "base") -> None:
101
+ """Downgrades Alembic migrations to the specified revision (default: base)."""
102
+ command.downgrade(get_alembic_config(db_url=db_url), revision)
103
+
104
+
105
+ def get_current_revision(db_url: str | None = None) -> Optional[str]:
106
+ """The database's alembic_version revision, or None if it is not tracked (or does not exist)."""
107
+ target_url = db_url or get_settings().db_url
108
+ path = _sqlite_file(target_url)
109
+ if path is not None and not path.exists():
110
+ return None
111
+ engine = create_engine(target_url)
112
+ try:
113
+ if "alembic_version" not in inspect(engine).get_table_names():
114
+ return None
115
+ with engine.connect() as conn:
116
+ return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
117
+ finally:
118
+ engine.dispose()
119
+
120
+
121
+ def stamp_new_database(engine: Engine) -> None:
122
+ """
123
+ Records head as the revision of a database that `StorageManager` has JUST created from scratch with
124
+ `create_all` (so its schema IS head). Before this, such a database was never tracked by Alembic and
125
+ could not be upgraded later. Safe if two processes create the same new database at once.
126
+ """
127
+ head = head_revision()
128
+ try:
129
+ with engine.begin() as conn:
130
+ conn.execute(text(
131
+ "CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL, "
132
+ "CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))"
133
+ ))
134
+ if conn.execute(text("SELECT count(*) FROM alembic_version")).scalar() == 0:
135
+ conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:v)"), {"v": head})
136
+ except IntegrityError:
137
+ pass # the other process stamped it first
138
+
139
+
140
+ # --- status ------------------------------------------------------------------
141
+
142
+ @dataclass(frozen=True)
143
+ class SchemaStatus:
144
+ state: str # fresh | current | behind | unstamped | diverged | unknown
145
+ current: Optional[str]
146
+ head: str
147
+ pending: list[str] = field(default_factory=list) # revisions `migrate` would apply, oldest first
148
+ differences: list[str] = field(default_factory=list) # for diverged: what differs from the models
149
+ missing_tables: list[str] = field(default_factory=list) # for unstamped: tables `migrate` will create
150
+ detail: str = ""
151
+
152
+ @property
153
+ def needs_action(self) -> bool:
154
+ return self.state in ("behind", "unstamped", "diverged", "unknown")
155
+
156
+
157
+ def _sqlite_file(db_url: str) -> Optional[Path]:
158
+ url = make_url(db_url)
159
+ if url.get_backend_name() == "sqlite" and url.database and url.database != ":memory:":
160
+ return Path(url.database)
161
+ return None
162
+
163
+
164
+ def _pending(current: str) -> list[str]:
165
+ script = ScriptDirectory.from_config(get_alembic_config("sqlite://"))
166
+ revisions = [rev.revision for rev in script.iterate_revisions(head_revision(), current)]
167
+ return [rev for rev in reversed(revisions) if rev != current]
168
+
169
+
170
+ def _structural_differences(engine: Engine) -> tuple[list[str], list[str]]:
171
+ """
172
+ `(differences, missing_tables)`: what in the database does NOT match the models, split into things that
173
+ make it unsafe to stamp (`differences`) and tables that are merely absent (`missing_tables`). Creating an
174
+ absent empty table cannot conflict with existing data, so those alone do not block.
175
+ """
176
+ with engine.connect() as conn:
177
+ context = MigrationContext.configure(conn, opts={"compare_type": False, "compare_server_default": False})
178
+ diffs = compare_metadata(context, Base.metadata)
179
+ out: list[str] = []
180
+ missing: list[str] = []
181
+ for diff in diffs:
182
+ kind = diff[0]
183
+ if kind == "add_table":
184
+ missing.append(diff[1].name)
185
+ elif kind == "remove_table":
186
+ if diff[1].name != "alembic_version":
187
+ out.append(f"table {diff[1].name} is not in the models")
188
+ elif kind == "add_column":
189
+ out.append(f"column {diff[2]}.{diff[3].name} is missing")
190
+ elif kind == "remove_column":
191
+ out.append(f"column {diff[2]}.{diff[3].name} is not in the models")
192
+ return sorted(out), sorted(missing)
193
+
194
+
195
+ def get_schema_status(db_url: str | None = None) -> SchemaStatus:
196
+ """Classifies the database (see the module docstring). Never creates or changes it."""
197
+ target_url = db_url or get_settings().db_url
198
+ head = head_revision()
199
+ path = _sqlite_file(target_url)
200
+ if path is not None and (not path.exists() or path.stat().st_size == 0):
201
+ return SchemaStatus("fresh", None, head, detail="no database yet")
202
+
203
+ engine = create_engine(target_url)
204
+ try:
205
+ tables = set(inspect(engine).get_table_names())
206
+ user_tables = tables - {"alembic_version"}
207
+ current = None
208
+ if "alembic_version" in tables:
209
+ with engine.connect() as conn:
210
+ current = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() or None
211
+ if not user_tables:
212
+ return SchemaStatus("fresh", current, head, detail="no tables yet")
213
+ if current is not None:
214
+ if current == head:
215
+ return SchemaStatus("current", current, head, detail=f"at {head}")
216
+ known = {rev.revision for rev in ScriptDirectory.from_config(get_alembic_config("sqlite://")).walk_revisions()}
217
+ if current not in known:
218
+ return SchemaStatus(
219
+ "unknown", current, head,
220
+ detail=f"revision {current} is not known to this CodePulse (a database from a newer version?)",
221
+ )
222
+ pending = _pending(current)
223
+ return SchemaStatus("behind", current, head, pending, detail=f"at {current}, {len(pending)} revision(s) behind {head}")
224
+ differences, missing_tables = _structural_differences(engine)
225
+ if differences:
226
+ return SchemaStatus(
227
+ "diverged", None, head, differences=differences,
228
+ missing_tables=missing_tables,
229
+ detail="not tracked by Alembic and its schema differs from the models: "
230
+ + "; ".join(differences + [f"table {t} is missing" for t in missing_tables]),
231
+ )
232
+ if missing_tables:
233
+ return SchemaStatus(
234
+ "unstamped", None, head, missing_tables=missing_tables,
235
+ detail=f"not tracked by Alembic; its schema matches the models except for missing table(s) {', '.join(missing_tables)}, which migrate will create",
236
+ )
237
+ return SchemaStatus("unstamped", None, head, detail="not tracked by Alembic, but its schema matches the models")
238
+ finally:
239
+ engine.dispose()
240
+
241
+
242
+ # --- migrate -----------------------------------------------------------------
243
+
244
+ @dataclass(frozen=True)
245
+ class MigrationResult:
246
+ action: str # created | up-to-date | upgraded | stamped | needs-upgrade | needs-stamp | blocked
247
+ state: str
248
+ from_revision: Optional[str]
249
+ to_revision: Optional[str]
250
+ backup_path: Optional[str] = None
251
+ applied: list[str] = field(default_factory=list)
252
+ detail: str = ""
253
+
254
+
255
+ def _backup_sqlite(path: Path, label: str) -> str:
256
+ """A consistent copy of a live SQLite database (the sqlite3 backup API, safe with a writer present)."""
257
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
258
+ dest = path.with_name(f"{path.name}.pre-{label}-{stamp}.bak")
259
+ counter = 1
260
+ while dest.exists():
261
+ counter += 1
262
+ dest = path.with_name(f"{path.name}.pre-{label}-{stamp}-{counter}.bak")
263
+ source = sqlite3.connect(str(path))
264
+ target = sqlite3.connect(str(dest))
265
+ try:
266
+ source.backup(target)
267
+ finally:
268
+ target.close()
269
+ source.close()
270
+ return str(dest)
271
+
272
+
273
+ def _verify(db_url: str, backup_path: Optional[str]) -> None:
274
+ if get_current_revision(db_url) != head_revision():
275
+ raise RuntimeError(f"migration finished but the database is not at {head_revision()}" + _restore_hint(backup_path))
276
+ path = _sqlite_file(db_url)
277
+ if path is not None:
278
+ conn = sqlite3.connect(str(path))
279
+ try:
280
+ result = conn.execute("PRAGMA integrity_check").fetchone()[0]
281
+ finally:
282
+ conn.close()
283
+ if result != "ok":
284
+ raise RuntimeError(f"integrity check failed after migrating: {result}" + _restore_hint(backup_path))
285
+
286
+
287
+ def _restore_hint(backup_path: Optional[str]) -> str:
288
+ return f" (a backup of the previous state is at {backup_path})" if backup_path else ""
289
+
290
+
291
+ def migrate_database(db_url: str | None = None, *, backup: bool = True, check: bool = False) -> MigrationResult:
292
+ """
293
+ Brings the database to head, safely (see the module docstring for what each state does).
294
+
295
+ `check=True` only reports what would happen and never changes anything. Otherwise a `diverged` or
296
+ `unknown` database raises `MigrationRefused` and is left exactly as it was. A SQLite file is backed up
297
+ (unless `backup=False`) before it is upgraded or stamped; a database that did not exist has nothing to
298
+ back up.
299
+ """
300
+ target_url = db_url or get_settings().db_url
301
+ status = get_schema_status(target_url)
302
+ head = status.head
303
+ path = _sqlite_file(target_url)
304
+
305
+ if check:
306
+ action = {
307
+ "fresh": "up-to-date", "current": "up-to-date", "behind": "needs-upgrade",
308
+ "unstamped": "needs-stamp", "diverged": "blocked", "unknown": "blocked",
309
+ }[status.state]
310
+ return MigrationResult(action, status.state, status.current, head, applied=list(status.pending), detail=status.detail)
311
+
312
+ if status.state == "current":
313
+ return MigrationResult("up-to-date", "current", status.current, head, detail=status.detail)
314
+ if status.state in ("diverged", "unknown"):
315
+ raise MigrationRefused(
316
+ status.detail + ". Nothing was changed. "
317
+ + ("Back the file up and rebuild it by re-indexing, or migrate it by hand." if status.state == "diverged"
318
+ else "Upgrade CodePulse instead of migrating this database.")
319
+ )
320
+
321
+ backup_path: Optional[str] = None
322
+ if status.state == "fresh":
323
+ run_migrations(target_url)
324
+ _verify(target_url, None)
325
+ return MigrationResult("created", "fresh", None, head, applied=[head], detail="created the schema")
326
+
327
+ if backup and path is not None:
328
+ backup_path = _backup_sqlite(path, status.current or "unstamped")
329
+ if status.state == "behind":
330
+ run_migrations(target_url)
331
+ _verify(target_url, backup_path)
332
+ return MigrationResult("upgraded", "behind", status.current, head, backup_path, list(status.pending), f"upgraded {status.current} -> {head}")
333
+ # unstamped and matching the models, apart from tables it does not have yet
334
+ created: list[str] = []
335
+ if status.missing_tables:
336
+ engine = create_engine(target_url)
337
+ try:
338
+ Base.metadata.create_all(engine, tables=[Base.metadata.tables[name] for name in status.missing_tables])
339
+ finally:
340
+ engine.dispose()
341
+ created = list(status.missing_tables)
342
+ stamp_database(target_url)
343
+ _verify(target_url, backup_path)
344
+ detail = f"stamped at {head} (its schema matches the models)"
345
+ if created:
346
+ detail = f"created missing table(s) {', '.join(created)} and stamped at {head}"
347
+ return MigrationResult("stamped", "unstamped", None, head, backup_path, [], detail)
@@ -0,0 +1 @@
1
+ Generic single-database configuration.
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from logging.config import fileConfig
4
+ from sqlalchemy import engine_from_config, pool
5
+ from alembic import context
6
+
7
+ from codepulse.config import get_settings
8
+ from codepulse.storage.db import Base
9
+
10
+ # this is the Alembic Config object, which provides
11
+ # access to the values within the .ini file in use.
12
+ config = context.config
13
+
14
+ # Interpret the config file for Python logging.
15
+ if config.config_file_name is not None:
16
+ fileConfig(config.config_file_name)
17
+
18
+ # Model metadata for autogenerate support
19
+ target_metadata = Base.metadata
20
+
21
+
22
+ def get_db_url() -> str:
23
+ """Returns the database URL from config file option, or defaults to CodePulseConfig."""
24
+ url = config.get_main_option("sqlalchemy.url")
25
+ if not url or url.strip() == "" or url == "driver://user:pass@localhost/dbname":
26
+ url = get_settings().db_url
27
+ return url
28
+
29
+
30
+ def run_migrations_offline() -> None:
31
+ """Run migrations in 'offline' mode."""
32
+ url = get_db_url()
33
+ context.configure(
34
+ url=url,
35
+ target_metadata=target_metadata,
36
+ literal_binds=True,
37
+ dialect_opts={"paramstyle": "named"},
38
+ render_as_batch=True,
39
+ )
40
+
41
+ with context.begin_transaction():
42
+ context.run_migrations()
43
+
44
+
45
+ def run_migrations_online() -> None:
46
+ """Run migrations in 'online' mode."""
47
+ configuration = config.get_section(config.config_ini_section, {})
48
+ configuration["sqlalchemy.url"] = get_db_url()
49
+
50
+ connectable = engine_from_config(
51
+ configuration,
52
+ prefix="sqlalchemy.",
53
+ poolclass=pool.NullPool,
54
+ )
55
+
56
+ with connectable.connect() as connection:
57
+ context.configure(
58
+ connection=connection,
59
+ target_metadata=target_metadata,
60
+ render_as_batch=True,
61
+ )
62
+
63
+ with context.begin_transaction():
64
+ context.run_migrations()
65
+
66
+
67
+ if context.is_offline_mode():
68
+ run_migrations_offline()
69
+ else:
70
+ run_migrations_online()
@@ -0,0 +1,28 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,82 @@
1
+ """add_base_classes_and_relationship_type
2
+
3
+ Revision ID: 135a7b064ea7
4
+ Revises: 2cd38eb12a7e
5
+ Create Date: 2026-09-19 00:00:00.000000
6
+
7
+ Phase 4.1 (roadmap §37 item 4.1: "Base-class/inheritance extraction
8
+ (Python + JS/TS)"; §29 Data Model: "Symbol | Yes (exists, needs upsert
9
+ key + base-class field) | Core"). Architecture approved explicitly by
10
+ the user before implementation (see CODEPULSE_PROGRESS.md): raw
11
+ base-class text lives on `Symbol`/`symbols`; resolved inheritance
12
+ relationships are persisted as `CallEdge`/`call_edges` rows,
13
+ distinguished from ordinary call edges by a new `relationship_type`
14
+ column, reusing that table's existing `repo_id` scoping, `confidence`
15
+ vocabulary, and Phase 3.5 incremental-update machinery rather than
16
+ introducing a second persistence mechanism.
17
+
18
+ This migration is purely additive and non-destructive:
19
+
20
+ 1. Adds `symbols.base_classes` (nullable `TEXT`, JSON-serialized
21
+ `list[str]`, the same "Text column holding a JSON-serialized list,
22
+ parsed in application code" convention `EmbeddingModel.
23
+ embedding_json` already uses -- not a new serialization approach).
24
+ `NULL` for every existing row (none of them were ever asked for
25
+ this data before this migration existed -- not a guess, an honest
26
+ absence, exactly like `confidence`'s own precedent in
27
+ `f6e481353553`) and for every non-CLASS symbol going forward.
28
+ Every existing row's `id`/`name`/`start_line`/`body_hash`/etc. is
29
+ untouched.
30
+
31
+ 2. Adds `call_edges.relationship_type` (`VARCHAR(20)`, `NOT NULL`,
32
+ `DEFAULT 'calls'`). Every one of this table's existing rows is
33
+ UNAMBIGUOUSLY a call -- nothing else has ever been written to it
34
+ (confirmed: no code path existed to write anything else before
35
+ this migration) -- so `'calls'` is the correct backfill for every
36
+ existing row, not a guess in the way `confidence`'s or `repo_id`'s
37
+ own backfills had to be conservative about ambiguity. SQLite
38
+ applies the column default to every existing row automatically
39
+ when a `NOT NULL DEFAULT` column is added.
40
+
41
+ Both additions use Alembic "batch" mode (SQLite table-rebuild-under-
42
+ the-hood), matching every prior column-addition migration in this
43
+ project's history, even though a plain `ALTER TABLE ADD COLUMN` with a
44
+ constant default would likely work directly here -- consistency with
45
+ established migration style, not a functional requirement.
46
+ """
47
+ from typing import Sequence, Union
48
+
49
+ from alembic import op
50
+ import sqlalchemy as sa
51
+
52
+
53
+ # revision identifiers, used by Alembic.
54
+ revision: str = '135a7b064ea7'
55
+ down_revision: Union[str, Sequence[str], None] = '2cd38eb12a7e'
56
+ branch_labels: Union[str, Sequence[str], None] = None
57
+ depends_on: Union[str, Sequence[str], None] = None
58
+
59
+
60
+ def upgrade() -> None:
61
+ """Upgrade schema."""
62
+ with op.batch_alter_table('symbols', schema=None) as batch_op:
63
+ batch_op.add_column(sa.Column('base_classes', sa.Text(), nullable=True))
64
+
65
+ with op.batch_alter_table('call_edges', schema=None) as batch_op:
66
+ batch_op.add_column(
67
+ sa.Column(
68
+ 'relationship_type',
69
+ sa.String(length=20),
70
+ nullable=False,
71
+ server_default='calls',
72
+ )
73
+ )
74
+
75
+
76
+ def downgrade() -> None:
77
+ """Downgrade schema."""
78
+ with op.batch_alter_table('call_edges', schema=None) as batch_op:
79
+ batch_op.drop_column('relationship_type')
80
+
81
+ with op.batch_alter_table('symbols', schema=None) as batch_op:
82
+ batch_op.drop_column('base_classes')
@@ -0,0 +1,106 @@
1
+ """add_file_content_hash_and_natural_key_uniqueness
2
+
3
+ Revision ID: 20fc2d0607e2
4
+ Revises: 21a7cb7f1a85
5
+ Create Date: 2026-09-05 12:55:39.151635
6
+
7
+ Phase 0.3 (Repository/File upsert with content_hash).
8
+
9
+ This migration is additive and non-destructive:
10
+
11
+ - Adds `files.content_hash` (nullable). Existing File rows predate content
12
+ hashing and only ever stored path/language/size/line-count metadata --
13
+ the actual source bytes that were indexed were never persisted anywhere
14
+ in the database (Symbol.body only stores per-symbol snippets, not whole
15
+ files), so there is no reliable, non-fabricated way to compute a
16
+ historically-accurate hash for pre-existing rows during this migration.
17
+ Per the Phase 0.3 spec, we do NOT invent/backfill a value for them --
18
+ `content_hash` is left NULL for rows that predate this migration. Each
19
+ such row gets a real, accurate content_hash automatically the next time
20
+ its file is re-indexed by the application (see
21
+ IndexingEngine._persist_to_db), at which point the current on-disk
22
+ content is genuinely available to hash.
23
+
24
+ - Adds a UNIQUE constraint on `files(repo_id, path)` and on
25
+ `repositories(url)`, enforcing the natural-key invariants the Phase 0.3
26
+ upsert logic depends on. A pre-flight check aborts the migration (rather
27
+ than failing partway through a batch table-recreate, or silently
28
+ accepting an inconsistent state) if any pre-existing duplicate rows
29
+ would violate these constraints.
30
+
31
+ No table is dropped or recreated destructively and no existing row is
32
+ deleted or overwritten. SQLAlchemy/Alembic "batch" mode is used because
33
+ SQLite requires a copy-and-swap under the hood to add a constraint to an
34
+ existing table -- all existing rows and columns are carried over unchanged
35
+ by that process.
36
+ """
37
+ from typing import Sequence, Union
38
+
39
+ from alembic import op
40
+ import sqlalchemy as sa
41
+
42
+
43
+ # revision identifiers, used by Alembic.
44
+ revision: str = '20fc2d0607e2'
45
+ down_revision: Union[str, Sequence[str], None] = '21a7cb7f1a85'
46
+ branch_labels: Union[str, Sequence[str], None] = None
47
+ depends_on: Union[str, Sequence[str], None] = None
48
+
49
+
50
+ def _assert_no_duplicates() -> None:
51
+ """
52
+ Pre-flight safety check: refuse to add the UNIQUE constraints if any
53
+ pre-existing data would violate them, rather than letting the batch
54
+ table-recreate fail halfway (or, on backends that don't error the same
55
+ way, silently accepting an inconsistent state).
56
+ """
57
+ bind = op.get_bind()
58
+
59
+ dup_repo_urls = bind.execute(
60
+ sa.text(
61
+ "SELECT url, COUNT(*) AS c FROM repositories "
62
+ "GROUP BY url HAVING COUNT(*) > 1"
63
+ )
64
+ ).fetchall()
65
+ if dup_repo_urls:
66
+ raise RuntimeError(
67
+ "Cannot add UNIQUE constraint on repositories.url: duplicate "
68
+ f"url values already exist: {[row[0] for row in dup_repo_urls]}. "
69
+ "Resolve duplicate Repository rows before applying this migration."
70
+ )
71
+
72
+ dup_files = bind.execute(
73
+ sa.text(
74
+ "SELECT repo_id, path, COUNT(*) AS c FROM files "
75
+ "GROUP BY repo_id, path HAVING COUNT(*) > 1"
76
+ )
77
+ ).fetchall()
78
+ if dup_files:
79
+ raise RuntimeError(
80
+ "Cannot add UNIQUE constraint on files(repo_id, path): duplicate "
81
+ f"(repo_id, path) pairs already exist: "
82
+ f"{[(row[0], row[1]) for row in dup_files]}. Resolve duplicate "
83
+ "File rows before applying this migration."
84
+ )
85
+
86
+
87
+ def upgrade() -> None:
88
+ """Upgrade schema."""
89
+ _assert_no_duplicates()
90
+
91
+ with op.batch_alter_table("files", schema=None) as batch_op:
92
+ batch_op.add_column(sa.Column("content_hash", sa.String(length=64), nullable=True))
93
+ batch_op.create_unique_constraint("uq_files_repo_id_path", ["repo_id", "path"])
94
+
95
+ with op.batch_alter_table("repositories", schema=None) as batch_op:
96
+ batch_op.create_unique_constraint("uq_repositories_url", ["url"])
97
+
98
+
99
+ def downgrade() -> None:
100
+ """Downgrade schema."""
101
+ with op.batch_alter_table("repositories", schema=None) as batch_op:
102
+ batch_op.drop_constraint("uq_repositories_url", type_="unique")
103
+
104
+ with op.batch_alter_table("files", schema=None) as batch_op:
105
+ batch_op.drop_constraint("uq_files_repo_id_path", type_="unique")
106
+ batch_op.drop_column("content_hash")