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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- 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 = ""
|