code-atlas-forge 1.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 (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,505 @@
1
+ """StructuralSearch: queries reais sobre o grafo persistido (Phase 6).
2
+
3
+ Somente SQL parametrizado. Todas as queries têm ``snapshot_id`` como
4
+ âncora — nunca vaza dado entre snapshots nem entre repositórios.
5
+
6
+ Paginação: ``LIMIT ? OFFSET ?`` com binds; ``total`` vem de um COUNT
7
+ paralelo, e ``truncated`` = ``offset + len(results) < total``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from code_atlas.contracts.enums import SnapshotStatus
18
+ from code_atlas.contracts.models import Snapshot
19
+ from code_atlas.storage.store import Store
20
+
21
+ SCHEMA_VERSION = "2"
22
+ DEFAULT_LIMIT = 50
23
+ MAX_LIMIT = 500
24
+ MAX_DEPTH = 8 # lf-0022: teto duro de --depth (traversal limitado)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SearchFilters:
29
+ """Filtros opcionais — None significa sem restrição."""
30
+
31
+ language: str | None = None
32
+ kind: str | None = None
33
+ path: str | None = None # substring em relative_path
34
+ relation_type: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Page:
39
+ """Resultado paginado com flag de truncamento honesta."""
40
+
41
+ total: int
42
+ truncated: bool
43
+ items: tuple[dict[str, Any], ...]
44
+
45
+
46
+ class StructuralSearch:
47
+ """Implementação real sobre sqlite (Storage-backed SearchPort)."""
48
+
49
+ def __init__(self, conn: sqlite3.Connection) -> None:
50
+ self._conn = conn
51
+ self._store = Store(conn)
52
+
53
+ # -- snapshot resolution -------------------------------------------
54
+
55
+ def repo_for_root(self, root_handle: str) -> int | None:
56
+ return self._store.repository_id_for(root_handle)
57
+
58
+ def repository_info(self, repo_id: int) -> dict[str, Any] | None:
59
+ """{id, root_handle, identity_key} para o envelope (lf-0022)."""
60
+ row = self._conn.execute(
61
+ "SELECT id, root_handle, identity_key FROM repositories"
62
+ " WHERE id = ?",
63
+ (repo_id,),
64
+ ).fetchone()
65
+ if row is None:
66
+ return None
67
+ return {
68
+ "id": int(row["id"]),
69
+ "root_handle": row["root_handle"],
70
+ "identity_key": row["identity_key"],
71
+ }
72
+
73
+ def resolve_snapshot(
74
+ self, repo_id: int, snapshot_id: int | None
75
+ ) -> tuple[Snapshot | None, str]:
76
+ """READY mais recente por padrão; --snapshot exige id READY do repo."""
77
+ if snapshot_id is None:
78
+ snap = self._store.latest_ready_snapshot(repo_id)
79
+ return (snap, "" if snap else "no READY snapshot for repository")
80
+ snap = self._store.snapshot_by_id(snapshot_id)
81
+ if snap is None or snap.repository_id != repo_id:
82
+ return None, f"snapshot {snapshot_id} not found for this repository"
83
+ if snap.status is not SnapshotStatus.READY:
84
+ return None, f"snapshot {snapshot_id} is {snap.status}, not READY"
85
+ return snap, ""
86
+
87
+ # -- queries ---------------------------------------------------------
88
+
89
+ def search_symbols(
90
+ self,
91
+ repo_id: int,
92
+ snapshot_id: int,
93
+ query: str,
94
+ filters: SearchFilters,
95
+ limit: int,
96
+ offset: int,
97
+ ) -> Page:
98
+ """Substring case-insensitive sobre qualified_name/name."""
99
+ conds = ["o.snapshot_id = ?", "s.repository_id = ?"]
100
+ params: list[object] = [snapshot_id, repo_id]
101
+ if query:
102
+ conds.append(
103
+ "(s.qualified_name LIKE ? ESCAPE '\\' "
104
+ "OR s.qualified_name LIKE ? ESCAPE '\\')"
105
+ )
106
+ like = f"%{_escape_like(query)}%"
107
+ params += [like, like]
108
+ if filters.language:
109
+ conds.append("s.language = ?")
110
+ params.append(filters.language)
111
+ if filters.kind:
112
+ conds.append("s.kind = ?")
113
+ params.append(filters.kind)
114
+ if filters.path:
115
+ conds.append("f.relative_path LIKE ? ESCAPE '\\'")
116
+ params.append(f"%{_escape_like(filters.path)}%")
117
+ # `where` contém SOMENTE fragmentos constantes do código (nomes de
118
+ # coluna + ? binds) — input do usuário flui exclusivamente por
119
+ # `params`. A auditoria estática exige a query em variável local.
120
+ where = " AND ".join(conds)
121
+ join = (
122
+ "FROM symbols s "
123
+ "JOIN symbol_occurrences o ON o.logical_symbol_id = s.id "
124
+ "JOIN source_files f ON f.id = o.source_file_id "
125
+ f"WHERE {where}"
126
+ )
127
+ count_sql = "SELECT COUNT(DISTINCT s.id) AS c " + join
128
+ total_row = self._conn.execute(count_sql, params).fetchone()
129
+ # lf-0051: occurrence representativa + path por subquery escalar
130
+ # (MIN id no snapshot) — grounding hit→ocorrência sem duplicar
131
+ # linhas por arquivo. Params das subqueries precedem os do WHERE.
132
+ list_sql = (
133
+ "SELECT DISTINCT s.id, s.language, s.qualified_name, s.kind,"
134
+ " (SELECT MIN(o2.id) FROM symbol_occurrences o2"
135
+ " WHERE o2.logical_symbol_id = s.id"
136
+ " AND o2.snapshot_id = ?) AS occurrence_id,"
137
+ " (SELECT f2.relative_path FROM symbol_occurrences o3"
138
+ " JOIN source_files f2 ON f2.id = o3.source_file_id"
139
+ " WHERE o3.logical_symbol_id = s.id"
140
+ " AND o3.snapshot_id = ? ORDER BY o3.id LIMIT 1) AS path "
141
+ + join
142
+ + " ORDER BY s.qualified_name, s.id LIMIT ? OFFSET ?"
143
+ )
144
+ rows = self._conn.execute(
145
+ list_sql,
146
+ [snapshot_id, snapshot_id, *params, limit, offset],
147
+ ).fetchall()
148
+ items = tuple(
149
+ {
150
+ "symbol_id": int(r["id"]),
151
+ "qualified_name": r["qualified_name"],
152
+ "language": r["language"],
153
+ "kind": r["kind"],
154
+ "occurrence_id": (
155
+ int(r["occurrence_id"])
156
+ if r["occurrence_id"] is not None else 0),
157
+ "path": r["path"] or "",
158
+ }
159
+ for r in rows
160
+ )
161
+ total = int(total_row["c"])
162
+ return Page(total, offset + len(items) < total, items)
163
+
164
+ def symbol_detail(
165
+ self, repo_id: int, snapshot_id: int, ident: str
166
+ ) -> dict[str, Any] | None:
167
+ """Detalhe de símbolo por id numérico ou qualified_name exato."""
168
+ if ident.isdigit():
169
+ sym = self._conn.execute(
170
+ "SELECT * FROM symbols WHERE id = ? AND repository_id = ?",
171
+ (int(ident), repo_id),
172
+ ).fetchone()
173
+ else:
174
+ sym = self._conn.execute(
175
+ "SELECT * FROM symbols WHERE qualified_name = ? "
176
+ "AND repository_id = ? ORDER BY id LIMIT 1",
177
+ (ident, repo_id),
178
+ ).fetchone()
179
+ if sym is None:
180
+ return None
181
+ occs = self._conn.execute(
182
+ """
183
+ SELECT o.id, o.line, o.col, o.parse_status, o.content_hash,
184
+ f.relative_path
185
+ FROM symbol_occurrences o
186
+ JOIN source_files f ON f.id = o.source_file_id
187
+ WHERE o.snapshot_id = ? AND o.logical_symbol_id = ?
188
+ ORDER BY f.relative_path, o.line
189
+ """,
190
+ (snapshot_id, int(sym["id"])),
191
+ ).fetchall()
192
+ return {
193
+ "symbol_id": int(sym["id"]),
194
+ "qualified_name": sym["qualified_name"],
195
+ "language": sym["language"],
196
+ "kind": sym["kind"],
197
+ "occurrences": [
198
+ {
199
+ "occurrence_id": int(o["id"]),
200
+ "path": o["relative_path"],
201
+ "line": o["line"],
202
+ "col": o["col"],
203
+ "parse_status": o["parse_status"],
204
+ }
205
+ for o in occs
206
+ ],
207
+ }
208
+
209
+ def references(
210
+ self,
211
+ repo_id: int,
212
+ snapshot_id: int,
213
+ ident: str,
214
+ relation_type: str | None,
215
+ limit: int,
216
+ offset: int,
217
+ ) -> tuple[Page | None, str]:
218
+ """Arestas **entrantes** do símbolo (lf-0022: ``o2`` é o símbolo).
219
+
220
+ Default: tipos REFERENCES+CALLS; ``relation_type`` filtra outro
221
+ tipo explicitamente. ``ambiguous`` permanece ``ambiguous`` —
222
+ nunca promovido a ``resolved``.
223
+ """
224
+ detail = self.symbol_detail(repo_id, snapshot_id, ident)
225
+ if detail is None:
226
+ return None, f"symbol {ident!r} not found in repository"
227
+ sym_id = int(detail["symbol_id"])
228
+
229
+ conds = ["r.snapshot_id = ?", "o2.logical_symbol_id = ?"]
230
+ params: list[object] = [snapshot_id, sym_id]
231
+ if relation_type:
232
+ conds.append("r.type = ?")
233
+ params.append(relation_type)
234
+ else:
235
+ conds.append("r.type IN ('REFERENCES','CALLS')")
236
+ where = " AND ".join(conds)
237
+
238
+ # ver nota em search_symbols: fragments constantes + binds
239
+ base = (
240
+ "FROM relations r "
241
+ "JOIN symbol_occurrences o1 ON o1.id = r.from_id "
242
+ "JOIN symbols sf ON sf.id = o1.logical_symbol_id "
243
+ "JOIN source_files ff ON ff.id = o1.source_file_id "
244
+ "LEFT JOIN symbol_occurrences o2 ON o2.id = r.to_id "
245
+ "LEFT JOIN symbols st ON st.id = o2.logical_symbol_id "
246
+ "LEFT JOIN source_files ft ON ft.id = o2.source_file_id "
247
+ f"WHERE {where}"
248
+ )
249
+ count_sql = "SELECT COUNT(*) AS c " + base
250
+ total_row = self._conn.execute(count_sql, params).fetchone()
251
+ list_sql = (
252
+ "SELECT r.id, r.type, r.resolution_status, r.confidence_class, "
253
+ "r.resolver, r.to_unresolved, r.evidence_ids, "
254
+ "sf.qualified_name AS from_qname, st.qualified_name AS to_qname, "
255
+ "ff.relative_path AS from_path, ft.relative_path AS to_path, "
256
+ "o1.line AS from_line, o2.line AS to_line "
257
+ + base
258
+ + " ORDER BY r.id LIMIT ? OFFSET ?"
259
+ )
260
+ rows = self._conn.execute(list_sql, [*params, limit, offset]).fetchall()
261
+ items = tuple(
262
+ {
263
+ "relation_id": int(r["id"]),
264
+ "type": r["type"],
265
+ "from": {
266
+ "symbol": r["from_qname"],
267
+ "path": r["from_path"],
268
+ "line": r["from_line"],
269
+ },
270
+ "to": (
271
+ {
272
+ "symbol": r["to_qname"],
273
+ "path": r["to_path"],
274
+ "line": r["to_line"],
275
+ }
276
+ if r["to_qname"] is not None
277
+ else None
278
+ ),
279
+ "to_unresolved": r["to_unresolved"],
280
+ "resolution_status": r["resolution_status"],
281
+ "confidence_class": r["confidence_class"],
282
+ "resolver": r["resolver"],
283
+ "evidence_ids": json.loads(r["evidence_ids"]),
284
+ }
285
+ for r in rows
286
+ )
287
+ total = int(total_row["c"])
288
+ return Page(total, offset + len(items) < total, items), ""
289
+
290
+ def graph_dump(
291
+ self, snapshot_id: int, filters: SearchFilters, limit: int
292
+ ) -> dict[str, Any]:
293
+ """Nós + arestas do snapshot — JSON exportável, sem dependência de UI."""
294
+ nconds = ["o.snapshot_id = ?"]
295
+ nparams: list[object] = [snapshot_id]
296
+ if filters.language:
297
+ nconds.append("s.language = ?")
298
+ nparams.append(filters.language)
299
+ if filters.kind:
300
+ nconds.append("s.kind = ?")
301
+ nparams.append(filters.kind)
302
+ if filters.path:
303
+ nconds.append("f.relative_path LIKE ? ESCAPE '\\'")
304
+ nparams.append(f"%{_escape_like(filters.path)}%")
305
+ nodes_sql = (
306
+ "SELECT o.id AS occurrence_id, s.id AS symbol_id, "
307
+ "s.qualified_name, s.kind, s.language, "
308
+ "f.relative_path, o.line, o.col, o.parse_status "
309
+ "FROM symbol_occurrences o "
310
+ "JOIN symbols s ON s.id = o.logical_symbol_id "
311
+ "JOIN source_files f ON f.id = o.source_file_id "
312
+ "WHERE " + " AND ".join(nconds) + " ORDER BY o.id LIMIT ?"
313
+ )
314
+ nodes = self._conn.execute(nodes_sql, [*nparams, limit]).fetchall()
315
+
316
+ econds = ["r.snapshot_id = ?"]
317
+ eparams: list[object] = [snapshot_id]
318
+ if filters.relation_type:
319
+ econds.append("r.type = ?")
320
+ eparams.append(filters.relation_type)
321
+ edges_sql = (
322
+ "SELECT r.id, r.from_id, r.to_id, r.to_unresolved, r.type, "
323
+ "r.resolution_status, r.confidence_class, r.resolver "
324
+ "FROM relations r WHERE " + " AND ".join(econds)
325
+ + " ORDER BY r.id LIMIT ?"
326
+ )
327
+ edges = self._conn.execute(edges_sql, [*eparams, limit]).fetchall()
328
+
329
+ return {
330
+ "nodes": [
331
+ {
332
+ "occurrence_id": int(n["occurrence_id"]),
333
+ "symbol_id": int(n["symbol_id"]),
334
+ "qualified_name": n["qualified_name"],
335
+ "kind": n["kind"],
336
+ "language": n["language"],
337
+ "path": n["relative_path"],
338
+ "line": n["line"],
339
+ "parse_status": n["parse_status"],
340
+ }
341
+ for n in nodes
342
+ ],
343
+ "edges": [
344
+ {
345
+ "relation_id": int(e["id"]),
346
+ "from_occurrence": e["from_id"],
347
+ "to_occurrence": e["to_id"],
348
+ "to_unresolved": e["to_unresolved"],
349
+ "type": e["type"],
350
+ "resolution_status": e["resolution_status"],
351
+ "confidence_class": e["confidence_class"],
352
+ "resolver": e["resolver"],
353
+ }
354
+ for e in edges
355
+ ],
356
+ "counts": {"nodes": len(nodes), "edges": len(edges)},
357
+ }
358
+
359
+ def definitions(
360
+ self, repo_id: int, snapshot_id: int, ident: str
361
+ ) -> dict[str, Any] | None:
362
+ """Sites de definição do símbolo (path + span) — lf-0022.
363
+
364
+ Ocorrências do símbolo lógico são seus pontos de definição;
365
+ inclui ``parse_status`` para honestidade do site.
366
+ """
367
+ detail = self.symbol_detail(repo_id, snapshot_id, ident)
368
+ if detail is None:
369
+ return None
370
+ return {
371
+ "symbol_id": detail["symbol_id"],
372
+ "qualified_name": detail["qualified_name"],
373
+ "kind": detail["kind"],
374
+ "language": detail["language"],
375
+ "definitions": [
376
+ {
377
+ "path": o["path"],
378
+ "line": o["line"],
379
+ "col": o["col"],
380
+ "parse_status": o["parse_status"],
381
+ }
382
+ for o in detail["occurrences"]
383
+ ],
384
+ }
385
+
386
+ def dependencies(
387
+ self,
388
+ repo_id: int,
389
+ snapshot_id: int,
390
+ ident: str,
391
+ depth: int,
392
+ limit: int,
393
+ offset: int,
394
+ ) -> tuple[dict[str, Any] | None, str]:
395
+ """Arestas **saíntes** do símbolo com traversal BFS — lf-0022.
396
+
397
+ ``depth`` já chega clampado (1..MAX_DEPTH). Ciclo-safe:
398
+ ``visited`` de symbol_ids impede re-expansão. ``truncated`` é
399
+ verdadeiro quando o budget de nós (``limit`` por página acumula
400
+ ``MAX_LIMIT``) ou o ``offset`` corta o resultado. Arestas
401
+ ``ambiguous``/``unresolved`` aparecem com seu status real —
402
+ nunca promovidas a ``resolved``.
403
+ """
404
+ detail = self.symbol_detail(repo_id, snapshot_id, ident)
405
+ if detail is None:
406
+ return None, f"symbol {ident!r} not found in repository"
407
+ start_id = int(detail["symbol_id"])
408
+
409
+ node_budget = MAX_LIMIT
410
+ visited = {start_id}
411
+ frontier = {start_id}
412
+ edges: list[dict[str, Any]] = []
413
+ truncated = False
414
+ edge_sql = (
415
+ "SELECT r.id, r.type, r.resolution_status, r.confidence_class,"
416
+ " r.resolver, r.to_unresolved, r.evidence_ids,"
417
+ " sf.qualified_name AS from_qname, st.id AS to_symbol_id,"
418
+ " st.qualified_name AS to_qname,"
419
+ " ff.relative_path AS from_path, ft.relative_path AS to_path,"
420
+ " o1.line AS from_line, o2.line AS to_line"
421
+ " FROM relations r"
422
+ " JOIN symbol_occurrences o1 ON o1.id = r.from_id"
423
+ " JOIN symbols sf ON sf.id = o1.logical_symbol_id"
424
+ " JOIN source_files ff ON ff.id = o1.source_file_id"
425
+ " LEFT JOIN symbol_occurrences o2 ON o2.id = r.to_id"
426
+ " LEFT JOIN symbols st ON st.id = o2.logical_symbol_id"
427
+ " LEFT JOIN source_files ft ON ft.id = o2.source_file_id"
428
+ " WHERE r.snapshot_id = ? AND o1.logical_symbol_id = ?"
429
+ " AND r.type IN ('IMPORTS','CALLS','REFERENCES','EXTENDS',"
430
+ " 'IMPLEMENTS')"
431
+ " ORDER BY r.id"
432
+ )
433
+ for level in range(1, depth + 1):
434
+ if not frontier or truncated:
435
+ break
436
+ next_frontier: set[int] = set()
437
+ for sym_id in sorted(frontier):
438
+ rows = self._conn.execute(
439
+ edge_sql, (snapshot_id, sym_id)
440
+ ).fetchall()
441
+ for r in rows:
442
+ if len(edges) >= node_budget:
443
+ truncated = True
444
+ break
445
+ to_sid = r["to_symbol_id"]
446
+ if to_sid is not None and int(to_sid) not in visited:
447
+ visited.add(int(to_sid))
448
+ next_frontier.add(int(to_sid))
449
+ edges.append(
450
+ {
451
+ "relation_id": int(r["id"]),
452
+ "depth": level,
453
+ "type": r["type"],
454
+ "from": {
455
+ "symbol": r["from_qname"],
456
+ "path": r["from_path"],
457
+ "line": r["from_line"],
458
+ },
459
+ "to": (
460
+ {
461
+ "symbol": r["to_qname"],
462
+ "symbol_id": int(to_sid),
463
+ "path": r["to_path"],
464
+ "line": r["to_line"],
465
+ }
466
+ if r["to_qname"] is not None
467
+ else None
468
+ ),
469
+ "to_unresolved": r["to_unresolved"],
470
+ "resolution_status": r["resolution_status"],
471
+ "confidence_class": r["confidence_class"],
472
+ "resolver": r["resolver"],
473
+ "evidence_ids": json.loads(r["evidence_ids"]),
474
+ }
475
+ )
476
+ if truncated:
477
+ break
478
+ frontier = next_frontier
479
+ total = len(edges)
480
+ page_items = edges[offset : offset + limit]
481
+ truncated = truncated or (offset + len(page_items) < total)
482
+ return (
483
+ {
484
+ "symbol_id": start_id,
485
+ "qualified_name": detail["qualified_name"],
486
+ "depth": depth,
487
+ "total": total,
488
+ "truncated": truncated,
489
+ "results": page_items,
490
+ "visited_symbols": len(visited),
491
+ },
492
+ "",
493
+ )
494
+
495
+
496
+ def _escape_like(term: str) -> str:
497
+ """Escapa curingas LIKE — input do usuário nunca vira padrão."""
498
+ return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
499
+
500
+
501
+ def clamp_limit(limit: int | None) -> int:
502
+ """Sane default + teto duro — saída nunca é ilimitada."""
503
+ if limit is None:
504
+ return DEFAULT_LIMIT
505
+ return max(0, min(int(limit), MAX_LIMIT))
@@ -0,0 +1,43 @@
1
+ """Tokenização de identificadores para o índice lexical (lf-0036).
2
+
3
+ FTS5 via ``sqlite3`` não permite tokenizer customizado (API C-only) —
4
+ a segmentação de identificadores é feita em Python na ingestão e na
5
+ query: ``processCustomerOrders`` indexa como
6
+ ``process customer orders`` na coluna ``terms`` (além do termo cru na
7
+ coluna ``name``), recuperável por fragmento E por forma exata.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ _SEGMENTS = re.compile(r"[^0-9A-Za-z]+")
15
+ _WORD = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+")
16
+
17
+
18
+ def split_identifier(text: str) -> list[str]:
19
+ """Divide um identificador/caminho em tokens minúsculos.
20
+
21
+ snake_case, camelCase, PascalCase, qualified.names, paths,
22
+ dígitos e siglas (``HTTPServer`` → ``http server``).
23
+ """
24
+ out: list[str] = []
25
+ for seg in _SEGMENTS.split(text):
26
+ if not seg:
27
+ continue
28
+ out.extend(m.group(0).lower() for m in _WORD.finditer(seg))
29
+ return out
30
+
31
+
32
+ def index_terms(*parts: str) -> str:
33
+ """Texto da coluna ``terms``: união ordenada dos tokens de todos os
34
+ campos (name + qualified_name + module)."""
35
+ toks: set[str] = set()
36
+ for p in parts:
37
+ toks.update(split_identifier(p))
38
+ return " ".join(sorted(toks))
39
+
40
+
41
+ def fts_escape(term: str) -> str:
42
+ """Escapa um termo para uso dentro de ``"..."`` no MATCH do FTS5."""
43
+ return '"' + term.replace('"', '""') + '"'
@@ -0,0 +1,35 @@
1
+ """Subsistema vetorial opt-in (lf-0039 / Cycle 4 Ph4)."""
2
+
3
+ from code_atlas.search.vector.contracts import (
4
+ EmbeddingModelInfo,
5
+ EmbeddingProvider,
6
+ VectorCapability,
7
+ VectorIndex,
8
+ VectorSearchResult,
9
+ )
10
+ from code_atlas.search.vector.embeddings import (
11
+ SqliteEmbeddingCache,
12
+ content_hash,
13
+ embed_texts_cached,
14
+ index_text_for,
15
+ )
16
+ from code_atlas.search.vector.index import (
17
+ BruteForceIndex,
18
+ sqlite_vec_available,
19
+ )
20
+ from code_atlas.search.vector.provider import NaiveEmbeddingProvider
21
+
22
+ __all__ = [
23
+ "BruteForceIndex",
24
+ "EmbeddingModelInfo",
25
+ "EmbeddingProvider",
26
+ "NaiveEmbeddingProvider",
27
+ "SqliteEmbeddingCache",
28
+ "VectorCapability",
29
+ "VectorIndex",
30
+ "VectorSearchResult",
31
+ "content_hash",
32
+ "embed_texts_cached",
33
+ "index_text_for",
34
+ "sqlite_vec_available",
35
+ ]
@@ -0,0 +1,76 @@
1
+ """Contratos do subsistema vetorial (lf-0039 / Cycle 4 Ph4).
2
+
3
+ Interfaces apenas — nenhuma dependência externa. sqlite-vec é um
4
+ extra opt-in (``[vector]``); ausência é reportada como capability
5
+ honesta, nunca erro silencioso.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class EmbeddingModelInfo:
16
+ """Identidade versionada do modelo de embedding."""
17
+
18
+ model_id: str # ex.: "naive-hash"
19
+ version: str # ex.: "v1" — muda → reindex
20
+ dim: int # dimensionalidade do vetor
21
+ detail: str = ""
22
+
23
+
24
+ class EmbeddingProvider(Protocol):
25
+ """Gera embeddings para textos — local por default."""
26
+
27
+ def info(self) -> EmbeddingModelInfo: ...
28
+ def embed(self, texts: list[str]) -> list[list[float]]:
29
+ """Um vetor por texto, dimensão ``info().dim``, determinístico."""
30
+ ...
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class VectorSearchResult:
35
+ """Hit vetorial bruto (pré-fusão)."""
36
+
37
+ symbol_key: str
38
+ occurrence_id: int
39
+ score: float # similaridade cosseno [0, 1] após clamp
40
+ model_id: str
41
+
42
+
43
+ class VectorIndex(Protocol):
44
+ """Index de vetores por snapshot — bruteforce ou sqlite-vec."""
45
+
46
+ def upsert(
47
+ self, snapshot_id: int, occurrence_id: int,
48
+ symbol_key: str, vector: list[float],
49
+ ) -> None: ...
50
+ def search(
51
+ self, snapshot_id: int, query: list[float], limit: int,
52
+ ) -> list[VectorSearchResult]: ...
53
+ def delete_snapshot(self, snapshot_id: int) -> None: ...
54
+
55
+
56
+ class EmbeddingCache(Protocol):
57
+ """Cache por conteúdo+modelo — rebuild só quando algo muda."""
58
+
59
+ def get(
60
+ self, model: EmbeddingModelInfo, content_hash: str
61
+ ) -> list[float] | None: ...
62
+ def put(
63
+ self, model: EmbeddingModelInfo, content_hash: str,
64
+ vector: list[float],
65
+ ) -> None: ...
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class VectorCapability:
70
+ """O que o backend vetorial oferece nesta build/instalação."""
71
+
72
+ available: bool
73
+ backend: str # "sqlite-vec" | "bruteforce" | "none"
74
+ provider: str # model_id ou ""
75
+ dim: int = 0
76
+ detail: str = ""