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,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')
@@ -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')
@@ -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')