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,331 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from typing import Any
5
+ from tree_sitter_languages import get_parser
6
+
7
+ from codepulse.models import CallReference, ImportItem, ImportStatement, Symbol, SymbolType
8
+ from codepulse.parser.base import LanguageParser
9
+
10
+
11
+ class PythonParser(LanguageParser):
12
+ """AST parser for Python source code using Tree-sitter."""
13
+
14
+ def __init__(self) -> None:
15
+ self._parser = get_parser("python")
16
+
17
+ @property
18
+ def language(self) -> str:
19
+ return "python"
20
+
21
+ def extract_symbols(self, source_code: str, file_path: str = "") -> list[Symbol]:
22
+ source_bytes = source_code.encode("utf-8")
23
+ tree = self._parser.parse(source_bytes)
24
+ symbols: list[Symbol] = []
25
+
26
+ def walk(node: Any, parent_class: str | None = None) -> None:
27
+ if node.type in ("function_definition", "async_function_definition"):
28
+ sym = self._parse_function(node, source_bytes, file_path, parent_class)
29
+ if sym:
30
+ symbols.append(sym)
31
+ # Continue walking into function body to support nested functions/classes
32
+ body_node = node.child_by_field_name("body")
33
+ if body_node:
34
+ for child in body_node.children:
35
+ walk(child, parent_class=sym.name if sym else parent_class)
36
+ return
37
+
38
+ elif node.type == "class_definition":
39
+ sym = self._parse_class(node, source_bytes, file_path, parent_class)
40
+ if sym:
41
+ symbols.append(sym)
42
+ body_node = node.child_by_field_name("body")
43
+ if body_node:
44
+ for child in body_node.children:
45
+ walk(child, parent_class=sym.name if sym else parent_class)
46
+ return
47
+
48
+ for child in node.children:
49
+ walk(child, parent_class)
50
+
51
+ walk(tree.root_node)
52
+ return symbols
53
+
54
+ def _parse_function(
55
+ self, node: Any, source_bytes: bytes, file_path: str, parent_class: str | None
56
+ ) -> Symbol | None:
57
+ name_node = node.child_by_field_name("name")
58
+ if not name_node:
59
+ return None
60
+
61
+ name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")
62
+ start_line = node.start_point[0] + 1
63
+ end_line = node.end_point[0] + 1
64
+ body_text = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
65
+ docstring = self._extract_docstring(node, source_bytes)
66
+ signature = self._extract_signature(node, source_bytes)
67
+ complexity = self._calculate_complexity(node)
68
+
69
+ symbol_type = SymbolType.METHOD if parent_class else SymbolType.FUNCTION
70
+
71
+ return Symbol(
72
+ name=name,
73
+ type=symbol_type,
74
+ file_path=file_path,
75
+ start_line=start_line,
76
+ end_line=end_line,
77
+ signature=signature,
78
+ docstring=docstring,
79
+ body=body_text,
80
+ body_hash=hashlib.sha256(body_text.strip().encode("utf-8")).hexdigest(),
81
+ language="python",
82
+ parent_symbol=parent_class,
83
+ complexity=complexity,
84
+ )
85
+
86
+ def _parse_class(
87
+ self, node: Any, source_bytes: bytes, file_path: str, parent_class: str | None
88
+ ) -> Symbol | None:
89
+ name_node = node.child_by_field_name("name")
90
+ if not name_node:
91
+ return None
92
+
93
+ name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")
94
+ start_line = node.start_point[0] + 1
95
+ end_line = node.end_point[0] + 1
96
+ body_text = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
97
+ docstring = self._extract_docstring(node, source_bytes)
98
+ signature = self._extract_signature(node, source_bytes)
99
+ base_classes = self._extract_base_classes(node, source_bytes)
100
+
101
+ return Symbol(
102
+ name=name,
103
+ type=SymbolType.CLASS,
104
+ file_path=file_path,
105
+ start_line=start_line,
106
+ end_line=end_line,
107
+ signature=signature,
108
+ docstring=docstring,
109
+ body=body_text,
110
+ body_hash=hashlib.sha256(body_text.strip().encode("utf-8")).hexdigest(),
111
+ language="python",
112
+ parent_symbol=parent_class,
113
+ complexity=1,
114
+ base_classes=base_classes,
115
+ )
116
+
117
+ def _extract_base_classes(self, node: Any, source_bytes: bytes) -> list[str]:
118
+ """
119
+ Phase 4.1 (roadmap §37 item 4.1: "Base-class/inheritance
120
+ extraction (Python + JS/TS)"). `class_definition`'s
121
+ `superclasses` field is the `argument_list` in `class Foo(Base,
122
+ pkg.Mixin, metaclass=Meta):` -- confirmed via a direct tree-
123
+ sitter grammar probe during this milestone's own design audit.
124
+
125
+ Keeps `identifier` (`Base`) and `attribute` (`pkg.Mixin`, a
126
+ dotted/qualified name) nodes -- the only two node types that can
127
+ actually name a base class in this grammar. Deliberately
128
+ EXCLUDES `keyword_argument` (e.g. `metaclass=Meta`) -- not a
129
+ base class -- and punctuation/commas. Returns the raw text
130
+ exactly as written; RESOLVING each name to a real symbol is a
131
+ separate step performed by `CallGraphBuilder`, not here (see
132
+ `Symbol.base_classes`'s own docstring for why raw and resolved
133
+ are deliberately kept apart).
134
+ """
135
+ superclasses = node.child_by_field_name("superclasses")
136
+ if not superclasses:
137
+ return []
138
+
139
+ bases: list[str] = []
140
+ for child in superclasses.children:
141
+ if child.type in ("identifier", "attribute"):
142
+ bases.append(
143
+ source_bytes[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
144
+ )
145
+ return bases
146
+
147
+ def _extract_docstring(self, node: Any, source_bytes: bytes) -> str | None:
148
+ body_node = node.child_by_field_name("body")
149
+ if not body_node or not body_node.children:
150
+ return None
151
+
152
+ first_stmt = body_node.children[0]
153
+ if first_stmt.type == "expression_statement" and first_stmt.children:
154
+ expr = first_stmt.children[0]
155
+ if expr.type == "string":
156
+ doc = source_bytes[expr.start_byte:expr.end_byte].decode("utf-8", errors="replace")
157
+ # Strip quotes (triple or single)
158
+ for q in ('"""', "'''", '"', "'"):
159
+ if doc.startswith(q) and doc.endswith(q) and len(doc) >= 2 * len(q):
160
+ return doc[len(q):-len(q)].strip()
161
+ return doc.strip()
162
+ return None
163
+
164
+ def _extract_signature(self, node: Any, source_bytes: bytes) -> str:
165
+ body_node = node.child_by_field_name("body")
166
+ if body_node:
167
+ sig_bytes = source_bytes[node.start_byte:body_node.start_byte]
168
+ sig_text = sig_bytes.decode("utf-8", errors="replace").strip()
169
+ if sig_text.endswith(":"):
170
+ sig_text = sig_text[:-1].strip()
171
+ return " ".join(sig_text.split())
172
+
173
+ # Fallback to first line
174
+ full_text = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
175
+ return full_text.splitlines()[0].strip().rstrip(":")
176
+
177
+ def _calculate_complexity(self, node: Any) -> int:
178
+ complexity = 1
179
+ branching_types = {
180
+ "if_statement", "elif_clause", "for_statement", "while_statement",
181
+ "except_clause", "with_statement", "boolean_operator", "conditional_expression"
182
+ }
183
+
184
+ def count_branches(n: Any) -> None:
185
+ nonlocal complexity
186
+ if n.type in branching_types:
187
+ complexity += 1
188
+ for child in n.children:
189
+ count_branches(child)
190
+
191
+ count_branches(node)
192
+ return complexity
193
+
194
+ def extract_imports(self, source_code: str, file_path: str = "") -> list[ImportStatement]:
195
+ source_bytes = source_code.encode("utf-8")
196
+ tree = self._parser.parse(source_bytes)
197
+ imports: list[ImportStatement] = []
198
+
199
+ def walk(node: Any) -> None:
200
+ if node.type == "import_statement":
201
+ # e.g., import math, sys as s
202
+ line_num = node.start_point[0] + 1
203
+ for child in node.children:
204
+ if child.type == "dotted_name":
205
+ mod_name = source_bytes[child.start_byte:child.end_byte].decode("utf-8")
206
+ imports.append(
207
+ ImportStatement(
208
+ module=mod_name,
209
+ items=[],
210
+ is_relative=False,
211
+ level=0,
212
+ line_number=line_num,
213
+ file_path=file_path,
214
+ )
215
+ )
216
+ elif child.type == "aliased_import":
217
+ name_n = child.child_by_field_name("name")
218
+ alias_n = child.child_by_field_name("alias")
219
+ if name_n:
220
+ mod_name = source_bytes[name_n.start_byte:name_n.end_byte].decode("utf-8")
221
+ alias = source_bytes[alias_n.start_byte:alias_n.end_byte].decode("utf-8") if alias_n else None
222
+ imports.append(
223
+ ImportStatement(
224
+ module=mod_name,
225
+ items=[ImportItem(name=mod_name, alias=alias)],
226
+ is_relative=False,
227
+ level=0,
228
+ line_number=line_num,
229
+ file_path=file_path,
230
+ )
231
+ )
232
+
233
+ elif node.type == "import_from_statement":
234
+ # e.g., from os.path import join as j, exists
235
+ # e.g., from ..utils import helper
236
+ line_num = node.start_point[0] + 1
237
+ module_name = ""
238
+ level = 0
239
+ is_relative = False
240
+
241
+ module_node = node.child_by_field_name("module_name")
242
+ if module_node:
243
+ if module_node.type == "relative_import":
244
+ is_relative = True
245
+ # Count dots
246
+ dot_str = ""
247
+ for sub in module_node.children:
248
+ if sub.type == "import_prefix":
249
+ dot_str += source_bytes[sub.start_byte:sub.end_byte].decode("utf-8")
250
+ elif sub.type == "dotted_name":
251
+ module_name = source_bytes[sub.start_byte:sub.end_byte].decode("utf-8")
252
+ level = dot_str.count(".")
253
+ else:
254
+ module_name = source_bytes[module_node.start_byte:module_node.end_byte].decode("utf-8")
255
+
256
+ items: list[ImportItem] = []
257
+ for child in node.children:
258
+ if child.type == "dotted_name" and child != module_node:
259
+ item_name = source_bytes[child.start_byte:child.end_byte].decode("utf-8")
260
+ items.append(ImportItem(name=item_name))
261
+ elif child.type == "aliased_import":
262
+ name_n = child.child_by_field_name("name")
263
+ alias_n = child.child_by_field_name("alias")
264
+ if name_n:
265
+ item_name = source_bytes[name_n.start_byte:name_n.end_byte].decode("utf-8")
266
+ alias = source_bytes[alias_n.start_byte:alias_n.end_byte].decode("utf-8") if alias_n else None
267
+ items.append(ImportItem(name=item_name, alias=alias))
268
+
269
+ imports.append(
270
+ ImportStatement(
271
+ module=module_name,
272
+ items=items,
273
+ is_relative=is_relative,
274
+ level=level,
275
+ line_number=line_num,
276
+ file_path=file_path,
277
+ )
278
+ )
279
+
280
+ for child in node.children:
281
+ walk(child)
282
+
283
+ walk(tree.root_node)
284
+ return imports
285
+
286
+ def extract_calls(
287
+ self, source_code: str, file_path: str = "", symbols: list[Symbol] | None = None
288
+ ) -> list[CallReference]:
289
+ if symbols is None:
290
+ symbols = self.extract_symbols(source_code, file_path=file_path)
291
+
292
+ source_bytes = source_code.encode("utf-8")
293
+ tree = self._parser.parse(source_bytes)
294
+ calls: list[CallReference] = []
295
+
296
+ def find_enclosing_symbol(line: int) -> str:
297
+ # Match most specific / deepest symbol containing line
298
+ matching = [s for s in symbols if s.start_line <= line <= s.end_line]
299
+ if not matching:
300
+ return "<module>"
301
+ # Sort by smallest line span
302
+ matching.sort(key=lambda s: (s.end_line - s.start_line))
303
+ return matching[0].name
304
+
305
+ def walk(node: Any) -> None:
306
+ if node.type == "call":
307
+ func_node = node.child_by_field_name("function")
308
+ if func_node:
309
+ callee_name = source_bytes[func_node.start_byte:func_node.end_byte].decode("utf-8", errors="replace")
310
+ # If attribute call like obj.method(), extract method name
311
+ if func_node.type == "attribute":
312
+ attr_node = func_node.child_by_field_name("attribute")
313
+ if attr_node:
314
+ callee_name = source_bytes[attr_node.start_byte:attr_node.end_byte].decode("utf-8", errors="replace")
315
+
316
+ line_num = node.start_point[0] + 1
317
+ caller = find_enclosing_symbol(line_num)
318
+ calls.append(
319
+ CallReference(
320
+ caller_symbol=caller,
321
+ callee_name=callee_name,
322
+ line_number=line_num,
323
+ file_path=file_path,
324
+ )
325
+ )
326
+
327
+ for child in node.children:
328
+ walk(child)
329
+
330
+ walk(tree.root_node)
331
+ return calls
@@ -0,0 +1,142 @@
1
+ """
2
+ Phase 1.4 — In-process token-bucket rate limiter (roadmap §37 item 1.4;
3
+ §6 "Recommended Architecture": rate limits live in the control plane /
4
+ API Gateway layer, alongside auth and central authorization; §35's
5
+ architecture diagram places "Rate limits" directly beside "Auth (API
6
+ keys)" and "Central authz"; backlog B11).
7
+
8
+ Scope, precisely (do not infer requirements from later milestones):
9
+ - A classic token-bucket algorithm (capacity + refill rate), entirely
10
+ in-process (a plain in-memory dict) -- no Redis, no database
11
+ persistence, no distributed state. State resets on process restart;
12
+ this project runs as one process, matching "in-process" literally
13
+ (roadmap §37/§45: "rate limiting (in-process token bucket, no Redis
14
+ yet)").
15
+ - One bucket per client identity: the raw `X-API-Key` header value if
16
+ present (and non-empty), else the client's IP address as a fallback
17
+ for unauthenticated requests -- so an unauthenticated flood doesn't
18
+ get an unlimited exemption, but distinct callers don't share one
19
+ bucket either. This does NOT verify the key (no DB lookup, no import
20
+ of `codepulse.auth`/`codepulse.api.security` here) -- the bucket is
21
+ keyed on the raw string alone, kept deliberately cheap and
22
+ independent; validity checking remains exactly `require_api_key`'s
23
+ job (Phase 1.2), unchanged and un-duplicated.
24
+ - Exceeding the bucket's capacity is reported as HTTP 429 with a real
25
+ `Retry-After` value (backlog B11's literal DoD: "Excess requests
26
+ receive 429 with retry-after"), computed as the actual number of
27
+ seconds until at least one token will be available -- never a
28
+ fabricated/estimated value.
29
+
30
+ Deliberately NOT in scope for Phase 1.4 (do not infer these belong here):
31
+ - Per-role/per-plan differentiated limits (roadmap §15's RBAC roles are
32
+ Phase 7 / B25 scope, not modeled at all yet).
33
+ - Distributed/shared rate limiting across multiple processes or
34
+ workers (explicitly deferred -- "no Redis yet").
35
+ - A structured `{"error": {...}}` response envelope for the 429 body --
36
+ Phase 1.6. The middleware in `codepulse/api/app.py` that uses this
37
+ module returns a plain `{"detail": "..."}` body, matching FastAPI's
38
+ own default `HTTPException` shape and every other error path already
39
+ in `api/app.py`.
40
+ - Ingestion-specific safety limits (path allow-listing, SSRF checks,
41
+ size caps) -- Phase 1.5 / B10, an entirely separate concern from
42
+ request-rate limiting.
43
+
44
+ Default parameters (`CodePulseConfig.rate_limit_requests_per_second`,
45
+ `.rate_limit_burst`) are a reasonable *starting* configuration for
46
+ local-first, single-developer use -- NOT a benchmarked/measured figure,
47
+ matching roadmap §19's own caution against presenting unvalidated
48
+ numbers as fact. Tune via config for a given workload.
49
+
50
+ This module lives outside `codepulse/indexer/` and `codepulse/api/`
51
+ deliberately -- a top-level module, mirroring `codepulse/auth.py` and
52
+ `codepulse/jobs.py`'s placement, per the roadmap's own "control plane"
53
+ grouping (§6) of auth/rate-limits/job-tracking as one layer, reusable
54
+ identically by a future CLI or MCP surface (though today only the REST
55
+ app installs it, as middleware).
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import threading
60
+ import time
61
+ from dataclasses import dataclass
62
+
63
+
64
+ @dataclass
65
+ class _Bucket:
66
+ tokens: float
67
+ last_refill: float
68
+
69
+
70
+ class TokenBucketLimiter:
71
+ """
72
+ A plain in-memory token-bucket rate limiter, one bucket per identity
73
+ string. Thread-safe via a single lock guarding the bucket dict --
74
+ cheap and sufficient for this project's single-process, in-process
75
+ scale (no Redis, no multi-process coordination).
76
+ """
77
+
78
+ def __init__(self, requests_per_second: float, burst: int) -> None:
79
+ self.requests_per_second = requests_per_second
80
+ self.burst = burst
81
+ self._buckets: dict[str, _Bucket] = {}
82
+ self._lock = threading.Lock()
83
+
84
+ def check(self, identity: str) -> tuple[bool, float]:
85
+ """
86
+ Attempts to consume one token for `identity`. Returns
87
+ `(allowed, retry_after_seconds)`: `retry_after_seconds` is `0.0`
88
+ when `allowed` is True, or the real number of seconds until at
89
+ least one token will be available when it is False.
90
+ """
91
+ now = time.monotonic()
92
+ with self._lock:
93
+ bucket = self._buckets.get(identity)
94
+ if bucket is None:
95
+ bucket = _Bucket(tokens=float(self.burst), last_refill=now)
96
+ self._buckets[identity] = bucket
97
+
98
+ elapsed = max(0.0, now - bucket.last_refill)
99
+ bucket.tokens = min(float(self.burst), bucket.tokens + elapsed * self.requests_per_second)
100
+ bucket.last_refill = now
101
+
102
+ if bucket.tokens >= 1.0:
103
+ bucket.tokens -= 1.0
104
+ return True, 0.0
105
+
106
+ missing = 1.0 - bucket.tokens
107
+ retry_after = missing / self.requests_per_second if self.requests_per_second > 0 else float("inf")
108
+ return False, retry_after
109
+
110
+ def reset(self) -> None:
111
+ """Clears all bucket state -- primarily for test isolation."""
112
+ with self._lock:
113
+ self._buckets.clear()
114
+
115
+
116
+ _DEFAULT_LIMITER: TokenBucketLimiter | None = None
117
+
118
+
119
+ def get_limiter() -> TokenBucketLimiter:
120
+ """
121
+ Returns the process-wide default `TokenBucketLimiter`, constructed
122
+ from the currently active `CodePulseConfig` on first use (and cached
123
+ -- call `reset_limiter()` after changing rate-limit settings, e.g. in
124
+ tests, matching the same pattern `codepulse.config.reset_settings()`
125
+ already establishes for the settings singleton itself).
126
+ """
127
+ global _DEFAULT_LIMITER
128
+ if _DEFAULT_LIMITER is None:
129
+ from codepulse.config import get_settings
130
+
131
+ settings = get_settings()
132
+ _DEFAULT_LIMITER = TokenBucketLimiter(
133
+ requests_per_second=settings.rate_limit_requests_per_second,
134
+ burst=settings.rate_limit_burst,
135
+ )
136
+ return _DEFAULT_LIMITER
137
+
138
+
139
+ def reset_limiter() -> None:
140
+ """Resets the module-level default limiter -- primarily for test isolation."""
141
+ global _DEFAULT_LIMITER
142
+ _DEFAULT_LIMITER = None
@@ -0,0 +1,83 @@
1
+ """
2
+ One repository, one identity, however its path is spelled
3
+ (CODEPULSE_FUTURE_CHANGES.md item 2; user-approved 2026-10-14).
4
+
5
+ A repository is identified by `RepositoryModel.url` (unique). For a repository
6
+ indexed from a local directory that string was whatever the caller typed, so the
7
+ same directory spelled with backslashes vs forward slashes, a trailing
8
+ separator, `..` segments, or a lowercase vs uppercase Windows drive letter
9
+ (VS Code reports `c:\\x`, a CLI index stores `C:\\x`) became two repositories.
10
+ Re-indexing then created a duplicate with a new id and, because a differently
11
+ spelled path looked brand-new to the "re-indexing needs `indexer`" check, a
12
+ viewer-only key could bypass it.
13
+
14
+ `canonical_repository_url` is the one definition of identity:
15
+ - a LOCAL PATH becomes absolute, with symlinks and `..` resolved
16
+ (`os.path.realpath`, which on Windows also returns the true case of every
17
+ component and normalizes slashes), an uppercase drive letter on Windows (also
18
+ for a path that no longer exists, where realpath cannot fix the case), and no
19
+ trailing separator;
20
+ - anything that is not a local path -- a git URL, `https://...`, the synthetic
21
+ `file:///...` ids some tests use -- is returned exactly as given.
22
+
23
+ `find_repository` is the one lookup: it matches the canonical form first, then
24
+ any row stored under another spelling (a database not yet migrated, or a
25
+ duplicate that predates this fix), so a lookup never creates a duplicate just
26
+ because an old row has an old spelling. When several rows match it returns the
27
+ lowest id, deterministically; it never merges or deletes anything.
28
+
29
+ Limit: identity follows the filesystem. A mapped network drive resolves to its
30
+ UNC path while the mapping exists; a path on a filesystem that is
31
+ case-insensitive but where `realpath` cannot report the true case (a deleted
32
+ directory, macOS) can still be spelled in more than one way.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import re
38
+
39
+ from sqlalchemy.orm import Session
40
+
41
+ from codepulse.storage.db import RepositoryModel
42
+
43
+ # `scheme://...` is a URL, not a path. A Windows drive (`C:\\x`, `C:/x`) has no `//` right after the colon.
44
+ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://")
45
+
46
+
47
+ def is_local_path(url: str) -> bool:
48
+ return bool(url) and not _URL_SCHEME.match(url)
49
+
50
+
51
+ def canonical_repository_url(url: str) -> str:
52
+ """The one canonical identity for `url`. Non-paths are returned unchanged; see the module docstring."""
53
+ if not is_local_path(url):
54
+ return url
55
+ resolved = os.path.realpath(os.path.abspath(url))
56
+ if os.name == "nt" and len(resolved) >= 2 and resolved[1] == ":":
57
+ resolved = resolved[0].upper() + resolved[1:]
58
+ return resolved
59
+
60
+
61
+ def identity_key(url: str) -> str:
62
+ """The canonical form, case-folded where the filesystem is case-insensitive, for comparing two spellings."""
63
+ return os.path.normcase(canonical_repository_url(url)) if is_local_path(url) else url
64
+
65
+
66
+ def find_repository(session: Session, url: str) -> RepositoryModel | None:
67
+ """
68
+ The repository `url` names, under any spelling of the same directory, or None.
69
+ Prefers an exact canonical row, then an exact row, then any row whose canonical
70
+ identity matches (lowest id first).
71
+ """
72
+ canonical = canonical_repository_url(url)
73
+ exact = session.query(RepositoryModel).filter(RepositoryModel.url == canonical).first()
74
+ if exact is not None or not is_local_path(url):
75
+ return exact
76
+ raw = session.query(RepositoryModel).filter(RepositoryModel.url == url).first()
77
+ if raw is not None:
78
+ return raw
79
+ wanted = identity_key(url)
80
+ for row in session.query(RepositoryModel).order_by(RepositoryModel.id).all():
81
+ if is_local_path(row.url) and identity_key(row.url) == wanted:
82
+ return row
83
+ return None