codrspot-processor-mcp 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 (100) hide show
  1. codepreproc_client/__init__.py +14 -0
  2. codepreproc_client/__main__.py +9 -0
  3. codepreproc_client/layer1_business/__init__.py +0 -0
  4. codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
  5. codepreproc_client/layer1_business/api_client/__init__.py +0 -0
  6. codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
  7. codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
  8. codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
  9. codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
  10. codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
  11. codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
  12. codepreproc_client/layer1_business/cli/__init__.py +0 -0
  13. codepreproc_client/layer1_business/cli/init.py +578 -0
  14. codepreproc_client/layer1_business/cli/main.py +78 -0
  15. codepreproc_client/layer1_business/config.py +104 -0
  16. codepreproc_client/layer1_business/git_utils.py +67 -0
  17. codepreproc_client/layer1_business/license/__init__.py +0 -0
  18. codepreproc_client/layer1_business/license/fingerprint.py +61 -0
  19. codepreproc_client/layer1_business/license/jwt_client.py +60 -0
  20. codepreproc_client/layer1_business/license/project_lock.py +44 -0
  21. codepreproc_client/layer1_business/local_registry.py +40 -0
  22. codepreproc_client/layer1_business/manifest/__init__.py +0 -0
  23. codepreproc_client/layer1_business/manifest/credentials.py +35 -0
  24. codepreproc_client/layer1_business/mcp/__init__.py +0 -0
  25. codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
  26. codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
  27. codepreproc_client/layer1_business/mcp/server.py +656 -0
  28. codepreproc_client/layer1_business/mcp/tools.py +1665 -0
  29. codepreproc_client/layer1_business/project_registry.py +99 -0
  30. codepreproc_client/layer2_tooling/__init__.py +0 -0
  31. codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
  32. codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
  33. codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
  34. codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
  35. codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
  36. codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
  37. codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
  38. codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
  39. codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
  40. codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
  41. codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
  42. codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
  43. codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
  44. codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
  45. codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
  46. codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
  47. codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
  48. codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
  49. codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
  50. codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
  51. codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
  52. codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
  53. codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
  54. codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
  55. codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
  56. codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
  57. codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
  58. codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
  59. codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
  60. codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
  61. codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
  62. codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
  63. codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
  64. codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
  65. codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
  66. codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
  67. codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
  68. codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
  69. codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
  70. codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
  71. codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
  72. codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
  73. codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
  74. codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
  75. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
  76. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
  77. codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
  78. codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
  79. codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
  80. codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
  81. codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
  82. codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
  83. codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
  84. codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
  85. codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
  86. codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
  87. codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
  88. codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
  89. codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
  90. codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
  91. codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
  92. codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
  93. codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
  94. codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
  95. codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
  96. codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
  97. codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
  98. contracts/__init__.py +0 -0
  99. contracts/dtos.py +1239 -0
  100. contracts/ir.py +102 -0
@@ -0,0 +1,99 @@
1
+ """Project registry resolution: match a session's working directory to a
2
+ registered project (by explicit id, active project, root path, or git
3
+ remote). Operates entirely on the local registry mirror
4
+ (local_registry.load_local_projects) - has no server dependency.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from git import Repo
12
+
13
+ from codepreproc_client.layer1_business.git_utils import get_remote_url, get_repo_root
14
+ from contracts.dtos import ProjectConfig, Registry
15
+
16
+
17
+ class ProjectRegistry:
18
+ """Resolve configured projects for a client session."""
19
+
20
+ def __init__(self, registry: Registry) -> None:
21
+ self.registry = registry
22
+
23
+ def list_projects(self) -> list[ProjectConfig]:
24
+ """Return projects in stable order."""
25
+
26
+ return [self.registry.projects[key] for key in sorted(self.registry.projects)]
27
+
28
+ def get(self, project_id: str) -> ProjectConfig:
29
+ """Return a project by id."""
30
+
31
+ return self.registry.projects[project_id]
32
+
33
+ def resolve_project(
34
+ self,
35
+ explicit_project: str | None = None,
36
+ active_project: str | None = None,
37
+ roots: list[Path] | None = None,
38
+ fallback_path: Path | None = None,
39
+ ) -> ProjectConfig:
40
+ """Resolve a project using the configured precedence."""
41
+
42
+ if explicit_project:
43
+ return self.get(explicit_project)
44
+ if active_project and active_project in self.registry.projects:
45
+ return self.get(active_project)
46
+
47
+ root_match = self.resolve_from_roots(roots or [])
48
+ if root_match:
49
+ return root_match
50
+
51
+ remote_match = self.resolve_from_remote(fallback_path)
52
+ if remote_match:
53
+ return remote_match
54
+
55
+ if len(self.registry.projects) == 1:
56
+ return next(iter(self.registry.projects.values()))
57
+
58
+ raise KeyError("project_not_resolved")
59
+
60
+ def resolve_from_roots(self, roots: list[Path]) -> ProjectConfig | None:
61
+ """Match a configured project by client root paths."""
62
+
63
+ resolved_roots = [root.resolve() for root in roots]
64
+ best_match: ProjectConfig | None = None
65
+ best_depth = -1
66
+ for project in self.registry.projects.values():
67
+ project_root = project.root.resolve()
68
+ for root in resolved_roots:
69
+ try:
70
+ root.relative_to(project_root)
71
+ depth = len(project_root.parts)
72
+ if depth > best_depth:
73
+ best_match = project
74
+ best_depth = depth
75
+ except ValueError:
76
+ continue
77
+ return best_match
78
+
79
+ def resolve_from_remote(self, path: Path | None) -> ProjectConfig | None:
80
+ """Match a project by git remote URL."""
81
+
82
+ if path is None:
83
+ return None
84
+ repo_root = get_repo_root(path)
85
+ if repo_root is None:
86
+ return None
87
+ remote = get_remote_url(Repo(repo_root))
88
+ if remote is None:
89
+ return None
90
+ for project in self.registry.projects.values():
91
+ project_root = get_repo_root(project.root)
92
+ if project_root is None:
93
+ continue
94
+ try:
95
+ if get_remote_url(Repo(project_root)) == remote:
96
+ return project
97
+ except Exception:
98
+ continue
99
+ return None
File without changes
@@ -0,0 +1,426 @@
1
+ """SQLite-backed AST graph metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import hashlib
7
+ import json
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ from contracts.dtos import CodeChunk
12
+
13
+
14
+ def _compute_export_surface_hash(symbol_names: list[str]) -> str:
15
+ """Stable hash over sorted bare symbol names — the foreign lookup key.
16
+
17
+ Bumps only when the exported symbol surface changes, not on body edits.
18
+ """
19
+ return hashlib.sha256(json.dumps(sorted(symbol_names)).encode()).hexdigest()
20
+
21
+ SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS symbols (
23
+ chunk_id TEXT PRIMARY KEY,
24
+ symbol TEXT NOT NULL,
25
+ file_path TEXT NOT NULL,
26
+ kind TEXT NOT NULL,
27
+ signature TEXT
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_symbol ON symbols(symbol);
30
+ CREATE INDEX IF NOT EXISTS idx_file ON symbols(file_path);
31
+ CREATE TABLE IF NOT EXISTS refs (
32
+ src_chunk_id TEXT NOT NULL,
33
+ dst_symbol TEXT NOT NULL,
34
+ PRIMARY KEY (src_chunk_id, dst_symbol)
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_dst ON refs(dst_symbol);
37
+ CREATE TABLE IF NOT EXISTS meta (
38
+ key TEXT PRIMARY KEY,
39
+ value TEXT
40
+ );
41
+ CREATE TABLE IF NOT EXISTS md_knowledge (
42
+ chunk_id TEXT PRIMARY KEY,
43
+ file_path TEXT NOT NULL,
44
+ dir_path TEXT NOT NULL,
45
+ depth INTEGER NOT NULL
46
+ );
47
+ CREATE INDEX IF NOT EXISTS idx_md_dir ON md_knowledge(dir_path);
48
+ """
49
+
50
+
51
+ class AstGraph:
52
+ """Project-local symbol graph."""
53
+
54
+ def __init__(self, db_path: Path) -> None:
55
+ self.db_path = db_path
56
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
57
+ self._walk_cache: tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]] | None = None
58
+ self._walk_sha: str | None = None
59
+ self._initialize()
60
+
61
+ @contextlib.contextmanager
62
+ def _connect(self):
63
+ """Open a connection scoped to the `with` block; always closed on exit.
64
+
65
+ A bare `with sqlite3.connect(...) as conn:` only manages the
66
+ transaction, not the connection's lifetime — every call site here
67
+ used that pattern, leaking a file handle to ast.sqlite per call. On
68
+ Windows those leaked handles hold a mandatory lock, so a stuck
69
+ reader (e.g. a hung MCP call) could permanently block the writer
70
+ (reindex swap). `timeout` makes SQLite retry instead of failing
71
+ immediately when a lock is briefly held by another connection.
72
+ """
73
+ conn = sqlite3.connect(self.db_path, timeout=30)
74
+ try:
75
+ yield conn
76
+ conn.commit()
77
+ except BaseException:
78
+ conn.rollback()
79
+ raise
80
+ finally:
81
+ conn.close()
82
+
83
+ def _initialize(self) -> None:
84
+ with self._connect() as conn:
85
+ conn.executescript(SCHEMA)
86
+ conn.execute("PRAGMA journal_mode=WAL")
87
+ conn.execute("PRAGMA busy_timeout=30000")
88
+
89
+ def clear_all(self) -> None:
90
+ """Delete all rows."""
91
+ self._walk_cache = None
92
+ self._walk_sha = None
93
+ with self._connect() as conn:
94
+ conn.execute("DELETE FROM refs")
95
+ conn.execute("DELETE FROM symbols")
96
+ conn.execute("DELETE FROM meta")
97
+ conn.execute("DELETE FROM md_knowledge")
98
+
99
+ def upsert_symbols(self, chunks: list[CodeChunk]) -> None:
100
+ """Insert or update symbol metadata."""
101
+
102
+ with self._connect() as conn:
103
+ conn.executemany(
104
+ """
105
+ INSERT INTO symbols (chunk_id, symbol, file_path, kind, signature)
106
+ VALUES (?, ?, ?, ?, ?)
107
+ ON CONFLICT(chunk_id) DO UPDATE SET
108
+ symbol=excluded.symbol,
109
+ file_path=excluded.file_path,
110
+ kind=excluded.kind,
111
+ signature=excluded.signature
112
+ """,
113
+ [(chunk.chunk_id, chunk.symbol, chunk.file_path, chunk.kind, chunk.signature) for chunk in chunks],
114
+ )
115
+
116
+ def delete_by_file(self, file_path: str) -> None:
117
+ """Remove all metadata for a file."""
118
+
119
+ with self._connect() as conn:
120
+ chunk_rows = conn.execute("SELECT chunk_id FROM symbols WHERE file_path = ?", (file_path,)).fetchall()
121
+ chunk_ids = [row[0] for row in chunk_rows]
122
+ conn.execute("DELETE FROM symbols WHERE file_path = ?", (file_path,))
123
+ for chunk_id in chunk_ids:
124
+ conn.execute("DELETE FROM refs WHERE src_chunk_id = ?", (chunk_id,))
125
+ conn.execute("DELETE FROM md_knowledge WHERE file_path = ?", (file_path,))
126
+
127
+ def add_references(self, chunk_id: str, referenced_symbols: list[str]) -> None:
128
+ """Upsert lightweight references for a chunk."""
129
+
130
+ with self._connect() as conn:
131
+ conn.execute("DELETE FROM refs WHERE src_chunk_id = ?", (chunk_id,))
132
+ conn.executemany(
133
+ "INSERT OR IGNORE INTO refs (src_chunk_id, dst_symbol) VALUES (?, ?)",
134
+ [(chunk_id, symbol) for symbol in referenced_symbols],
135
+ )
136
+
137
+ def add_references_bulk(self, chunks_deps: list[tuple[str, list[str]]]) -> None:
138
+ """Upsert references for all chunks in a single transaction."""
139
+
140
+ if not chunks_deps:
141
+ return
142
+ with self._connect() as conn:
143
+ conn.executemany(
144
+ "DELETE FROM refs WHERE src_chunk_id = ?",
145
+ [(cid,) for cid, _ in chunks_deps],
146
+ )
147
+ rows = [(cid, sym) for cid, deps in chunks_deps for sym in deps]
148
+ if rows:
149
+ conn.executemany(
150
+ "INSERT OR IGNORE INTO refs (src_chunk_id, dst_symbol) VALUES (?, ?)",
151
+ rows,
152
+ )
153
+
154
+ def upsert_md_knowledge(self, chunks: list[CodeChunk]) -> None:
155
+ """Register markdown knowledge chunks by directory scope."""
156
+ if not chunks:
157
+ return
158
+ rows = []
159
+ for chunk in chunks:
160
+ dir_path = Path(chunk.file_path).parent.as_posix()
161
+ depth = len(Path(chunk.file_path).parent.parts)
162
+ rows.append((chunk.chunk_id, chunk.file_path, dir_path, depth))
163
+ with self._connect() as conn:
164
+ conn.executemany(
165
+ """INSERT INTO md_knowledge (chunk_id, file_path, dir_path, depth)
166
+ VALUES (?, ?, ?, ?)
167
+ ON CONFLICT(chunk_id) DO UPDATE SET
168
+ file_path=excluded.file_path,
169
+ dir_path=excluded.dir_path,
170
+ depth=excluded.depth""",
171
+ rows,
172
+ )
173
+
174
+ def get_md_chunk_ids_for_dirs(self, dir_paths: list[str]) -> list[str]:
175
+ """Return MD chunk IDs for the given directory paths, ordered deepest first."""
176
+ if not dir_paths:
177
+ return []
178
+ placeholders = ','.join('?' * len(dir_paths))
179
+ with self._connect() as conn:
180
+ rows = conn.execute(
181
+ f"SELECT chunk_id FROM md_knowledge WHERE dir_path IN ({placeholders}) ORDER BY depth DESC",
182
+ dir_paths,
183
+ ).fetchall()
184
+ return [row[0] for row in rows]
185
+
186
+ def find_callers(self, symbol: str) -> list[str]:
187
+ """Return chunk ids that reference a symbol."""
188
+
189
+ with self._connect() as conn:
190
+ rows = conn.execute("SELECT src_chunk_id FROM refs WHERE dst_symbol = ?", (symbol,)).fetchall()
191
+ return [row[0] for row in rows]
192
+
193
+ def find_callees(self, chunk_id: str) -> list[str]:
194
+ """Resolve referenced symbols to chunk ids."""
195
+
196
+ with self._connect() as conn:
197
+ rows = conn.execute(
198
+ """
199
+ SELECT DISTINCT symbols.chunk_id
200
+ FROM refs
201
+ JOIN symbols ON symbols.symbol = refs.dst_symbol
202
+ WHERE refs.src_chunk_id = ?
203
+ """,
204
+ (chunk_id,),
205
+ ).fetchall()
206
+ return [row[0] for row in rows]
207
+
208
+ def find_callers_batch(self, symbols: list[str]) -> dict[str, list[str]]:
209
+ """Return {symbol: [caller_chunk_ids]} for all given symbols in a single query."""
210
+
211
+ if not symbols:
212
+ return {}
213
+ placeholders = ",".join("?" * len(symbols))
214
+ with self._connect() as conn:
215
+ rows = conn.execute(
216
+ f"SELECT dst_symbol, src_chunk_id FROM refs WHERE dst_symbol IN ({placeholders})",
217
+ symbols,
218
+ ).fetchall()
219
+ result: dict[str, list[str]] = {s: [] for s in symbols}
220
+ for dst_symbol, src_chunk_id in rows:
221
+ result[dst_symbol].append(src_chunk_id)
222
+ return result
223
+
224
+ def find_callees_batch(self, chunk_ids: list[str]) -> dict[str, list[str]]:
225
+ """Return {chunk_id: [callee_chunk_ids]} for all given chunk_ids in a single query."""
226
+
227
+ if not chunk_ids:
228
+ return {}
229
+ placeholders = ",".join("?" * len(chunk_ids))
230
+ with self._connect() as conn:
231
+ rows = conn.execute(
232
+ f"""
233
+ SELECT DISTINCT refs.src_chunk_id, symbols.chunk_id
234
+ FROM refs
235
+ JOIN symbols ON symbols.symbol = refs.dst_symbol
236
+ WHERE refs.src_chunk_id IN ({placeholders})
237
+ """,
238
+ chunk_ids,
239
+ ).fetchall()
240
+ result: dict[str, list[str]] = {cid: [] for cid in chunk_ids}
241
+ for src_chunk_id, callee_chunk_id in rows:
242
+ result[src_chunk_id].append(callee_chunk_id)
243
+ return result
244
+
245
+ def get_symbol(self, chunk_id: str) -> str | None:
246
+ """Return a symbol name for a chunk id."""
247
+
248
+ with self._connect() as conn:
249
+ row = conn.execute("SELECT symbol FROM symbols WHERE chunk_id = ?", (chunk_id,)).fetchone()
250
+ return row[0] if row else None
251
+
252
+ def load_for_walk(self) -> tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]]:
253
+ """Load the full graph into memory for BFS traversal.
254
+
255
+ Returns (symbol_map, callees_map, callers_map) where:
256
+ symbol_map[chunk_id] -> symbol name
257
+ callees_map[chunk_id] -> [chunk_ids that chunk_id references]
258
+ callers_map[symbol_name] -> [chunk_ids that reference that symbol]
259
+ """
260
+ with self._connect() as conn:
261
+ sym_rows = conn.execute("SELECT chunk_id, symbol FROM symbols").fetchall()
262
+ ref_rows = conn.execute("SELECT src_chunk_id, dst_symbol FROM refs").fetchall()
263
+
264
+ symbol_map: dict[str, str] = {r[0]: r[1] for r in sym_rows}
265
+ sym_to_chunk: dict[str, str] = {v: k for k, v in symbol_map.items()}
266
+
267
+ callees_map: dict[str, list[str]] = {}
268
+ callers_map: dict[str, list[str]] = {}
269
+ for src_chunk_id, dst_symbol in ref_rows:
270
+ dst_chunk = sym_to_chunk.get(dst_symbol)
271
+ if dst_chunk:
272
+ callees_map.setdefault(src_chunk_id, []).append(dst_chunk)
273
+ callers_map.setdefault(dst_symbol, []).append(src_chunk_id)
274
+
275
+ return symbol_map, callees_map, callers_map
276
+
277
+ def load_for_walk_cached(self) -> tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]]:
278
+ """Return cached graph; reloads only when SHA has changed since last load."""
279
+ current_sha = self.get_last_indexed_sha()
280
+ if self._walk_cache is not None and self._walk_sha == current_sha:
281
+ return self._walk_cache
282
+ result = self.load_for_walk()
283
+ self._walk_cache = result
284
+ self._walk_sha = current_sha
285
+ return result
286
+
287
+ def flush_all(self, chunks: list[CodeChunk], sha: str) -> None:
288
+ """Build entire graph in :memory: then backup to disk in one write."""
289
+ mem = sqlite3.connect(":memory:")
290
+ mem.executescript(SCHEMA)
291
+ if chunks:
292
+ mem.executemany(
293
+ "INSERT OR IGNORE INTO symbols (chunk_id, symbol, file_path, kind, signature) VALUES (?,?,?,?,?)",
294
+ [(c.chunk_id, c.symbol, c.file_path, c.kind, c.signature) for c in chunks],
295
+ )
296
+ ref_rows = [(c.chunk_id, sym) for c in chunks for sym in c.deps]
297
+ if ref_rows:
298
+ mem.executemany(
299
+ "INSERT OR IGNORE INTO refs (src_chunk_id, dst_symbol) VALUES (?,?)",
300
+ ref_rows,
301
+ )
302
+ md_rows = [
303
+ (c.chunk_id, c.file_path, Path(c.file_path).parent.as_posix(), len(Path(c.file_path).parent.parts))
304
+ for c in chunks if c.language == 'markdown'
305
+ ]
306
+ if md_rows:
307
+ mem.executemany(
308
+ "INSERT OR IGNORE INTO md_knowledge (chunk_id, file_path, dir_path, depth) VALUES (?,?,?,?)",
309
+ md_rows,
310
+ )
311
+ # Compute export_surface_hash over the frozen symbols table BEFORE commit.
312
+ sym_names = [row[0] for row in mem.execute("SELECT symbol FROM symbols").fetchall()]
313
+ export_hash = _compute_export_surface_hash(sym_names)
314
+ mem.execute(
315
+ "INSERT INTO meta (key, value) VALUES ('export_surface_hash', ?)",
316
+ (export_hash,),
317
+ )
318
+ mem.execute(
319
+ "INSERT INTO meta (key, value) VALUES ('last_indexed_sha', ?)",
320
+ (sha,),
321
+ )
322
+ mem.commit()
323
+ dest = sqlite3.connect(self.db_path)
324
+ mem.backup(dest)
325
+ dest.close()
326
+ mem.close()
327
+ self._walk_cache = None
328
+ self._walk_sha = None
329
+
330
+ def get_last_indexed_sha(self) -> str | None:
331
+ """Return last indexed SHA."""
332
+
333
+ with self._connect() as conn:
334
+ row = conn.execute("SELECT value FROM meta WHERE key = 'last_indexed_sha'").fetchone()
335
+ return row[0] if row else None
336
+
337
+ def set_last_indexed_sha(self, sha: str) -> None:
338
+ """Persist last indexed SHA."""
339
+
340
+ self.set_meta("last_indexed_sha", sha)
341
+
342
+ def get_export_surface_hash(self) -> str | None:
343
+ """Return the current export_surface_hash, or None if not yet computed."""
344
+ with self._connect() as conn:
345
+ row = conn.execute(
346
+ "SELECT value FROM meta WHERE key = 'export_surface_hash'"
347
+ ).fetchone()
348
+ return row[0] if row else None
349
+
350
+ def recompute_export_surface_hash(self) -> str:
351
+ """Recompute and persist export_surface_hash after an incremental symbol update."""
352
+ with self._connect() as conn:
353
+ names = [row[0] for row in conn.execute("SELECT symbol FROM symbols").fetchall()]
354
+ h = _compute_export_surface_hash(names)
355
+ conn.execute(
356
+ "INSERT INTO meta (key, value) VALUES ('export_surface_hash', ?) "
357
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
358
+ (h,),
359
+ )
360
+ return h
361
+
362
+ def set_meta(self, key: str, value: str) -> None:
363
+ """Set arbitrary meta value."""
364
+
365
+ with self._connect() as conn:
366
+ conn.execute(
367
+ "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
368
+ (key, value),
369
+ )
370
+
371
+ def get_chunk_count(self) -> int:
372
+ """Return number of indexed symbols."""
373
+
374
+ with self._connect() as conn:
375
+ row = conn.execute("SELECT COUNT(*) FROM symbols").fetchone()
376
+ return int(row[0] if row else 0)
377
+
378
+ def get_all_symbols(self) -> list[tuple[str, str, str, str, str | None]]:
379
+ """Return all (chunk_id, symbol, file_path, kind, signature) sorted by file_path."""
380
+
381
+ with self._connect() as conn:
382
+ rows = conn.execute(
383
+ "SELECT chunk_id, symbol, file_path, kind, signature FROM symbols ORDER BY file_path, symbol"
384
+ ).fetchall()
385
+ return [(r[0], r[1], r[2], r[3], r[4]) for r in rows]
386
+
387
+ def lookup_symbol(self, symbol: str, file_path: str) -> str | None:
388
+ """Return chunk_id for the given (symbol, file_path), or None if not found."""
389
+
390
+ normalized = file_path.replace("\\", "/")
391
+ with self._connect() as conn:
392
+ row = conn.execute(
393
+ "SELECT chunk_id FROM symbols WHERE symbol = ? AND replace(file_path, '\\', '/') = ? LIMIT 1",
394
+ (symbol, normalized),
395
+ ).fetchone()
396
+ return row[0] if row else None
397
+
398
+ def export_artifact(self, artifact_path: Path) -> None:
399
+ """Compress the live SQLite db to a .graph.zst file via VACUUM INTO + zstd."""
400
+ import zstandard
401
+ tmp = artifact_path.with_suffix(".vacuumed.db")
402
+ try:
403
+ with self._connect() as conn:
404
+ conn.execute(f"VACUUM INTO '{tmp.as_posix()}'")
405
+ artifact_path.write_bytes(zstandard.ZstdCompressor(level=3).compress(tmp.read_bytes()))
406
+ finally:
407
+ if tmp.exists():
408
+ tmp.unlink()
409
+
410
+ def import_artifact(self, artifact_path: Path) -> None:
411
+ """Decompress .graph.zst artifact and replace the current db via SQLite backup API."""
412
+ import zstandard
413
+ data = zstandard.ZstdDecompressor().decompress(artifact_path.read_bytes())
414
+ tmp = artifact_path.with_suffix(".restore.db")
415
+ try:
416
+ tmp.write_bytes(data)
417
+ src = sqlite3.connect(tmp)
418
+ dst = sqlite3.connect(self.db_path)
419
+ src.backup(dst)
420
+ dst.close()
421
+ src.close()
422
+ finally:
423
+ if tmp.exists():
424
+ tmp.unlink()
425
+ self._walk_cache = None
426
+ self._walk_sha = None
@@ -0,0 +1,132 @@
1
+ """BM25 storage wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pickle
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import bm25s
10
+
11
+ from contracts.dtos import CodeChunk, ScoredChunk
12
+
13
+ _TOKEN_BOUNDARY_RE = re.compile(r"[^A-Za-z0-9_]+")
14
+ _CAMEL_RE = re.compile(r"([a-z0-9])([A-Z])")
15
+
16
+
17
+ class BM25Store:
18
+ """Per-project BM25 index with simple pickle persistence."""
19
+
20
+ def __init__(self, store_path: Path) -> None:
21
+ self.store_path = store_path
22
+ self._loaded = False
23
+ self._bm25: bm25s.BM25 | None = None
24
+ self._corpus_tokens: list[list[str]] = []
25
+ self._corpus_docs: list[str] = []
26
+ self._chunk_map: dict[str, CodeChunk] = {}
27
+ self._file_map: dict[str, set[str]] = {}
28
+
29
+ def load(self) -> None:
30
+ """Load persisted state lazily."""
31
+
32
+ if self._loaded:
33
+ return
34
+ if self.store_path.exists():
35
+ data = pickle.loads(self.store_path.read_bytes())
36
+ self._corpus_tokens = data["corpus_tokens"]
37
+ self._corpus_docs = data["corpus_docs"]
38
+ self._chunk_map = {key: CodeChunk.model_validate(value) for key, value in data["chunk_map"].items()}
39
+ self._file_map = {key: set(value) for key, value in data["file_map"].items()}
40
+ self._rebuild_index()
41
+ self._loaded = True
42
+
43
+ def reset(self) -> None:
44
+ """Clear all in-memory state."""
45
+
46
+ self._bm25 = None
47
+ self._corpus_tokens = []
48
+ self._corpus_docs = []
49
+ self._chunk_map = {}
50
+ self._file_map = {}
51
+ self._loaded = True
52
+
53
+ def persist(self) -> None:
54
+ """Persist the current state."""
55
+
56
+ self.store_path.parent.mkdir(parents=True, exist_ok=True)
57
+ payload = {
58
+ "corpus_tokens": self._corpus_tokens,
59
+ "corpus_docs": self._corpus_docs,
60
+ "chunk_map": {key: value.model_dump(mode="json") for key, value in self._chunk_map.items()},
61
+ "file_map": {key: sorted(value) for key, value in self._file_map.items()},
62
+ }
63
+ self.store_path.write_bytes(pickle.dumps(payload))
64
+
65
+ def add_chunks(self, chunks: list[CodeChunk]) -> None:
66
+ """Add chunks and rebuild index."""
67
+
68
+ self.load()
69
+ for chunk in chunks:
70
+ self._chunk_map[chunk.chunk_id] = chunk
71
+ self._file_map.setdefault(chunk.file_path, set()).add(chunk.chunk_id)
72
+ self._rebuild_documents()
73
+
74
+ def remove_by_file(self, file_path: str) -> None:
75
+ """Remove all chunks belonging to a file."""
76
+
77
+ self.load()
78
+ for chunk_id in self._file_map.pop(file_path, set()):
79
+ self._chunk_map.pop(chunk_id, None)
80
+ self._rebuild_documents()
81
+
82
+ def search(self, query: str, top_k: int) -> list[ScoredChunk]:
83
+ """Search lexically."""
84
+
85
+ self.load()
86
+ if not self._bm25 or not self._corpus_tokens:
87
+ return []
88
+ capped_k = min(top_k, len(self._corpus_docs))
89
+ if capped_k <= 0:
90
+ return []
91
+ results = self._bm25.retrieve([self.tokenize(query)], corpus=self._corpus_docs, k=capped_k, show_progress=False)
92
+ documents = results.documents[0].tolist() if len(results.documents) else []
93
+ scores = results.scores[0].tolist() if len(results.scores) else []
94
+ found: list[ScoredChunk] = []
95
+ for document, score in zip(documents, scores, strict=False):
96
+ chunk = self._chunk_map.get(document)
97
+ if chunk:
98
+ found.append(ScoredChunk(chunk=chunk, score=float(score), source="bm25"))
99
+ return found
100
+
101
+ def get_by_ids(self, chunk_ids: list[str]) -> list[CodeChunk]:
102
+ """Return chunks for the given IDs, preserving order and skipping missing."""
103
+ self.load()
104
+ return [self._chunk_map[cid] for cid in chunk_ids if cid in self._chunk_map]
105
+
106
+ def chunk_count(self) -> int:
107
+ """Return indexed chunk count."""
108
+
109
+ self.load()
110
+ return len(self._chunk_map)
111
+
112
+ @staticmethod
113
+ def tokenize(text: str) -> list[str]:
114
+ """Split code-aware tokens."""
115
+
116
+ normalized = _CAMEL_RE.sub(r"\1 \2", text.replace(".", " ").replace("/", " ").replace("\\", " "))
117
+ return [token.lower() for token in _TOKEN_BOUNDARY_RE.split(normalized) if token]
118
+
119
+ def _rebuild_documents(self) -> None:
120
+ self._corpus_docs = list(self._chunk_map.keys())
121
+ self._corpus_tokens = [
122
+ self.tokenize(f"{chunk.file_path} {chunk.symbol} {chunk.signature or ''} {chunk.body}")
123
+ for chunk in self._chunk_map.values()
124
+ ]
125
+ self._rebuild_index()
126
+
127
+ def _rebuild_index(self) -> None:
128
+ if not self._corpus_tokens:
129
+ self._bm25 = None
130
+ return
131
+ self._bm25 = bm25s.BM25()
132
+ self._bm25.index(self._corpus_tokens, show_progress=False)