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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""initial_schema
|
|
2
|
+
|
|
3
|
+
Revision ID: 21a7cb7f1a85
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-09-01 23:26:31.067744
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = '21a7cb7f1a85'
|
|
16
|
+
down_revision: Union[str, Sequence[str], None] = None
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
# 1. Repositories
|
|
23
|
+
op.create_table(
|
|
24
|
+
'repositories',
|
|
25
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
26
|
+
sa.Column('team_id', sa.Integer(), nullable=False, server_default='1'),
|
|
27
|
+
sa.Column('name', sa.String(length=255), nullable=False),
|
|
28
|
+
sa.Column('url', sa.Text(), nullable=False),
|
|
29
|
+
sa.Column('branch', sa.String(length=100), nullable=True, server_default='main'),
|
|
30
|
+
sa.Column('last_sync', sa.DateTime(), nullable=True),
|
|
31
|
+
sa.Column('file_count', sa.Integer(), nullable=True, server_default='0'),
|
|
32
|
+
sa.Column('symbol_count', sa.Integer(), nullable=True, server_default='0'),
|
|
33
|
+
sa.PrimaryKeyConstraint('id'),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# 2. Files
|
|
37
|
+
op.create_table(
|
|
38
|
+
'files',
|
|
39
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
40
|
+
sa.Column('repo_id', sa.Integer(), nullable=False),
|
|
41
|
+
sa.Column('path', sa.Text(), nullable=False),
|
|
42
|
+
sa.Column('language', sa.String(length=50), nullable=False),
|
|
43
|
+
sa.Column('size_bytes', sa.Integer(), nullable=True, server_default='0'),
|
|
44
|
+
sa.Column('line_count', sa.Integer(), nullable=True, server_default='0'),
|
|
45
|
+
sa.Column('last_indexed', sa.DateTime(), nullable=True),
|
|
46
|
+
sa.ForeignKeyConstraint(['repo_id'], ['repositories.id'], ondelete='CASCADE'),
|
|
47
|
+
sa.PrimaryKeyConstraint('id'),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 3. Symbols
|
|
51
|
+
op.create_table(
|
|
52
|
+
'symbols',
|
|
53
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
54
|
+
sa.Column('file_id', sa.Integer(), nullable=False),
|
|
55
|
+
sa.Column('name', sa.String(length=255), nullable=False),
|
|
56
|
+
sa.Column('type', sa.String(length=50), nullable=False),
|
|
57
|
+
sa.Column('start_line', sa.Integer(), nullable=False),
|
|
58
|
+
sa.Column('end_line', sa.Integer(), nullable=False),
|
|
59
|
+
sa.Column('signature', sa.Text(), nullable=True, server_default=''),
|
|
60
|
+
sa.Column('docstring', sa.Text(), nullable=True),
|
|
61
|
+
sa.Column('body', sa.Text(), nullable=True, server_default=''),
|
|
62
|
+
sa.Column('body_hash', sa.String(length=64), nullable=True, server_default=''),
|
|
63
|
+
sa.Column('language', sa.String(length=50), nullable=False),
|
|
64
|
+
sa.Column('complexity', sa.Integer(), nullable=True, server_default='1'),
|
|
65
|
+
sa.ForeignKeyConstraint(['file_id'], ['files.id'], ondelete='CASCADE'),
|
|
66
|
+
sa.PrimaryKeyConstraint('id'),
|
|
67
|
+
)
|
|
68
|
+
op.create_index('ix_symbols_name', 'symbols', ['name'], unique=False)
|
|
69
|
+
|
|
70
|
+
# 4. Dependencies
|
|
71
|
+
op.create_table(
|
|
72
|
+
'dependencies',
|
|
73
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
74
|
+
sa.Column('source_symbol_name', sa.String(length=255), nullable=False),
|
|
75
|
+
sa.Column('source_file', sa.Text(), nullable=False),
|
|
76
|
+
sa.Column('target_symbol_name', sa.String(length=255), nullable=False),
|
|
77
|
+
sa.Column('target_file', sa.Text(), nullable=True),
|
|
78
|
+
sa.Column('call_count', sa.Integer(), nullable=True, server_default='1'),
|
|
79
|
+
sa.PrimaryKeyConstraint('id'),
|
|
80
|
+
)
|
|
81
|
+
op.create_index('ix_dependencies_source_symbol_name', 'dependencies', ['source_symbol_name'], unique=False)
|
|
82
|
+
op.create_index('ix_dependencies_target_symbol_name', 'dependencies', ['target_symbol_name'], unique=False)
|
|
83
|
+
|
|
84
|
+
# 5. Code Embeddings
|
|
85
|
+
op.create_table(
|
|
86
|
+
'code_embeddings',
|
|
87
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
88
|
+
sa.Column('symbol_id', sa.Integer(), nullable=False),
|
|
89
|
+
sa.Column('chunk_type', sa.String(length=50), nullable=True, server_default='signature_doc'),
|
|
90
|
+
sa.Column('embedding_json', sa.Text(), nullable=False),
|
|
91
|
+
sa.Column('chunk_content', sa.Text(), nullable=True, server_default=''),
|
|
92
|
+
sa.ForeignKeyConstraint(['symbol_id'], ['symbols.id'], ondelete='CASCADE'),
|
|
93
|
+
sa.PrimaryKeyConstraint('id'),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def downgrade() -> None:
|
|
98
|
+
op.drop_table('code_embeddings')
|
|
99
|
+
op.drop_index('ix_dependencies_target_symbol_name', table_name='dependencies')
|
|
100
|
+
op.drop_index('ix_dependencies_source_symbol_name', table_name='dependencies')
|
|
101
|
+
op.drop_table('dependencies')
|
|
102
|
+
op.drop_index('ix_symbols_name', table_name='symbols')
|
|
103
|
+
op.drop_table('symbols')
|
|
104
|
+
op.drop_table('files')
|
|
105
|
+
op.drop_table('repositories')
|
codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""add_call_edge_repo_id_isolation_fix
|
|
2
|
+
|
|
3
|
+
Revision ID: 2cd38eb12a7e
|
|
4
|
+
Revises: f6e481353553
|
|
5
|
+
Create Date: 2026-09-16 00:00:00.000000
|
|
6
|
+
|
|
7
|
+
Repository-isolation fix (not a named roadmap sprint item -- a real,
|
|
8
|
+
confirmed correctness/security bug found and disclosed by a Phase 3.4
|
|
9
|
+
pre-commit audit, fixed on the user's explicit instruction before
|
|
10
|
+
starting Phase 3.5).
|
|
11
|
+
|
|
12
|
+
`call_edges` (né `dependencies`/`DependencyModel`) has never carried a
|
|
13
|
+
repository association, since its Phase 0.4 introduction. `GET
|
|
14
|
+
/v1/symbols/{id}` queries it by symbol name alone -- confirmed LIVE, in
|
|
15
|
+
this session, that a caller authorized for one repository can receive
|
|
16
|
+
another, unrelated repository's call-graph edges (symbol name + file
|
|
17
|
+
path) whenever symbol names happen to collide across repositories. This
|
|
18
|
+
directly violates the roadmap's "Repository isolation" permanent
|
|
19
|
+
invariant (CLAUDE.md §4) and §15/§30's "every query... filtered by
|
|
20
|
+
repository_id" rule.
|
|
21
|
+
|
|
22
|
+
This migration:
|
|
23
|
+
|
|
24
|
+
1. Adds a new, nullable `repo_id` column (FK to `repositories.id`,
|
|
25
|
+
`ondelete='CASCADE'`, indexed -- the exact same pattern every other
|
|
26
|
+
`repo_id` column in this schema already uses), via batch mode
|
|
27
|
+
(SQLite requires a table rebuild to add a real FK constraint after
|
|
28
|
+
the fact, the same reason `20fc2d0607e2` used batch mode to add a
|
|
29
|
+
constraint to an existing table).
|
|
30
|
+
2. Backfills existing rows conservatively and HONESTLY -- never a
|
|
31
|
+
guess (CLAUDE.md: no fabricated metrics). For each distinct
|
|
32
|
+
`source_file` value already present in `call_edges`, this looks up
|
|
33
|
+
every `files.path` row that matches it: if exactly ONE distinct
|
|
34
|
+
`repo_id` results (the common case -- most databases have only one,
|
|
35
|
+
or a small number of, repositories, and most file paths are not
|
|
36
|
+
duplicated verbatim across them), every matching row is backfilled
|
|
37
|
+
with that `repo_id`. If the match is AMBIGUOUS (two or more
|
|
38
|
+
repositories happen to have a file at that exact same relative
|
|
39
|
+
path) or there is NO match at all (the file was since renamed,
|
|
40
|
+
moved, or deleted), the row is left with `repo_id = NULL` rather
|
|
41
|
+
than guessing -- a `NULL`-`repo_id` row is simply invisible to every
|
|
42
|
+
repository-scoped query from now on (a real, disclosed, one-time
|
|
43
|
+
completeness gap for that specific historical edge, not a security
|
|
44
|
+
problem -- the opposite of the bug being fixed), until its
|
|
45
|
+
repository is next re-indexed and persists a fresh, correctly
|
|
46
|
+
`repo_id`-tagged replacement edge.
|
|
47
|
+
|
|
48
|
+
Non-destructive: no existing row is deleted, and no existing column's
|
|
49
|
+
value is changed. `IndexingEngine._persist_to_db` now sets `repo_id` on
|
|
50
|
+
every NEW `CallEdge` row going forward (see that method's own comment) --
|
|
51
|
+
this migration only concerns rows that already existed before this fix.
|
|
52
|
+
"""
|
|
53
|
+
from typing import Sequence, Union
|
|
54
|
+
|
|
55
|
+
from alembic import op
|
|
56
|
+
import sqlalchemy as sa
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# revision identifiers, used by Alembic.
|
|
60
|
+
revision: str = '2cd38eb12a7e'
|
|
61
|
+
down_revision: Union[str, Sequence[str], None] = 'f6e481353553'
|
|
62
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
63
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def upgrade() -> None:
|
|
67
|
+
"""Upgrade schema."""
|
|
68
|
+
with op.batch_alter_table('call_edges', schema=None) as batch_op:
|
|
69
|
+
batch_op.add_column(sa.Column('repo_id', sa.Integer(), nullable=True))
|
|
70
|
+
batch_op.create_foreign_key(
|
|
71
|
+
'fk_call_edges_repo_id_repositories', 'repositories', ['repo_id'], ['id'], ondelete='CASCADE'
|
|
72
|
+
)
|
|
73
|
+
op.create_index(op.f('ix_call_edges_repo_id'), 'call_edges', ['repo_id'], unique=False)
|
|
74
|
+
|
|
75
|
+
_backfill_repo_id()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _backfill_repo_id() -> None:
|
|
79
|
+
"""
|
|
80
|
+
Conservative, honest backfill -- see this module's own docstring for
|
|
81
|
+
the exact rule. Only ever sets `repo_id` when exactly one repository
|
|
82
|
+
can be identified for a given `source_file` value; leaves it `NULL`
|
|
83
|
+
otherwise (ambiguous match, or no match at all).
|
|
84
|
+
"""
|
|
85
|
+
bind = op.get_bind()
|
|
86
|
+
|
|
87
|
+
distinct_files = bind.execute(sa.text("SELECT DISTINCT source_file FROM call_edges")).fetchall()
|
|
88
|
+
for (source_file,) in distinct_files:
|
|
89
|
+
matching_repo_ids = bind.execute(
|
|
90
|
+
sa.text("SELECT DISTINCT repo_id FROM files WHERE path = :p"),
|
|
91
|
+
{"p": source_file},
|
|
92
|
+
).fetchall()
|
|
93
|
+
if len(matching_repo_ids) == 1:
|
|
94
|
+
bind.execute(
|
|
95
|
+
sa.text(
|
|
96
|
+
"UPDATE call_edges SET repo_id = :r "
|
|
97
|
+
"WHERE source_file = :p AND repo_id IS NULL"
|
|
98
|
+
),
|
|
99
|
+
{"r": matching_repo_ids[0][0], "p": source_file},
|
|
100
|
+
)
|
|
101
|
+
# else: 0 matches (file since renamed/moved/deleted) or 2+
|
|
102
|
+
# distinct repo_id matches (the same relative path exists in
|
|
103
|
+
# more than one repository -- genuinely ambiguous) -- leave
|
|
104
|
+
# NULL, honestly, rather than guessing.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def downgrade() -> None:
|
|
108
|
+
"""Downgrade schema."""
|
|
109
|
+
op.drop_index(op.f('ix_call_edges_repo_id'), table_name='call_edges')
|
|
110
|
+
with op.batch_alter_table('call_edges', schema=None) as batch_op:
|
|
111
|
+
batch_op.drop_constraint('fk_call_edges_repo_id_repositories', type_='foreignkey')
|
|
112
|
+
batch_op.drop_column('repo_id')
|
codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""add_symbol_natural_key_uniqueness
|
|
2
|
+
|
|
3
|
+
Revision ID: 3ba396e9c78f
|
|
4
|
+
Revises: 20fc2d0607e2
|
|
5
|
+
Create Date: 2026-09-05 13:29:49.980342
|
|
6
|
+
|
|
7
|
+
Phase 0.4 (Symbol upsert by (file_id, name, start_line)).
|
|
8
|
+
|
|
9
|
+
This migration is additive and non-destructive:
|
|
10
|
+
|
|
11
|
+
- Adds a UNIQUE constraint on `symbols(file_id, name, start_line)`,
|
|
12
|
+
enforcing the natural-key invariant the Phase 0.4 upsert logic depends
|
|
13
|
+
on (roadmap §8: "Symbol rows, upserted by (file_id, name, start_line)
|
|
14
|
+
key"). A pre-flight check aborts the migration (rather than failing
|
|
15
|
+
partway through a batch table-recreate, or silently accepting an
|
|
16
|
+
inconsistent state) if any pre-existing duplicate rows would violate
|
|
17
|
+
this constraint.
|
|
18
|
+
|
|
19
|
+
- No column is added and no data is transformed -- `symbols.body_hash`
|
|
20
|
+
already existed from the initial schema and is reused as-is for
|
|
21
|
+
Phase 0.4's change-detection logic (no new hashing convention is
|
|
22
|
+
introduced by this migration).
|
|
23
|
+
|
|
24
|
+
No table is dropped or recreated destructively and no existing row is
|
|
25
|
+
deleted, inserted, or overwritten. SQLAlchemy/Alembic "batch" mode is used
|
|
26
|
+
because SQLite requires a copy-and-swap under the hood to add a constraint
|
|
27
|
+
to an existing table -- all existing rows and columns are carried over
|
|
28
|
+
unchanged by that process.
|
|
29
|
+
"""
|
|
30
|
+
from typing import Sequence, Union
|
|
31
|
+
|
|
32
|
+
from alembic import op
|
|
33
|
+
import sqlalchemy as sa
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# revision identifiers, used by Alembic.
|
|
37
|
+
revision: str = '3ba396e9c78f'
|
|
38
|
+
down_revision: Union[str, Sequence[str], None] = '20fc2d0607e2'
|
|
39
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
40
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _assert_no_duplicates() -> None:
|
|
44
|
+
"""
|
|
45
|
+
Pre-flight safety check: refuse to add the UNIQUE constraint if any
|
|
46
|
+
pre-existing data would violate it, rather than letting the batch
|
|
47
|
+
table-recreate fail halfway (or, on backends that don't error the same
|
|
48
|
+
way, silently accepting an inconsistent state).
|
|
49
|
+
"""
|
|
50
|
+
bind = op.get_bind()
|
|
51
|
+
|
|
52
|
+
dup_symbols = bind.execute(
|
|
53
|
+
sa.text(
|
|
54
|
+
"SELECT file_id, name, start_line, COUNT(*) AS c FROM symbols "
|
|
55
|
+
"GROUP BY file_id, name, start_line HAVING COUNT(*) > 1"
|
|
56
|
+
)
|
|
57
|
+
).fetchall()
|
|
58
|
+
if dup_symbols:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"Cannot add UNIQUE constraint on symbols(file_id, name, "
|
|
61
|
+
"start_line): duplicate rows already exist: "
|
|
62
|
+
f"{[(row[0], row[1], row[2]) for row in dup_symbols]}. Resolve "
|
|
63
|
+
"duplicate Symbol rows before applying this migration."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def upgrade() -> None:
|
|
68
|
+
"""Upgrade schema."""
|
|
69
|
+
_assert_no_duplicates()
|
|
70
|
+
|
|
71
|
+
with op.batch_alter_table("symbols", schema=None) as batch_op:
|
|
72
|
+
batch_op.create_unique_constraint(
|
|
73
|
+
"uq_symbols_file_id_name_start_line", ["file_id", "name", "start_line"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def downgrade() -> None:
|
|
78
|
+
"""Downgrade schema."""
|
|
79
|
+
with op.batch_alter_table("symbols", schema=None) as batch_op:
|
|
80
|
+
batch_op.drop_constraint("uq_symbols_file_id_name_start_line", type_="unique")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""add_embedding_persistence_fields
|
|
2
|
+
|
|
3
|
+
Revision ID: 49572f834366
|
|
4
|
+
Revises: 3ba396e9c78f
|
|
5
|
+
Create Date: 2026-09-05 14:19:04.742535
|
|
6
|
+
|
|
7
|
+
Phase 0.5 (Embedding persistence + hydration).
|
|
8
|
+
|
|
9
|
+
This migration is additive and non-destructive:
|
|
10
|
+
|
|
11
|
+
- Adds `code_embeddings.chunk_index` (NOT NULL, default 0), completing the
|
|
12
|
+
natural key `(symbol_id, chunk_type, chunk_index)` specified by the
|
|
13
|
+
roadmap's Phase 0 idempotency rule (§8). Every pre-existing row
|
|
14
|
+
implicitly becomes chunk_index=0 (there was no concept of multiple
|
|
15
|
+
indexed chunks per (symbol, chunk_type) before this migration).
|
|
16
|
+
- Adds `code_embeddings.content_hash` (nullable) for change detection --
|
|
17
|
+
left NULL for pre-existing rows, for the same reason `files.content_hash`
|
|
18
|
+
was left NULL in the Phase 0.3 migration: the exact chunk text that
|
|
19
|
+
produced a historical row was never separately hashed, so no
|
|
20
|
+
historically-accurate hash can be reconstructed without fabricating one.
|
|
21
|
+
Each such row gets a real hash the next time its symbol is re-indexed.
|
|
22
|
+
- Adds `code_embeddings.embedding_model` and `code_embeddings.embedding_dim`
|
|
23
|
+
(both nullable), recording which model/dimension produced a vector so
|
|
24
|
+
hydration can detect a model or dimension change and skip loading an
|
|
25
|
+
incompatible vector rather than silently corrupting the in-memory
|
|
26
|
+
vector index.
|
|
27
|
+
- Adds a UNIQUE constraint on `code_embeddings(symbol_id, chunk_type,
|
|
28
|
+
chunk_index)`. A pre-flight check aborts the migration (rather than
|
|
29
|
+
failing partway through a batch table-recreate, or silently accepting
|
|
30
|
+
an inconsistent state) if any pre-existing (symbol_id, chunk_type) pair
|
|
31
|
+
already has more than one row -- such rows would collide once they all
|
|
32
|
+
default to chunk_index=0.
|
|
33
|
+
|
|
34
|
+
No table is dropped or recreated destructively and no existing row is
|
|
35
|
+
deleted or has its embedding_json/chunk_content overwritten. SQLAlchemy/
|
|
36
|
+
Alembic "batch" mode is used because SQLite requires a copy-and-swap under
|
|
37
|
+
the hood to add columns and a constraint to an existing table -- all
|
|
38
|
+
existing rows are carried over unchanged by that process.
|
|
39
|
+
"""
|
|
40
|
+
from typing import Sequence, Union
|
|
41
|
+
|
|
42
|
+
from alembic import op
|
|
43
|
+
import sqlalchemy as sa
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# revision identifiers, used by Alembic.
|
|
47
|
+
revision: str = '49572f834366'
|
|
48
|
+
down_revision: Union[str, Sequence[str], None] = '3ba396e9c78f'
|
|
49
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
50
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _assert_no_duplicates() -> None:
|
|
54
|
+
"""
|
|
55
|
+
Pre-flight safety check: refuse to add the UNIQUE constraint if any
|
|
56
|
+
pre-existing data would violate it once every row defaults to
|
|
57
|
+
chunk_index=0, rather than letting the batch table-recreate fail
|
|
58
|
+
halfway (or, on backends that don't error the same way, silently
|
|
59
|
+
accepting an inconsistent state).
|
|
60
|
+
"""
|
|
61
|
+
bind = op.get_bind()
|
|
62
|
+
|
|
63
|
+
dup = bind.execute(
|
|
64
|
+
sa.text(
|
|
65
|
+
"SELECT symbol_id, chunk_type, COUNT(*) AS c FROM code_embeddings "
|
|
66
|
+
"GROUP BY symbol_id, chunk_type HAVING COUNT(*) > 1"
|
|
67
|
+
)
|
|
68
|
+
).fetchall()
|
|
69
|
+
if dup:
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
"Cannot add UNIQUE constraint on code_embeddings(symbol_id, "
|
|
72
|
+
"chunk_type, chunk_index): duplicate (symbol_id, chunk_type) "
|
|
73
|
+
"pairs already exist, and would collide once defaulted to "
|
|
74
|
+
f"chunk_index=0: {[(row[0], row[1]) for row in dup]}. Resolve "
|
|
75
|
+
"duplicate embedding rows before applying this migration."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def upgrade() -> None:
|
|
80
|
+
"""Upgrade schema."""
|
|
81
|
+
_assert_no_duplicates()
|
|
82
|
+
|
|
83
|
+
with op.batch_alter_table("code_embeddings", schema=None) as batch_op:
|
|
84
|
+
batch_op.add_column(
|
|
85
|
+
sa.Column("chunk_index", sa.Integer(), nullable=False, server_default="0")
|
|
86
|
+
)
|
|
87
|
+
batch_op.add_column(sa.Column("content_hash", sa.String(length=64), nullable=True))
|
|
88
|
+
batch_op.add_column(sa.Column("embedding_model", sa.String(length=150), nullable=True))
|
|
89
|
+
batch_op.add_column(sa.Column("embedding_dim", sa.Integer(), nullable=True))
|
|
90
|
+
batch_op.create_unique_constraint(
|
|
91
|
+
"uq_code_embeddings_symbol_chunk", ["symbol_id", "chunk_type", "chunk_index"]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def downgrade() -> None:
|
|
96
|
+
"""Downgrade schema."""
|
|
97
|
+
with op.batch_alter_table("code_embeddings", schema=None) as batch_op:
|
|
98
|
+
batch_op.drop_constraint("uq_code_embeddings_symbol_chunk", type_="unique")
|
|
99
|
+
batch_op.drop_column("embedding_dim")
|
|
100
|
+
batch_op.drop_column("embedding_model")
|
|
101
|
+
batch_op.drop_column("content_hash")
|
|
102
|
+
batch_op.drop_column("chunk_index")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""add_module_edges
|
|
2
|
+
|
|
3
|
+
Revision ID: 7c3e5a91d2f4
|
|
4
|
+
Revises: b9014c049e33
|
|
5
|
+
Create Date: 2026-10-12 00:00:00.000000
|
|
6
|
+
|
|
7
|
+
Persists the file-level import graph so module structure survives a
|
|
8
|
+
restart (CODEPULSE_FUTURE_CHANGES.md item 15; user-approved change to
|
|
9
|
+
Phase 4.3's original "no persistence" scope).
|
|
10
|
+
|
|
11
|
+
`GET /v1/repositories/{id}/modules`, the CLI `modules` command, the MCP
|
|
12
|
+
`module_structure` tool and the VS Code Module Structure view all read
|
|
13
|
+
`IndexingEngine.analyze_module_structure`, which built its graph from
|
|
14
|
+
`parse_results`, an in-memory cache. After any server restart module
|
|
15
|
+
structure was therefore empty (0 clusters) until the repository was
|
|
16
|
+
re-indexed, because imports were not stored anywhere. The call graph
|
|
17
|
+
(`call_edges`) and the symbol map (`symbols`/`files`) could be restored from
|
|
18
|
+
existing tables; this one could not.
|
|
19
|
+
|
|
20
|
+
This migration is purely additive and non-destructive: it only CREATES
|
|
21
|
+
`module_edges` (one row per resolved source-file -> target-file import edge,
|
|
22
|
+
plus one row with a NULL `target_file` for each file that has no resolved
|
|
23
|
+
imports, so the node set is stored too; `repo_id` NOT NULL with ON DELETE
|
|
24
|
+
CASCADE, unique per (repo_id, source_file, target_file)). No existing table,
|
|
25
|
+
column or row is touched, so there is no pre-existing-data failure mode and
|
|
26
|
+
no backfill: a repository's rows are written the next time it is indexed
|
|
27
|
+
(until then its module structure behaves exactly as it did before this
|
|
28
|
+
migration -- cold after a restart, never a misleading partial graph).
|
|
29
|
+
"""
|
|
30
|
+
from typing import Sequence, Union
|
|
31
|
+
|
|
32
|
+
from alembic import op
|
|
33
|
+
import sqlalchemy as sa
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# revision identifiers, used by Alembic.
|
|
37
|
+
revision: str = '7c3e5a91d2f4'
|
|
38
|
+
down_revision: Union[str, Sequence[str], None] = 'b9014c049e33'
|
|
39
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
40
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def upgrade() -> None:
|
|
44
|
+
"""Upgrade schema."""
|
|
45
|
+
op.create_table(
|
|
46
|
+
'module_edges',
|
|
47
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
48
|
+
sa.Column('repo_id', sa.Integer(), nullable=False),
|
|
49
|
+
sa.Column('source_file', sa.Text(), nullable=False),
|
|
50
|
+
sa.Column('target_file', sa.Text(), nullable=True),
|
|
51
|
+
sa.ForeignKeyConstraint(['repo_id'], ['repositories.id'], ondelete='CASCADE'),
|
|
52
|
+
sa.PrimaryKeyConstraint('id'),
|
|
53
|
+
sa.UniqueConstraint('repo_id', 'source_file', 'target_file', name='uq_module_edges_repo_source_target'),
|
|
54
|
+
)
|
|
55
|
+
op.create_index(op.f('ix_module_edges_repo_id'), 'module_edges', ['repo_id'], unique=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def downgrade() -> None:
|
|
59
|
+
"""Downgrade schema."""
|
|
60
|
+
op.drop_index(op.f('ix_module_edges_repo_id'), table_name='module_edges')
|
|
61
|
+
op.drop_table('module_edges')
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""canonicalize_repository_urls
|
|
2
|
+
|
|
3
|
+
Revision ID: a41f0d2c6b8e
|
|
4
|
+
Revises: 7c3e5a91d2f4
|
|
5
|
+
Create Date: 2026-10-14 00:00:00.000000
|
|
6
|
+
|
|
7
|
+
Repository identity (CODEPULSE_FUTURE_CHANGES.md item 2; user-approved).
|
|
8
|
+
|
|
9
|
+
A repository was identified by the exact string it was first indexed under, so
|
|
10
|
+
the same directory spelled with backslashes vs forward slashes, a trailing
|
|
11
|
+
separator, `..` segments or a lowercase vs uppercase Windows drive letter became
|
|
12
|
+
two repositories. From this revision on the application stores and compares ONE
|
|
13
|
+
canonical form (see `codepulse.repository_identity`); this migration brings the
|
|
14
|
+
rows that already exist into that form.
|
|
15
|
+
|
|
16
|
+
DATA-ONLY and non-destructive: no table, column or constraint changes, nothing is
|
|
17
|
+
deleted and nothing is merged.
|
|
18
|
+
- A row whose `url` is a LOCAL PATH is rewritten to its canonical form
|
|
19
|
+
(absolute, `..` resolved, symlinks resolved where the path exists, uppercase
|
|
20
|
+
drive letter on Windows, no trailing separator).
|
|
21
|
+
- Anything that is not a local path (`https://...`, `ssh://...`, `file:///...`)
|
|
22
|
+
is left exactly as it is.
|
|
23
|
+
- If another row already holds the canonical form -- a DUPLICATE that predates
|
|
24
|
+
this fix -- the row is left unchanged and the pair is logged as a warning.
|
|
25
|
+
The two rows each own their own files, symbols, embeddings, jobs and API-key
|
|
26
|
+
grants, so merging them is a decision for a person, not a migration. The
|
|
27
|
+
application still finds the lowest-id row for either spelling.
|
|
28
|
+
|
|
29
|
+
The canonicalization is written out here (not imported) so this migration keeps
|
|
30
|
+
doing what it did on the day it was written even if the application's rule later
|
|
31
|
+
changes. If this runs on a machine where the paths do not exist (a restored
|
|
32
|
+
backup), `realpath` cannot resolve symlinks or fix the case of components, so the
|
|
33
|
+
result is the normalized absolute path with an uppercase drive letter; rows are
|
|
34
|
+
only ever rewritten to a form no other row holds.
|
|
35
|
+
|
|
36
|
+
Downgrade is a no-op: the original spellings were not recorded and cannot be
|
|
37
|
+
restored, and the canonical form is valid under the previous schema too.
|
|
38
|
+
"""
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
from typing import Sequence, Union
|
|
43
|
+
|
|
44
|
+
from alembic import op
|
|
45
|
+
import sqlalchemy as sa
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# revision identifiers, used by Alembic.
|
|
49
|
+
revision: str = 'a41f0d2c6b8e'
|
|
50
|
+
down_revision: Union[str, Sequence[str], None] = '7c3e5a91d2f4'
|
|
51
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
52
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("alembic.runtime.migration")
|
|
55
|
+
|
|
56
|
+
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _canonical(url: str) -> str:
|
|
60
|
+
if not url or _URL_SCHEME.match(url):
|
|
61
|
+
return url
|
|
62
|
+
resolved = os.path.realpath(os.path.abspath(url))
|
|
63
|
+
if os.name == "nt" and len(resolved) >= 2 and resolved[1] == ":":
|
|
64
|
+
resolved = resolved[0].upper() + resolved[1:]
|
|
65
|
+
return resolved
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def upgrade() -> None:
|
|
69
|
+
"""Upgrade data."""
|
|
70
|
+
bind = op.get_bind()
|
|
71
|
+
rows = bind.execute(sa.text("SELECT id, url FROM repositories ORDER BY id")).fetchall()
|
|
72
|
+
taken = {url: repo_id for repo_id, url in rows}
|
|
73
|
+
rewritten = 0
|
|
74
|
+
for repo_id, url in rows:
|
|
75
|
+
canonical = _canonical(url)
|
|
76
|
+
if canonical == url:
|
|
77
|
+
continue
|
|
78
|
+
holder = taken.get(canonical)
|
|
79
|
+
if holder is not None and holder != repo_id:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"repository %s (%r) and repository %s both name %r; left unchanged, merge them by hand if they are the same project",
|
|
82
|
+
repo_id, url, holder, canonical,
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
bind.execute(sa.text("UPDATE repositories SET url = :u WHERE id = :i"), {"u": canonical, "i": repo_id})
|
|
86
|
+
del taken[url]
|
|
87
|
+
taken[canonical] = repo_id
|
|
88
|
+
rewritten += 1
|
|
89
|
+
if rewritten:
|
|
90
|
+
logger.info("canonicalized %d repository url(s)", rewritten)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def downgrade() -> None:
|
|
94
|
+
"""Downgrade: nothing to undo (the original spellings were not recorded)."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""add_api_key_model
|
|
2
|
+
|
|
3
|
+
Revision ID: b0bdd63949f4
|
|
4
|
+
Revises: 49572f834366
|
|
5
|
+
Create Date: 2026-09-08 20:50:33.127639
|
|
6
|
+
|
|
7
|
+
Phase 1.1 (roadmap §37 item 1.1: "APIKey model + hashing + creation flow").
|
|
8
|
+
|
|
9
|
+
This migration is purely additive and non-destructive: it creates two new
|
|
10
|
+
tables (`api_keys`, `api_key_repositories`) and touches no existing table,
|
|
11
|
+
column, or row. No `codepulse.db` data is read, transformed, or migrated.
|
|
12
|
+
|
|
13
|
+
- `api_keys`: one row per API key. Only `key_hash` (a SHA-256 hex digest)
|
|
14
|
+
is stored, never the raw key -- see codepulse.auth.hash_api_key.
|
|
15
|
+
`key_prefix` is a short, non-secret display value. `revoked_at` is
|
|
16
|
+
schema support for a future revocation flow (not implemented in Phase
|
|
17
|
+
1.1); it stays NULL for every key this milestone creates.
|
|
18
|
+
- `api_key_repositories`: a (api_key_id, repository_id) grant table
|
|
19
|
+
implementing "API keys ... scoped to one or more repositories"
|
|
20
|
+
(roadmap §15). Phase 1.1 only writes to this table (via
|
|
21
|
+
codepulse.auth.create_api_key) -- nothing reads/enforces it yet; that
|
|
22
|
+
is Phase 1.2's central authorization dependency.
|
|
23
|
+
|
|
24
|
+
Since both tables are brand new, no pre-flight duplicate check is needed
|
|
25
|
+
(unlike prior phases' migrations, which added constraints to existing,
|
|
26
|
+
possibly-already-populated tables) -- there is no pre-existing data that
|
|
27
|
+
could violate either table's UNIQUE constraint.
|
|
28
|
+
"""
|
|
29
|
+
from typing import Sequence, Union
|
|
30
|
+
|
|
31
|
+
from alembic import op
|
|
32
|
+
import sqlalchemy as sa
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# revision identifiers, used by Alembic.
|
|
36
|
+
revision: str = 'b0bdd63949f4'
|
|
37
|
+
down_revision: Union[str, Sequence[str], None] = '49572f834366'
|
|
38
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
39
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def upgrade() -> None:
|
|
43
|
+
"""Upgrade schema."""
|
|
44
|
+
op.create_table(
|
|
45
|
+
'api_keys',
|
|
46
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
47
|
+
sa.Column('name', sa.String(length=255), nullable=False),
|
|
48
|
+
sa.Column('key_hash', sa.String(length=64), nullable=False),
|
|
49
|
+
sa.Column('key_prefix', sa.String(length=32), nullable=False),
|
|
50
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
51
|
+
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
|
52
|
+
sa.PrimaryKeyConstraint('id'),
|
|
53
|
+
sa.UniqueConstraint('key_hash', name='uq_api_keys_key_hash'),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
op.create_table(
|
|
57
|
+
'api_key_repositories',
|
|
58
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
59
|
+
sa.Column('api_key_id', sa.Integer(), nullable=False),
|
|
60
|
+
sa.Column('repo_id', sa.Integer(), nullable=False),
|
|
61
|
+
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
|
62
|
+
sa.ForeignKeyConstraint(['repo_id'], ['repositories.id'], ondelete='CASCADE'),
|
|
63
|
+
sa.PrimaryKeyConstraint('id'),
|
|
64
|
+
sa.UniqueConstraint('api_key_id', 'repo_id', name='uq_api_key_repositories_key_repo'),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def downgrade() -> None:
|
|
69
|
+
"""Downgrade schema."""
|
|
70
|
+
op.drop_table('api_key_repositories')
|
|
71
|
+
op.drop_table('api_keys')
|