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,1045 @@
|
|
|
1
|
+
"""Acesso a dados do grafo (GraphStore parcial do Cycle 1).
|
|
2
|
+
|
|
3
|
+
Somente queries parametrizadas. O Cycle 1 escreve inventário + snapshots;
|
|
4
|
+
``symbols``/``relations``/``evidence`` existem no schema mas não são
|
|
5
|
+
populados por parsing real ainda.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from code_atlas.contracts.enums import SnapshotStatus
|
|
15
|
+
from code_atlas.contracts.models import Repository, Snapshot
|
|
16
|
+
from code_atlas.discovery.models import SourceFile, WarningKind
|
|
17
|
+
from code_atlas.domains.model import DomainEntity
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _snapshot_from_row(row: sqlite3.Row) -> Snapshot:
|
|
21
|
+
return Snapshot(
|
|
22
|
+
id=int(row["id"]),
|
|
23
|
+
repository_id=int(row["repository_id"]),
|
|
24
|
+
git_ref=str(row["git_ref"]),
|
|
25
|
+
commit_sha=row["commit_sha"],
|
|
26
|
+
dirty=bool(row["dirty"]),
|
|
27
|
+
worktree_digest=str(row["worktree_digest"]),
|
|
28
|
+
analyzer_versions=json.loads(row["analyzer_versions"]),
|
|
29
|
+
parent_snapshot_id=row["parent_snapshot_id"],
|
|
30
|
+
status=SnapshotStatus(str(row["status"])),
|
|
31
|
+
created_at=str(row["created_at"]),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Store:
|
|
36
|
+
"""Repositório de dados sobre uma conexão sqlite3 já configurada."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
39
|
+
self._conn = conn
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def conn(self) -> sqlite3.Connection:
|
|
43
|
+
"""Conexão subjacente — para queries compostas (lf-0019 reuse)."""
|
|
44
|
+
return self._conn
|
|
45
|
+
|
|
46
|
+
# --- repositories -------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def get_or_create_repository(
|
|
49
|
+
self, identity_key: str, root_handle: str = ""
|
|
50
|
+
) -> int:
|
|
51
|
+
"""Repo estável por **identidade lógica** (lf-0021), não por path.
|
|
52
|
+
|
|
53
|
+
``identity_key`` vem de ``discovery.identity`` (git remote →
|
|
54
|
+
commit raiz → path). ``root_handle`` é só diagnóstico —
|
|
55
|
+
atualizado a cada index para refletir a localização atual
|
|
56
|
+
(realocação mantém identidade).
|
|
57
|
+
|
|
58
|
+
Race-safe (lf-0015 + v5): UNIQUE em ``identity_key`` +
|
|
59
|
+
``INSERT OR IGNORE`` — dois indexadores concorrentes convergem
|
|
60
|
+
para a mesma linha.
|
|
61
|
+
"""
|
|
62
|
+
row = self._conn.execute(
|
|
63
|
+
"SELECT id, root_handle FROM repositories WHERE identity_key = ?",
|
|
64
|
+
(identity_key,),
|
|
65
|
+
).fetchone()
|
|
66
|
+
if row is not None:
|
|
67
|
+
if root_handle and row["root_handle"] != root_handle:
|
|
68
|
+
self._conn.execute(
|
|
69
|
+
"UPDATE repositories SET root_handle = ? WHERE id = ?",
|
|
70
|
+
(root_handle, int(row["id"])),
|
|
71
|
+
)
|
|
72
|
+
return int(row["id"])
|
|
73
|
+
self._conn.execute(
|
|
74
|
+
"INSERT OR IGNORE INTO repositories (root_handle, identity_key) "
|
|
75
|
+
"VALUES (?, ?)",
|
|
76
|
+
(root_handle, identity_key),
|
|
77
|
+
)
|
|
78
|
+
row2 = self._conn.execute(
|
|
79
|
+
"SELECT id FROM repositories WHERE identity_key = ?", (identity_key,)
|
|
80
|
+
).fetchone()
|
|
81
|
+
assert row2 is not None # UNIQUE + INSERT OR IGNORE garante a linha
|
|
82
|
+
return int(row2["id"])
|
|
83
|
+
|
|
84
|
+
def repository_id_for(self, key_or_handle: str) -> int | None:
|
|
85
|
+
"""Id do repo por ``identity_key`` **ou** ``root_handle``.
|
|
86
|
+
|
|
87
|
+
Compat de lookup: busca por identity primeiro (repos novos) e
|
|
88
|
+
cai para root_handle (repos pré-v5/sem git indexados por path).
|
|
89
|
+
"""
|
|
90
|
+
row = self._conn.execute(
|
|
91
|
+
"SELECT id FROM repositories WHERE identity_key = ?"
|
|
92
|
+
" OR root_handle = ? ORDER BY id LIMIT 1",
|
|
93
|
+
(key_or_handle, key_or_handle),
|
|
94
|
+
).fetchone()
|
|
95
|
+
return int(row["id"]) if row else None
|
|
96
|
+
|
|
97
|
+
def repository_by_id(self, repo_id: int) -> Repository | None:
|
|
98
|
+
row = self._conn.execute(
|
|
99
|
+
"SELECT * FROM repositories WHERE id = ?", (repo_id,)
|
|
100
|
+
).fetchone()
|
|
101
|
+
if row is None:
|
|
102
|
+
return None
|
|
103
|
+
return Repository(
|
|
104
|
+
id=int(row["id"]),
|
|
105
|
+
root_handle=str(row["root_handle"]),
|
|
106
|
+
origin=str(row["origin"]),
|
|
107
|
+
git_provider=str(row["git_provider"]),
|
|
108
|
+
default_branch=str(row["default_branch"]),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# --- snapshots ----------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def latest_ready_snapshot(self, repository_id: int) -> Snapshot | None:
|
|
114
|
+
row = self._conn.execute(
|
|
115
|
+
"SELECT * FROM snapshots WHERE repository_id = ? AND status = 'READY' "
|
|
116
|
+
"ORDER BY id DESC LIMIT 1",
|
|
117
|
+
(repository_id,),
|
|
118
|
+
).fetchone()
|
|
119
|
+
return _snapshot_from_row(row) if row else None
|
|
120
|
+
|
|
121
|
+
def snapshot_by_id(self, snapshot_id: int) -> Snapshot | None:
|
|
122
|
+
row = self._conn.execute(
|
|
123
|
+
"SELECT * FROM snapshots WHERE id = ?", (snapshot_id,)
|
|
124
|
+
).fetchone()
|
|
125
|
+
return _snapshot_from_row(row) if row else None
|
|
126
|
+
|
|
127
|
+
def insert_snapshot(
|
|
128
|
+
self,
|
|
129
|
+
repository_id: int,
|
|
130
|
+
worktree_digest: str,
|
|
131
|
+
analyzer_versions: dict[str, str],
|
|
132
|
+
parent_snapshot_id: int | None,
|
|
133
|
+
git_ref: str = "",
|
|
134
|
+
commit_sha: str | None = None,
|
|
135
|
+
dirty: bool = False,
|
|
136
|
+
) -> int:
|
|
137
|
+
"""Cria snapshot em estado BUILDING.
|
|
138
|
+
|
|
139
|
+
``dirty`` (lf-0021): worktree com alterações não commitadas —
|
|
140
|
+
snapshot é **vinculado ao estado atual**, nunca ao ``commit_sha``
|
|
141
|
+
limpo (queries veem ``commit_sha`` do HEAD mas ``dirty=1`` marca
|
|
142
|
+
que o conteúdo indexado diverge dele).
|
|
143
|
+
"""
|
|
144
|
+
cur = self._conn.execute(
|
|
145
|
+
"INSERT INTO snapshots (repository_id, git_ref, commit_sha, dirty, "
|
|
146
|
+
"worktree_digest, analyzer_versions, parent_snapshot_id, status) "
|
|
147
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 'BUILDING')",
|
|
148
|
+
(
|
|
149
|
+
repository_id,
|
|
150
|
+
git_ref,
|
|
151
|
+
commit_sha,
|
|
152
|
+
int(dirty),
|
|
153
|
+
worktree_digest,
|
|
154
|
+
json.dumps(analyzer_versions, sort_keys=True),
|
|
155
|
+
parent_snapshot_id,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
return int(cur.lastrowid) # type: ignore[arg-type]
|
|
159
|
+
|
|
160
|
+
def set_snapshot_status(
|
|
161
|
+
self, snapshot_id: int, status: SnapshotStatus, error: str | None = None
|
|
162
|
+
) -> None:
|
|
163
|
+
self._conn.execute(
|
|
164
|
+
"UPDATE snapshots SET status = ?, error = COALESCE(?, error) WHERE id = ?",
|
|
165
|
+
(str(status), error, snapshot_id),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def publish_snapshot(self, repository_id: int, snapshot_id: int) -> None:
|
|
169
|
+
"""Marca o novo READY e torna STALE os anteriores (mesma transação).
|
|
170
|
+
|
|
171
|
+
lf-0132: também renova ``sqlite_stat1`` das tabelas de domínio —
|
|
172
|
+
os índices ``(snapshot_id, endpoint)`` de v11 só vencem o
|
|
173
|
+
range-scan de ``idx_domain_relations_snap`` quando o planner
|
|
174
|
+
conhece a seletividade real. O ANALYZE roda dentro da tx de
|
|
175
|
+
publicação: stats commitam atomicamente com os dados que
|
|
176
|
+
descrevem. Custo é O(tabelas de domínio) por publicação —
|
|
177
|
+
proporcional e medido em ``benchmarks/bench_sqlite.py``.
|
|
178
|
+
``PRAGMA optimize`` foi medido e não re-analisa tabelas
|
|
179
|
+
crescidas de forma confiável — ANALYZE explícito é o honesto.
|
|
180
|
+
"""
|
|
181
|
+
self._conn.execute(
|
|
182
|
+
"UPDATE snapshots SET status = 'STALE' "
|
|
183
|
+
"WHERE repository_id = ? AND status = 'READY' AND id != ?",
|
|
184
|
+
(repository_id, snapshot_id),
|
|
185
|
+
)
|
|
186
|
+
self.set_snapshot_status(snapshot_id, SnapshotStatus.READY)
|
|
187
|
+
self._refresh_domain_stats()
|
|
188
|
+
|
|
189
|
+
def _refresh_domain_stats(self) -> None:
|
|
190
|
+
"""``ANALYZE`` escopo-domínio — stats fiéis às rows publicadas."""
|
|
191
|
+
if not self._has_table("domain_relations"):
|
|
192
|
+
return
|
|
193
|
+
# SQL literal por tabela — auditoria AST exige execute() com
|
|
194
|
+
# constante/nome, nunca expressão concatenada.
|
|
195
|
+
self._conn.execute("ANALYZE domain_relations")
|
|
196
|
+
self._conn.execute("ANALYZE domain_entity_occurrences")
|
|
197
|
+
self._conn.execute("ANALYZE domain_entities")
|
|
198
|
+
|
|
199
|
+
def gc_stale_snapshots(self, repository_id: int, keep: int) -> int:
|
|
200
|
+
"""Remove snapshots STALE além dos ``keep`` mais recentes.
|
|
201
|
+
|
|
202
|
+
lf-0031 — política de GC explícita: READY nunca é tocado;
|
|
203
|
+
FAILED também é preservado (trilha de auditoria de falhas).
|
|
204
|
+
Filhos caem por ``ON DELETE CASCADE`` — a integridade referencial
|
|
205
|
+
continua garantida pelo próprio schema. Chamado dentro da
|
|
206
|
+
transação de publicação (atomicidade com o READY novo).
|
|
207
|
+
Retorna quantos snapshots foram removidos.
|
|
208
|
+
"""
|
|
209
|
+
if keep < 0:
|
|
210
|
+
raise ValueError("keep must be >= 0")
|
|
211
|
+
# ``LIMIT -1 OFFSET keep`` = todos os STALE exceto os ``keep``
|
|
212
|
+
# mais novos — SQL constante (auditoria AST).
|
|
213
|
+
# FKs sem cascade precisam ser desfeitas antes do delete:
|
|
214
|
+
# ``index_jobs.snapshot_id`` (o job fica — só perde o vínculo)
|
|
215
|
+
# e ``snapshots.parent_snapshot_id`` (linhagem dos mais novos).
|
|
216
|
+
_doomed = (
|
|
217
|
+
"SELECT id FROM snapshots"
|
|
218
|
+
" WHERE repository_id = ? AND status = 'STALE'"
|
|
219
|
+
" ORDER BY id DESC LIMIT -1 OFFSET ?"
|
|
220
|
+
)
|
|
221
|
+
_sql_jobs = (
|
|
222
|
+
"UPDATE index_jobs SET snapshot_id = NULL"
|
|
223
|
+
" WHERE snapshot_id IN (" + _doomed + ")"
|
|
224
|
+
)
|
|
225
|
+
_sql_parent = (
|
|
226
|
+
"UPDATE snapshots SET parent_snapshot_id = NULL"
|
|
227
|
+
" WHERE parent_snapshot_id IN (" + _doomed + ")"
|
|
228
|
+
)
|
|
229
|
+
_sql_del = "DELETE FROM snapshots WHERE id IN (" + _doomed + ")"
|
|
230
|
+
_sql_doomed_ids = (
|
|
231
|
+
"SELECT id FROM snapshots"
|
|
232
|
+
" WHERE repository_id = ? AND status = 'STALE'"
|
|
233
|
+
" ORDER BY id DESC LIMIT -1 OFFSET ?"
|
|
234
|
+
)
|
|
235
|
+
doomed_ids = [
|
|
236
|
+
int(r["id"]) for r in self._conn.execute(
|
|
237
|
+
_sql_doomed_ids, (repository_id, keep)
|
|
238
|
+
)
|
|
239
|
+
]
|
|
240
|
+
self._conn.execute(_sql_jobs, (repository_id, keep))
|
|
241
|
+
self._conn.execute(_sql_parent, (repository_id, keep))
|
|
242
|
+
if doomed_ids and self._has_table("fts_occurrences"):
|
|
243
|
+
# filhos caem por CASCADE, mas FTS não é FK — delete manual
|
|
244
|
+
self._delete_fts_occurrences(doomed_ids)
|
|
245
|
+
if doomed_ids and self._has_table("domain_fts"):
|
|
246
|
+
# lf-0133: mesmo cuidado — domain_fts é FTS standalone, não
|
|
247
|
+
# participa do cascade de snapshots.
|
|
248
|
+
self._delete_domain_fts(doomed_ids)
|
|
249
|
+
cur = self._conn.execute(_sql_del, (repository_id, keep))
|
|
250
|
+
return int(cur.rowcount if cur.rowcount else 0)
|
|
251
|
+
|
|
252
|
+
def _has_table(self, name: str) -> bool:
|
|
253
|
+
row = self._conn.execute(
|
|
254
|
+
"SELECT 1 FROM sqlite_master WHERE type IN ('table','shadow')"
|
|
255
|
+
" AND name = ?",
|
|
256
|
+
(name,),
|
|
257
|
+
).fetchone()
|
|
258
|
+
return row is not None
|
|
259
|
+
|
|
260
|
+
# --- source files + inventory ------------------------------------
|
|
261
|
+
|
|
262
|
+
def upsert_source_file(self, repository_id: int, f: SourceFile) -> int:
|
|
263
|
+
"""Dedup por (repository_id, relative_path) — chave de conteúdo estável."""
|
|
264
|
+
row = self._conn.execute(
|
|
265
|
+
"SELECT id FROM source_files WHERE repository_id = ? AND relative_path = ?",
|
|
266
|
+
(repository_id, f.path),
|
|
267
|
+
).fetchone()
|
|
268
|
+
if row:
|
|
269
|
+
return int(row["id"])
|
|
270
|
+
cur = self._conn.execute(
|
|
271
|
+
"INSERT INTO source_files (repository_id, relative_path, language, "
|
|
272
|
+
"sha256, size_bytes, ignored) VALUES (?, ?, ?, ?, ?, ?)",
|
|
273
|
+
(repository_id, f.path, f.language, f.sha256, f.size_bytes, int(f.ignored)),
|
|
274
|
+
)
|
|
275
|
+
return int(cur.lastrowid) # type: ignore[arg-type]
|
|
276
|
+
|
|
277
|
+
def ensure_source_files(
|
|
278
|
+
self, repository_id: int, files: Iterable[SourceFile]
|
|
279
|
+
) -> dict[str, int]:
|
|
280
|
+
"""Dedup de ``source_files`` em duas fases — path → id.
|
|
281
|
+
|
|
282
|
+
Mesmo padrão de ``ensure_symbols`` (lf-0023): um SELECT inicial,
|
|
283
|
+
executemany ``INSERT OR IGNORE`` dos ausentes, SELECT final.
|
|
284
|
+
Paridade com o upsert antigo: metadados da linha existente
|
|
285
|
+
NÃO são atualizados (o inventário por snapshot carrega o
|
|
286
|
+
sha256/size frescos em ``file_inventory``).
|
|
287
|
+
"""
|
|
288
|
+
flist = list(files)
|
|
289
|
+
existing = self._conn.execute(
|
|
290
|
+
"SELECT id, relative_path FROM source_files"
|
|
291
|
+
" WHERE repository_id = ?",
|
|
292
|
+
(repository_id,),
|
|
293
|
+
).fetchall()
|
|
294
|
+
ids = {r["relative_path"]: int(r["id"]) for r in existing}
|
|
295
|
+
missing = [f for f in flist if f.path not in ids]
|
|
296
|
+
if missing:
|
|
297
|
+
self._conn.executemany(
|
|
298
|
+
"INSERT OR IGNORE INTO source_files (repository_id,"
|
|
299
|
+
" relative_path, language, sha256, size_bytes, ignored)"
|
|
300
|
+
" VALUES (?, ?, ?, ?, ?, ?)",
|
|
301
|
+
[
|
|
302
|
+
(repository_id, f.path, f.language, f.sha256,
|
|
303
|
+
f.size_bytes, int(f.ignored))
|
|
304
|
+
for f in missing
|
|
305
|
+
],
|
|
306
|
+
)
|
|
307
|
+
refetch = self._conn.execute(
|
|
308
|
+
"SELECT id, relative_path FROM source_files"
|
|
309
|
+
" WHERE repository_id = ?",
|
|
310
|
+
(repository_id,),
|
|
311
|
+
).fetchall()
|
|
312
|
+
ids = {r["relative_path"]: int(r["id"]) for r in refetch}
|
|
313
|
+
return ids
|
|
314
|
+
|
|
315
|
+
def insert_inventory_rows(
|
|
316
|
+
self, snapshot_id: int, rows: Iterable[tuple[int, SourceFile]]
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Insere (snapshot, source_file) com atributos da ocorrência."""
|
|
319
|
+
self._conn.executemany(
|
|
320
|
+
"INSERT INTO file_inventory (snapshot_id, source_file_id, sha256, "
|
|
321
|
+
"size_bytes, ignored) VALUES (?, ?, ?, ?, ?)",
|
|
322
|
+
[(snapshot_id, fid, f.sha256, f.size_bytes, int(f.ignored)) for fid, f in rows],
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def inventory_count(self, snapshot_id: int) -> int:
|
|
326
|
+
row = self._conn.execute(
|
|
327
|
+
"SELECT COUNT(*) AS c FROM file_inventory WHERE snapshot_id = ?",
|
|
328
|
+
(snapshot_id,),
|
|
329
|
+
).fetchone()
|
|
330
|
+
return int(row["c"])
|
|
331
|
+
|
|
332
|
+
def snapshot_inventory(self, snapshot_id: int) -> list[dict[str, object]]:
|
|
333
|
+
"""Inventário do snapshot: path, language, sha256, source_file_id,
|
|
334
|
+
e pior parse_status observado nas ocorrências do arquivo."""
|
|
335
|
+
rows = self._conn.execute(
|
|
336
|
+
"""
|
|
337
|
+
SELECT sf.id AS source_file_id, sf.relative_path, sf.language,
|
|
338
|
+
fi.sha256,
|
|
339
|
+
COALESCE(
|
|
340
|
+
(SELECT MAX(CASE so.parse_status
|
|
341
|
+
WHEN 'parse-failed' THEN 2
|
|
342
|
+
WHEN 'parsed-with-errors' THEN 1
|
|
343
|
+
ELSE 0 END)
|
|
344
|
+
FROM symbol_occurrences so
|
|
345
|
+
WHERE so.snapshot_id = fi.snapshot_id
|
|
346
|
+
AND so.source_file_id = fi.source_file_id),
|
|
347
|
+
-1) AS status_rank
|
|
348
|
+
FROM file_inventory fi
|
|
349
|
+
JOIN source_files sf ON sf.id = fi.source_file_id
|
|
350
|
+
WHERE fi.snapshot_id = ?
|
|
351
|
+
ORDER BY sf.relative_path
|
|
352
|
+
""",
|
|
353
|
+
(snapshot_id,),
|
|
354
|
+
).fetchall()
|
|
355
|
+
rank = {0: "parsed", 1: "parsed-with-errors", 2: "parse-failed"}
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
"path": r["relative_path"],
|
|
359
|
+
"language": r["language"],
|
|
360
|
+
"sha256": r["sha256"],
|
|
361
|
+
"source_file_id": r["source_file_id"],
|
|
362
|
+
"parse_status": rank.get(int(r["status_rank"]), "unparsed"),
|
|
363
|
+
}
|
|
364
|
+
for r in rows
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
def module_import_edges(
|
|
368
|
+
self, snapshot_id: int
|
|
369
|
+
) -> list[tuple[str, str | None, str, str]]:
|
|
370
|
+
"""Arestas IMPORTS do snapshot mapeadas para paths.
|
|
371
|
+
|
|
372
|
+
Retorna ``(importer_path, imported_path|None, to_unresolved,
|
|
373
|
+
resolution_status)`` — insumo do planner de invalidação (lf-0019).
|
|
374
|
+
"""
|
|
375
|
+
rows = self._conn.execute(
|
|
376
|
+
"""
|
|
377
|
+
SELECT sf_from.relative_path AS importer,
|
|
378
|
+
sf_to.relative_path AS imported,
|
|
379
|
+
r.to_unresolved AS to_text,
|
|
380
|
+
r.resolution_status AS status
|
|
381
|
+
FROM relations r
|
|
382
|
+
JOIN symbol_occurrences o_from ON o_from.id = r.from_id
|
|
383
|
+
JOIN source_files sf_from ON sf_from.id = o_from.source_file_id
|
|
384
|
+
LEFT JOIN symbol_occurrences o_to ON o_to.id = r.to_id
|
|
385
|
+
LEFT JOIN source_files sf_to ON sf_to.id = o_to.source_file_id
|
|
386
|
+
WHERE r.snapshot_id = ? AND r.type = 'IMPORTS'
|
|
387
|
+
ORDER BY sf_from.relative_path
|
|
388
|
+
""",
|
|
389
|
+
(snapshot_id,),
|
|
390
|
+
).fetchall()
|
|
391
|
+
return [
|
|
392
|
+
(r["importer"], r["imported"], r["to_text"] or "", r["status"])
|
|
393
|
+
for r in rows
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
def snapshot_file_symbols(
|
|
397
|
+
self, snapshot_id: int
|
|
398
|
+
) -> list[tuple[str, str, str, str, int]]:
|
|
399
|
+
"""Símbolos com ocorrência no snapshot, por arquivo.
|
|
400
|
+
|
|
401
|
+
Retorna ``(path, kind, qualified_name, language, source_file_id)``
|
|
402
|
+
— usado para reconstruir contextos de módulo reutilizados no
|
|
403
|
+
índice incremental (lf-0019).
|
|
404
|
+
"""
|
|
405
|
+
rows = self._conn.execute(
|
|
406
|
+
"""
|
|
407
|
+
SELECT DISTINCT sf.relative_path AS path, s.kind, s.qualified_name,
|
|
408
|
+
s.language, so.source_file_id
|
|
409
|
+
FROM symbol_occurrences so
|
|
410
|
+
JOIN symbols s ON s.id = so.logical_symbol_id
|
|
411
|
+
JOIN source_files sf ON sf.id = so.source_file_id
|
|
412
|
+
WHERE so.snapshot_id = ?
|
|
413
|
+
ORDER BY sf.relative_path, s.qualified_name
|
|
414
|
+
""",
|
|
415
|
+
(snapshot_id,),
|
|
416
|
+
).fetchall()
|
|
417
|
+
return [
|
|
418
|
+
(r["path"], r["kind"], r["qualified_name"], r["language"],
|
|
419
|
+
r["source_file_id"])
|
|
420
|
+
for r in rows
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
def foreign_key_violations(self) -> list[str]:
|
|
424
|
+
"""Lista violações FK pendentes (integrity gate antes do READY)."""
|
|
425
|
+
rows = self._conn.execute("PRAGMA foreign_key_check").fetchall()
|
|
426
|
+
return [str(tuple(r)) for r in rows]
|
|
427
|
+
|
|
428
|
+
def cross_snapshot_relations(self, snapshot_id: int) -> list[int]:
|
|
429
|
+
"""Relações cujos endpoints apontam fora do próprio snapshot.
|
|
430
|
+
|
|
431
|
+
``from_id`` é NOT NULL (JOIN); ``to_id`` pode ser NULL em alvos
|
|
432
|
+
unresolved/external — LEFT JOIN + checagem só quando presente.
|
|
433
|
+
"""
|
|
434
|
+
rows = self._conn.execute(
|
|
435
|
+
"""
|
|
436
|
+
SELECT r.id FROM relations r
|
|
437
|
+
JOIN symbol_occurrences o1 ON o1.id = r.from_id
|
|
438
|
+
LEFT JOIN symbol_occurrences o2 ON o2.id = r.to_id
|
|
439
|
+
WHERE r.snapshot_id = ?
|
|
440
|
+
AND (o1.snapshot_id != ?
|
|
441
|
+
OR (r.to_id IS NOT NULL AND o2.snapshot_id != ?))
|
|
442
|
+
""",
|
|
443
|
+
(snapshot_id, snapshot_id, snapshot_id),
|
|
444
|
+
).fetchall()
|
|
445
|
+
return [int(r["id"]) for r in rows]
|
|
446
|
+
|
|
447
|
+
# --- symbols / occurrences / relations / evidence (Cycle 2) -------
|
|
448
|
+
# --- persistência em lote (lf-0023, medido: ~145k execute/5k arquivos) --
|
|
449
|
+
|
|
450
|
+
_BATCH = 400 # linhas por INSERT multi-row (abaixo do limite de vars)
|
|
451
|
+
|
|
452
|
+
def ensure_symbols(
|
|
453
|
+
self, repository_id: int, keys: list[tuple[str, str, str]]
|
|
454
|
+
) -> tuple[dict[tuple[str, str, str], int], set[tuple[str, str, str]]]:
|
|
455
|
+
"""Dedup de símbolos em duas fases — 145k+ round-trips → ~90.
|
|
456
|
+
|
|
457
|
+
Fase 1: SELECT dos existentes numa query. Fase 2: executemany
|
|
458
|
+
``INSERT OR IGNORE`` só dos ausentes, seguido de SELECT final
|
|
459
|
+
dos ids (``RETURNING`` não devolve ids de linhas ignoradas).
|
|
460
|
+
``keys``: ``(language, qualified_name, kind)`` dedupados pelo
|
|
461
|
+
caller.
|
|
462
|
+
|
|
463
|
+
Retorna ``(id_map, created)`` — ``created`` é o conjunto exato
|
|
464
|
+
de chaves que passaram a existir nesta chamada (correto mesmo
|
|
465
|
+
sob escritor concorrente: derivado do diff dos SELECTs).
|
|
466
|
+
"""
|
|
467
|
+
uniq = sorted(set(keys))
|
|
468
|
+
existing = self._conn.execute(
|
|
469
|
+
"SELECT id, language, qualified_name, kind FROM symbols"
|
|
470
|
+
" WHERE repository_id = ?",
|
|
471
|
+
(repository_id,),
|
|
472
|
+
).fetchall()
|
|
473
|
+
ids = {
|
|
474
|
+
(r["language"], r["qualified_name"], r["kind"]): int(r["id"])
|
|
475
|
+
for r in existing
|
|
476
|
+
}
|
|
477
|
+
pre = set(ids)
|
|
478
|
+
missing = [k for k in uniq if k not in ids]
|
|
479
|
+
if missing:
|
|
480
|
+
self._conn.executemany(
|
|
481
|
+
"INSERT OR IGNORE INTO symbols "
|
|
482
|
+
"(repository_id, language, qualified_name, kind) "
|
|
483
|
+
"VALUES (?, ?, ?, ?)",
|
|
484
|
+
[(repository_id, *k) for k in missing],
|
|
485
|
+
)
|
|
486
|
+
refetch = self._conn.execute(
|
|
487
|
+
"SELECT id, language, qualified_name, kind FROM symbols"
|
|
488
|
+
" WHERE repository_id = ?",
|
|
489
|
+
(repository_id,),
|
|
490
|
+
).fetchall()
|
|
491
|
+
ids = {
|
|
492
|
+
(r["language"], r["qualified_name"], r["kind"]): int(r["id"])
|
|
493
|
+
for r in refetch
|
|
494
|
+
}
|
|
495
|
+
return ids, set(ids) - pre
|
|
496
|
+
|
|
497
|
+
def insert_symbols_batch(
|
|
498
|
+
self, rows: Iterable[tuple[int, str, str, str]]
|
|
499
|
+
) -> None:
|
|
500
|
+
"""FTS: inserts em lote — chamado só para símbolos novos."""
|
|
501
|
+
self._conn.executemany(
|
|
502
|
+
"INSERT INTO fts_symbols (rowid, qualified_name, language, kind) "
|
|
503
|
+
"VALUES (?, ?, ?, ?)",
|
|
504
|
+
list(rows),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def insert_fts_occurrences_batch(
|
|
508
|
+
self, rows: Iterable[tuple[object, ...]]
|
|
509
|
+
) -> None:
|
|
510
|
+
"""lf-0036 — FTS por-ocorrência (snapshot-scoped): rowid = id da
|
|
511
|
+
ocorrência; campos já vêm tokenizados do persist."""
|
|
512
|
+
self._conn.executemany(
|
|
513
|
+
"INSERT INTO fts_occurrences (rowid, snapshot_id, name, terms,"
|
|
514
|
+
" signature, docstring, path, language, kind)"
|
|
515
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
516
|
+
list(rows),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def _delete_fts_occurrences(self, snapshot_ids: Iterable[int]) -> None:
|
|
520
|
+
ids = list(snapshot_ids)
|
|
521
|
+
if not ids:
|
|
522
|
+
return
|
|
523
|
+
# DELETE em FTS regular: placeholder único via json_each (SQL
|
|
524
|
+
# constante — auditoria AST proíbe IN dinâmico).
|
|
525
|
+
self._conn.execute(
|
|
526
|
+
"DELETE FROM fts_occurrences WHERE snapshot_id IN"
|
|
527
|
+
" (SELECT value FROM json_each(?))",
|
|
528
|
+
(json.dumps(ids),),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
def _delete_domain_fts(self, snapshot_ids: Iterable[int]) -> None:
|
|
532
|
+
ids = list(snapshot_ids)
|
|
533
|
+
if not ids:
|
|
534
|
+
return
|
|
535
|
+
self._conn.execute(
|
|
536
|
+
"DELETE FROM domain_fts WHERE snapshot_id IN"
|
|
537
|
+
" (SELECT value FROM json_each(?))",
|
|
538
|
+
(json.dumps(ids),),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def _insert_returning(
|
|
542
|
+
self, sql_prefix: str, rows: list[tuple[object, ...]], ncols: int
|
|
543
|
+
) -> list[int]:
|
|
544
|
+
"""Multi-row ``INSERT ... RETURNING id`` em chunks.
|
|
545
|
+
|
|
546
|
+
SQLite devolve ids na ordem das VALUES — o mapa com as linhas
|
|
547
|
+
de entrada é posicional e estável dentro da transação.
|
|
548
|
+
"""
|
|
549
|
+
out: list[int] = []
|
|
550
|
+
ph = ",".join("?" for _ in range(ncols))
|
|
551
|
+
for i in range(0, len(rows), self._BATCH):
|
|
552
|
+
chunk = rows[i : i + self._BATCH]
|
|
553
|
+
stmt = sql_prefix + ",".join(
|
|
554
|
+
"(" + ph + ")" for _ in chunk
|
|
555
|
+
) + " RETURNING id"
|
|
556
|
+
cur = self._conn.execute(
|
|
557
|
+
stmt, [v for row in chunk for v in row]
|
|
558
|
+
)
|
|
559
|
+
out.extend(int(r["id"]) for r in cur.fetchall())
|
|
560
|
+
return out
|
|
561
|
+
|
|
562
|
+
def insert_occurrences_batch(
|
|
563
|
+
self, rows: list[tuple[object, ...]]
|
|
564
|
+
) -> list[int]:
|
|
565
|
+
"""Batch de ocorrências — retorna ids na ordem das linhas."""
|
|
566
|
+
return self._insert_returning(
|
|
567
|
+
"INSERT INTO symbol_occurrences (snapshot_id, logical_symbol_id,"
|
|
568
|
+
" source_file_id, span_start, span_end, line, col, content_hash,"
|
|
569
|
+
" parse_status) VALUES ",
|
|
570
|
+
rows, 9,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
def insert_evidence_batch(
|
|
574
|
+
self, rows: list[tuple[object, ...]]
|
|
575
|
+
) -> list[int]:
|
|
576
|
+
"""Batch de evidências — retorna ids na ordem das linhas."""
|
|
577
|
+
return self._insert_returning(
|
|
578
|
+
"INSERT INTO evidence (snapshot_id, source_file_id, path, span,"
|
|
579
|
+
" content_hash, extractor, extractor_version, notes,"
|
|
580
|
+
" resolver_id, resolver_version, evidence_tier, index_hash,"
|
|
581
|
+
" validation_status) VALUES ",
|
|
582
|
+
rows, 13,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def insert_evidence(
|
|
586
|
+
self,
|
|
587
|
+
snapshot_id: int,
|
|
588
|
+
source_file_id: int,
|
|
589
|
+
path: str,
|
|
590
|
+
span_json: str | None,
|
|
591
|
+
content_hash: str,
|
|
592
|
+
extractor: str,
|
|
593
|
+
extractor_version: str,
|
|
594
|
+
notes: str = "",
|
|
595
|
+
resolver_id: str = "",
|
|
596
|
+
resolver_version: str = "",
|
|
597
|
+
evidence_tier: int = 0,
|
|
598
|
+
index_hash: str = "",
|
|
599
|
+
validation_status: str = "",
|
|
600
|
+
) -> int:
|
|
601
|
+
cur = self._conn.execute(
|
|
602
|
+
"INSERT INTO evidence (snapshot_id, source_file_id, path, span, "
|
|
603
|
+
"content_hash, extractor, extractor_version, notes, "
|
|
604
|
+
"resolver_id, resolver_version, evidence_tier, index_hash, "
|
|
605
|
+
"validation_status) "
|
|
606
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
607
|
+
(
|
|
608
|
+
snapshot_id, source_file_id, path, span_json, content_hash,
|
|
609
|
+
extractor, extractor_version, notes, resolver_id,
|
|
610
|
+
resolver_version, evidence_tier, index_hash,
|
|
611
|
+
validation_status,
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
return int(cur.lastrowid) # type: ignore[arg-type]
|
|
615
|
+
|
|
616
|
+
def insert_relations(self, rows: Iterable[tuple[object, ...]]) -> int:
|
|
617
|
+
"""Batch de relações — executemany (nenhum id precisa voltar)."""
|
|
618
|
+
self._conn.executemany(
|
|
619
|
+
"INSERT INTO relations (snapshot_id, from_id, to_id, to_unresolved, "
|
|
620
|
+
"type, resolution_status, confidence_class, resolver, evidence_ids) "
|
|
621
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
622
|
+
list(rows),
|
|
623
|
+
)
|
|
624
|
+
return self._conn.total_changes # para telemetria externa
|
|
625
|
+
|
|
626
|
+
# --- domain entities / relations (lf-0110, schema v9) --------------
|
|
627
|
+
|
|
628
|
+
def ensure_domain_entities(
|
|
629
|
+
self, repository_id: int, entities: Iterable[DomainEntity]
|
|
630
|
+
) -> dict[str, int]:
|
|
631
|
+
"""Dedup de entidades de domínio — identity_key → row id.
|
|
632
|
+
|
|
633
|
+
Mesma técnica de ``ensure_symbols``: SELECT inicial, ``INSERT OR
|
|
634
|
+
IGNORE`` em lote dos ausentes, SELECT final. ``identity_key`` é
|
|
635
|
+
a chave lógica canônica (``DomainIdentity.logical_key``) —
|
|
636
|
+
catálogo/namespace/ambiente fazem parte da chave, então
|
|
637
|
+
homônimos em escopos distintos não colidem.
|
|
638
|
+
"""
|
|
639
|
+
items: dict[str, DomainEntity] = {}
|
|
640
|
+
for e in entities:
|
|
641
|
+
items[e.identity_key()] = e
|
|
642
|
+
if not items:
|
|
643
|
+
return {}
|
|
644
|
+
existing = self._conn.execute(
|
|
645
|
+
"SELECT id, identity_key FROM domain_entities"
|
|
646
|
+
" WHERE repository_id = ?",
|
|
647
|
+
(repository_id,),
|
|
648
|
+
).fetchall()
|
|
649
|
+
ids = {r["identity_key"]: int(r["id"]) for r in existing}
|
|
650
|
+
missing = [k for k in sorted(items) if k not in ids]
|
|
651
|
+
if missing:
|
|
652
|
+
self._conn.executemany(
|
|
653
|
+
"INSERT OR IGNORE INTO domain_entities "
|
|
654
|
+
"(repository_id, domain, kind, identity_key, qualified_name,"
|
|
655
|
+
" name, catalog, namespace, environment, attributes)"
|
|
656
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
657
|
+
[
|
|
658
|
+
(
|
|
659
|
+
repository_id,
|
|
660
|
+
items[k].identity.domain,
|
|
661
|
+
str(items[k].kind),
|
|
662
|
+
k,
|
|
663
|
+
items[k].identity.qualified_name,
|
|
664
|
+
items[k].name,
|
|
665
|
+
items[k].identity.catalog,
|
|
666
|
+
items[k].identity.namespace,
|
|
667
|
+
items[k].identity.environment,
|
|
668
|
+
json.dumps(
|
|
669
|
+
dict(items[k].attributes), sort_keys=True
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
for k in missing
|
|
673
|
+
],
|
|
674
|
+
)
|
|
675
|
+
refetch = self._conn.execute(
|
|
676
|
+
"SELECT id, identity_key FROM domain_entities"
|
|
677
|
+
" WHERE repository_id = ?",
|
|
678
|
+
(repository_id,),
|
|
679
|
+
).fetchall()
|
|
680
|
+
ids = {r["identity_key"]: int(r["id"]) for r in refetch}
|
|
681
|
+
return ids
|
|
682
|
+
|
|
683
|
+
def insert_domain_occurrences_batch(
|
|
684
|
+
self, rows: list[tuple[object, ...]]
|
|
685
|
+
) -> list[int]:
|
|
686
|
+
"""Batch de ocorrências de domínio — retorna ids na ordem.
|
|
687
|
+
|
|
688
|
+
``rows``: (snapshot_id, entity_id, source_file_id, span_start,
|
|
689
|
+
span_end, line, col, content_hash, attributes_json,
|
|
690
|
+
extraction_status).
|
|
691
|
+
"""
|
|
692
|
+
return self._insert_returning(
|
|
693
|
+
"INSERT INTO domain_entity_occurrences (snapshot_id, entity_id,"
|
|
694
|
+
" source_file_id, span_start, span_end, line, col, content_hash,"
|
|
695
|
+
" attributes, extraction_status) VALUES ",
|
|
696
|
+
rows, 10,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def insert_domain_relations(self, rows: Iterable[tuple[object, ...]]) -> int:
|
|
700
|
+
"""Batch de relações de domínio — executemany.
|
|
701
|
+
|
|
702
|
+
``rows``: (snapshot_id, from_entity_occ_id, from_symbol_occ_id,
|
|
703
|
+
to_entity_occ_id, to_symbol_occ_id, to_unresolved, type,
|
|
704
|
+
resolution_status, confidence_class, resolver, evidence_ids).
|
|
705
|
+
"""
|
|
706
|
+
self._conn.executemany(
|
|
707
|
+
"INSERT INTO domain_relations (snapshot_id, from_entity_occ_id,"
|
|
708
|
+
" from_symbol_occ_id, to_entity_occ_id, to_symbol_occ_id,"
|
|
709
|
+
" to_unresolved, type, resolution_status, confidence_class,"
|
|
710
|
+
" resolver, evidence_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
711
|
+
list(rows),
|
|
712
|
+
)
|
|
713
|
+
return self._conn.total_changes
|
|
714
|
+
|
|
715
|
+
# --- domain_fts (lf-0133, schema v12) ---------------------------------
|
|
716
|
+
|
|
717
|
+
def domain_fts_available(self) -> bool:
|
|
718
|
+
"""``domain_fts`` presente **e** build com FTS5 — gate de escrita.
|
|
719
|
+
|
|
720
|
+
Bancos migrados por uma build com FTS5 podem ser abertos por uma
|
|
721
|
+
sem o módulo: o INSERT na vtable falharia. A checagem dupla
|
|
722
|
+
(tabela + probe de capacidade) é a condição honesta de escrita.
|
|
723
|
+
"""
|
|
724
|
+
if not self._has_table("domain_fts"):
|
|
725
|
+
return False
|
|
726
|
+
from code_atlas.storage.db import fts5_available
|
|
727
|
+
|
|
728
|
+
return fts5_available(self._conn)
|
|
729
|
+
|
|
730
|
+
def insert_domain_fts_batch(
|
|
731
|
+
self, rows: Iterable[tuple[object, ...]]
|
|
732
|
+
) -> None:
|
|
733
|
+
"""FTS de domínio em lote — rowid = id de ``domain_entity_occurrences``.
|
|
734
|
+
|
|
735
|
+
``rows``: (occ_id, snapshot_id, entity_id, name, qualified_name,
|
|
736
|
+
terms, path, domain, kind) — campos já normalizados pelo caller
|
|
737
|
+
(``terms`` sai de ``search.tokenizer.index_terms``).
|
|
738
|
+
"""
|
|
739
|
+
self._conn.executemany(
|
|
740
|
+
"INSERT INTO domain_fts (rowid, snapshot_id, entity_id, name,"
|
|
741
|
+
" qualified_name, terms, path, domain, kind)"
|
|
742
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
743
|
+
list(rows),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
def rebuild_domain_fts(self, snapshot_id: int) -> int:
|
|
747
|
+
"""Backfill in-place de ``domain_fts`` para um snapshot existente.
|
|
748
|
+
|
|
749
|
+
lf-0133: snapshots persistidos antes da v12 não têm rows de FTS
|
|
750
|
+
e a faceta cai no LIKE bounded (declarado). Este é o caminho de
|
|
751
|
+
reconstrução — o equivalente programático de um ``code-atlas
|
|
752
|
+
reindex``: lê o join ocorrência↔entidade↔arquivo e regrava o
|
|
753
|
+
índice do snapshot com os mesmos campos do persist (``terms``
|
|
754
|
+
pré-tokenizado em Python — não é expressível em SQL puro).
|
|
755
|
+
Idempotente dentro da transação do caller (delete + insert do
|
|
756
|
+
mesmo escopo). Retorna o número de rows regravadas; 0 quando a
|
|
757
|
+
tabela não existe ou o snapshot não tem ocorrências de domínio.
|
|
758
|
+
"""
|
|
759
|
+
if not self.domain_fts_available():
|
|
760
|
+
return 0
|
|
761
|
+
rows = self._conn.execute(
|
|
762
|
+
"SELECT deo.id AS occ_id, deo.snapshot_id, deo.entity_id,"
|
|
763
|
+
" de.name, de.qualified_name, de.domain, de.kind,"
|
|
764
|
+
" sf.relative_path AS path"
|
|
765
|
+
" FROM domain_entity_occurrences deo"
|
|
766
|
+
" JOIN domain_entities de ON de.id = deo.entity_id"
|
|
767
|
+
" LEFT JOIN source_files sf ON sf.id = deo.source_file_id"
|
|
768
|
+
" WHERE deo.snapshot_id = ? ORDER BY deo.id",
|
|
769
|
+
(snapshot_id,),
|
|
770
|
+
).fetchall()
|
|
771
|
+
self._conn.execute(
|
|
772
|
+
"DELETE FROM domain_fts WHERE snapshot_id = ?",
|
|
773
|
+
(snapshot_id,),
|
|
774
|
+
)
|
|
775
|
+
if not rows:
|
|
776
|
+
return 0
|
|
777
|
+
# import tardio: storage não pode puxar code_atlas.search em
|
|
778
|
+
# nível de módulo (search/__init__ → engine → domain → store).
|
|
779
|
+
from code_atlas.search.tokenizer import index_terms
|
|
780
|
+
|
|
781
|
+
fts_rows = [
|
|
782
|
+
(
|
|
783
|
+
int(r["occ_id"]), int(r["snapshot_id"]), int(r["entity_id"]),
|
|
784
|
+
str(r["name"] or ""), str(r["qualified_name"]),
|
|
785
|
+
index_terms(
|
|
786
|
+
str(r["name"] or ""), str(r["qualified_name"]),
|
|
787
|
+
str(r["path"] or ""),
|
|
788
|
+
),
|
|
789
|
+
str(r["path"] or ""), str(r["domain"]), str(r["kind"]),
|
|
790
|
+
)
|
|
791
|
+
for r in rows
|
|
792
|
+
]
|
|
793
|
+
self.insert_domain_fts_batch(fts_rows)
|
|
794
|
+
return len(fts_rows)
|
|
795
|
+
|
|
796
|
+
def snapshot_domain_entities(
|
|
797
|
+
self, snapshot_id: int
|
|
798
|
+
) -> list[tuple[str, str, str, str, str | None]]:
|
|
799
|
+
"""Entidades com ocorrência no snapshot.
|
|
800
|
+
|
|
801
|
+
Retorna ``(domain, kind, qualified_name, identity_key, path)`` —
|
|
802
|
+
insumo de inspeção/testes e da resolução cross-domain (lf-0117).
|
|
803
|
+
"""
|
|
804
|
+
rows = self._conn.execute(
|
|
805
|
+
"""
|
|
806
|
+
SELECT de.domain, de.kind, de.qualified_name, de.identity_key,
|
|
807
|
+
sf.relative_path AS path
|
|
808
|
+
FROM domain_entity_occurrences deo
|
|
809
|
+
JOIN domain_entities de ON de.id = deo.entity_id
|
|
810
|
+
LEFT JOIN source_files sf ON sf.id = deo.source_file_id
|
|
811
|
+
WHERE deo.snapshot_id = ?
|
|
812
|
+
ORDER BY de.domain, de.kind, de.qualified_name
|
|
813
|
+
""",
|
|
814
|
+
(snapshot_id,),
|
|
815
|
+
).fetchall()
|
|
816
|
+
return [
|
|
817
|
+
(r["domain"], r["kind"], r["qualified_name"], r["identity_key"],
|
|
818
|
+
r["path"])
|
|
819
|
+
for r in rows
|
|
820
|
+
]
|
|
821
|
+
|
|
822
|
+
def domain_entity_id_for(
|
|
823
|
+
self, repository_id: int, identity_key: str
|
|
824
|
+
) -> int | None:
|
|
825
|
+
"""Row id de ``domain_entities`` por chave lógica canônica."""
|
|
826
|
+
row = self._conn.execute(
|
|
827
|
+
"SELECT id FROM domain_entities"
|
|
828
|
+
" WHERE repository_id = ? AND identity_key = ?",
|
|
829
|
+
(repository_id, identity_key),
|
|
830
|
+
).fetchone()
|
|
831
|
+
return int(row["id"]) if row else None
|
|
832
|
+
|
|
833
|
+
def domain_occurrences_for(
|
|
834
|
+
self, snapshot_id: int, entity_id: int
|
|
835
|
+
) -> list[int]:
|
|
836
|
+
"""Ids de ocorrências de uma entidade num snapshot."""
|
|
837
|
+
rows = self._conn.execute(
|
|
838
|
+
"SELECT id FROM domain_entity_occurrences"
|
|
839
|
+
" WHERE snapshot_id = ? AND entity_id = ? ORDER BY id",
|
|
840
|
+
(snapshot_id, entity_id),
|
|
841
|
+
).fetchall()
|
|
842
|
+
return [int(r["id"]) for r in rows]
|
|
843
|
+
|
|
844
|
+
def symbol_occurrences_for_name(
|
|
845
|
+
self, snapshot_id: int, qualified_name: str
|
|
846
|
+
) -> list[int]:
|
|
847
|
+
"""Ocorrências de código num snapshot por qualified_name.
|
|
848
|
+
|
|
849
|
+
Ponte código↔domínio (lf-0117/0120): endpoints ``*_symbol_occ_id``
|
|
850
|
+
de ``domain_relations`` referenciam estas rows. Múltiplos matches
|
|
851
|
+
(mesmo qname, kinds distintos) são retornados todos — o caller
|
|
852
|
+
decide entre resolved único / ambiguous honesto.
|
|
853
|
+
"""
|
|
854
|
+
rows = self._conn.execute(
|
|
855
|
+
"""
|
|
856
|
+
SELECT so.id FROM symbol_occurrences so
|
|
857
|
+
JOIN symbols s ON s.id = so.logical_symbol_id
|
|
858
|
+
WHERE so.snapshot_id = ? AND s.qualified_name = ?
|
|
859
|
+
ORDER BY so.id
|
|
860
|
+
""",
|
|
861
|
+
(snapshot_id, qualified_name),
|
|
862
|
+
).fetchall()
|
|
863
|
+
return [int(r["id"]) for r in rows]
|
|
864
|
+
|
|
865
|
+
def cross_snapshot_domain_relations(self, snapshot_id: int) -> list[int]:
|
|
866
|
+
"""Relações de domínio com endpoint fora do próprio snapshot.
|
|
867
|
+
|
|
868
|
+
Espelha ``cross_snapshot_relations``: endpoints nullable recebem
|
|
869
|
+
LEFT JOIN e só contam quando presentes.
|
|
870
|
+
"""
|
|
871
|
+
rows = self._conn.execute(
|
|
872
|
+
"""
|
|
873
|
+
SELECT r.id FROM domain_relations r
|
|
874
|
+
LEFT JOIN domain_entity_occurrences fe
|
|
875
|
+
ON fe.id = r.from_entity_occ_id
|
|
876
|
+
LEFT JOIN symbol_occurrences fs
|
|
877
|
+
ON fs.id = r.from_symbol_occ_id
|
|
878
|
+
LEFT JOIN domain_entity_occurrences te
|
|
879
|
+
ON te.id = r.to_entity_occ_id
|
|
880
|
+
LEFT JOIN symbol_occurrences ts
|
|
881
|
+
ON ts.id = r.to_symbol_occ_id
|
|
882
|
+
WHERE r.snapshot_id = ?
|
|
883
|
+
AND ((r.from_entity_occ_id IS NOT NULL AND fe.snapshot_id != ?)
|
|
884
|
+
OR (r.from_symbol_occ_id IS NOT NULL AND fs.snapshot_id != ?)
|
|
885
|
+
OR (r.to_entity_occ_id IS NOT NULL AND te.snapshot_id != ?)
|
|
886
|
+
OR (r.to_symbol_occ_id IS NOT NULL AND ts.snapshot_id != ?))
|
|
887
|
+
""",
|
|
888
|
+
(snapshot_id,) * 5,
|
|
889
|
+
).fetchall()
|
|
890
|
+
return [int(r["id"]) for r in rows]
|
|
891
|
+
|
|
892
|
+
# --- resolução cross-domain (lf-0117, Phase 9) ---------------------
|
|
893
|
+
|
|
894
|
+
def domain_occurrences_snapshot(
|
|
895
|
+
self, snapshot_id: int
|
|
896
|
+
) -> list[sqlite3.Row]:
|
|
897
|
+
"""Ocorrências de domínio do snapshot com a entidade lógica.
|
|
898
|
+
|
|
899
|
+
Junta ``domain_entity_occurrences`` + ``domain_entities`` +
|
|
900
|
+
path do arquivo-fonte — a visão completa que a resolução
|
|
901
|
+
cross-domain precisa (identidade canônica vem da
|
|
902
|
+
``identity_key``; ``root`` só existe lá dentro).
|
|
903
|
+
"""
|
|
904
|
+
return self._conn.execute(
|
|
905
|
+
"""
|
|
906
|
+
SELECT deo.id AS occ_id, deo.entity_id, deo.source_file_id,
|
|
907
|
+
deo.span_start, deo.span_end, deo.extraction_status,
|
|
908
|
+
de.domain, de.kind, de.identity_key, de.qualified_name,
|
|
909
|
+
de.name, de.catalog, de.namespace, de.environment,
|
|
910
|
+
de.attributes,
|
|
911
|
+
sf.relative_path AS source_path
|
|
912
|
+
FROM domain_entity_occurrences deo
|
|
913
|
+
JOIN domain_entities de ON de.id = deo.entity_id
|
|
914
|
+
LEFT JOIN source_files sf ON sf.id = deo.source_file_id
|
|
915
|
+
WHERE deo.snapshot_id = ?
|
|
916
|
+
ORDER BY deo.id
|
|
917
|
+
""",
|
|
918
|
+
(snapshot_id,),
|
|
919
|
+
).fetchall()
|
|
920
|
+
|
|
921
|
+
def domain_relations_snapshot(
|
|
922
|
+
self, snapshot_id: int
|
|
923
|
+
) -> list[sqlite3.Row]:
|
|
924
|
+
"""Todas as rows de ``domain_relations`` do snapshot (ordem de id).
|
|
925
|
+
|
|
926
|
+
O resolver lê o conjunto inteiro — re-resolve só as
|
|
927
|
+
``unresolved``, mas precisa das demais para dedup de arestas
|
|
928
|
+
novas e para idempotência (``resolver='cross-domain'``).
|
|
929
|
+
"""
|
|
930
|
+
return self._conn.execute(
|
|
931
|
+
"""
|
|
932
|
+
SELECT id, type, from_entity_occ_id, from_symbol_occ_id,
|
|
933
|
+
to_entity_occ_id, to_symbol_occ_id, to_unresolved,
|
|
934
|
+
resolution_status, confidence_class, resolver,
|
|
935
|
+
evidence_ids
|
|
936
|
+
FROM domain_relations
|
|
937
|
+
WHERE snapshot_id = ?
|
|
938
|
+
ORDER BY id
|
|
939
|
+
""",
|
|
940
|
+
(snapshot_id,),
|
|
941
|
+
).fetchall()
|
|
942
|
+
|
|
943
|
+
def evidence_snapshot(self, snapshot_id: int) -> list[sqlite3.Row]:
|
|
944
|
+
"""Rows de ``evidence`` do snapshot — insumo de proveniência."""
|
|
945
|
+
return self._conn.execute(
|
|
946
|
+
"""
|
|
947
|
+
SELECT id, source_file_id, path, span, extractor, notes
|
|
948
|
+
FROM evidence
|
|
949
|
+
WHERE snapshot_id = ?
|
|
950
|
+
ORDER BY id
|
|
951
|
+
""",
|
|
952
|
+
(snapshot_id,),
|
|
953
|
+
).fetchall()
|
|
954
|
+
|
|
955
|
+
def file_symbol_occurrences(
|
|
956
|
+
self, snapshot_id: int
|
|
957
|
+
) -> list[tuple[str, int]]:
|
|
958
|
+
"""``(path, occ_id)`` de símbolos FILE com ocorrência no snapshot.
|
|
959
|
+
|
|
960
|
+
Ponte domínio→código (lf-0117): ``qualified_name`` de nó FILE é
|
|
961
|
+
o path relativo — alvo honesto para refs textuais que são paths
|
|
962
|
+
inventariados (artefato de CI, script de job).
|
|
963
|
+
"""
|
|
964
|
+
rows = self._conn.execute(
|
|
965
|
+
"""
|
|
966
|
+
SELECT s.qualified_name AS path, so.id AS occ_id
|
|
967
|
+
FROM symbol_occurrences so
|
|
968
|
+
JOIN symbols s ON s.id = so.logical_symbol_id
|
|
969
|
+
WHERE so.snapshot_id = ? AND s.kind = 'file'
|
|
970
|
+
ORDER BY s.qualified_name, so.id
|
|
971
|
+
""",
|
|
972
|
+
(snapshot_id,),
|
|
973
|
+
).fetchall()
|
|
974
|
+
return [(str(r["path"]), int(r["occ_id"])) for r in rows]
|
|
975
|
+
|
|
976
|
+
def persist_cross_resolutions(
|
|
977
|
+
self,
|
|
978
|
+
snapshot_id: int,
|
|
979
|
+
updates: Iterable[tuple[object, ...]],
|
|
980
|
+
rows: Iterable[tuple[object, ...]],
|
|
981
|
+
) -> dict[str, int]:
|
|
982
|
+
"""Aplica o plano do resolver cross-domain — transação do caller.
|
|
983
|
+
|
|
984
|
+
``updates``: (to_entity_occ, to_symbol_occ, to_unresolved,
|
|
985
|
+
resolution_status, confidence_class, resolver, evidence_ids_json,
|
|
986
|
+
relation_id) — re-resolução in place de rows ``unresolved``;
|
|
987
|
+
``resolver`` marca a proveniência da decisão (``cross-domain``).
|
|
988
|
+
O ``WHERE`` exige ``snapshot_id`` — isolamento por construção,
|
|
989
|
+
nenhuma row de outro snapshot pode ser tocada.
|
|
990
|
+
|
|
991
|
+
``rows``: mesmo shape de ``insert_domain_relations`` — arestas
|
|
992
|
+
novas entre ocorrências do snapshot. Retorna contagens.
|
|
993
|
+
"""
|
|
994
|
+
upd = list(updates)
|
|
995
|
+
ins = list(rows)
|
|
996
|
+
self._conn.executemany(
|
|
997
|
+
"UPDATE domain_relations"
|
|
998
|
+
" SET to_entity_occ_id = ?, to_symbol_occ_id = ?,"
|
|
999
|
+
" to_unresolved = ?, resolution_status = ?,"
|
|
1000
|
+
" confidence_class = ?, resolver = ?, evidence_ids = ?"
|
|
1001
|
+
" WHERE id = ? AND snapshot_id = ?",
|
|
1002
|
+
[(*u, snapshot_id) for u in upd],
|
|
1003
|
+
)
|
|
1004
|
+
self._conn.executemany(
|
|
1005
|
+
"INSERT INTO domain_relations (snapshot_id, from_entity_occ_id,"
|
|
1006
|
+
" from_symbol_occ_id, to_entity_occ_id, to_symbol_occ_id,"
|
|
1007
|
+
" to_unresolved, type, resolution_status, confidence_class,"
|
|
1008
|
+
" resolver, evidence_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1009
|
+
ins,
|
|
1010
|
+
)
|
|
1011
|
+
return {"cross_updated": len(upd), "cross_inserted": len(ins)}
|
|
1012
|
+
|
|
1013
|
+
# --- index jobs ---------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
def insert_index_job(self, repository_id: int, manifest_hash: str) -> int:
|
|
1016
|
+
cur = self._conn.execute(
|
|
1017
|
+
"INSERT INTO index_jobs (repository_id, status, manifest_hash) "
|
|
1018
|
+
"VALUES (?, 'running', ?)",
|
|
1019
|
+
(repository_id, manifest_hash),
|
|
1020
|
+
)
|
|
1021
|
+
return int(cur.lastrowid) # type: ignore[arg-type]
|
|
1022
|
+
|
|
1023
|
+
def finish_index_job(
|
|
1024
|
+
self,
|
|
1025
|
+
job_id: int,
|
|
1026
|
+
status: str,
|
|
1027
|
+
snapshot_id: int | None = None,
|
|
1028
|
+
counts: dict[str, int] | None = None,
|
|
1029
|
+
failures: tuple[str, ...] = (),
|
|
1030
|
+
) -> None:
|
|
1031
|
+
self._conn.execute(
|
|
1032
|
+
"UPDATE index_jobs SET status = ?, snapshot_id = ?, counts = ?, "
|
|
1033
|
+
"failures = ?, finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
|
|
1034
|
+
"WHERE id = ?",
|
|
1035
|
+
(
|
|
1036
|
+
status,
|
|
1037
|
+
snapshot_id,
|
|
1038
|
+
json.dumps(counts or {}, sort_keys=True),
|
|
1039
|
+
json.dumps(list(failures)),
|
|
1040
|
+
job_id,
|
|
1041
|
+
),
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
def last_warnings_digest(self, warnings: Iterable[WarningKind]) -> str:
|
|
1045
|
+
return json.dumps(sorted({str(w) for w in warnings}))
|