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,1514 @@
|
|
|
1
|
+
"""Handlers da API v1 — reutilizam os engines de domínio, nunca SQL solto.
|
|
2
|
+
|
|
3
|
+
Cada handler abre uma conexão read-only por request (ciclo de vida real,
|
|
4
|
+
mesmo padrão do MCP) e retorna ``dict`` serializável. Erros viram
|
|
5
|
+
``ApiError`` tipados — nunca traceback ao cliente.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
21
|
+
|
|
22
|
+
from code_atlas import __version__
|
|
23
|
+
from code_atlas.api.server import ApiError
|
|
24
|
+
from code_atlas.federation import (
|
|
25
|
+
Federation,
|
|
26
|
+
FederationError,
|
|
27
|
+
InvalidKeyError,
|
|
28
|
+
InvalidPinError,
|
|
29
|
+
MemberNotFoundError,
|
|
30
|
+
RepositoryNotFoundError,
|
|
31
|
+
SnapshotNotFoundError,
|
|
32
|
+
SnapshotNotReadyError,
|
|
33
|
+
WorkspaceExistsError,
|
|
34
|
+
WorkspaceNotFoundError,
|
|
35
|
+
WorkspaceSizeError,
|
|
36
|
+
)
|
|
37
|
+
from code_atlas.observability.logging import (
|
|
38
|
+
get_logger,
|
|
39
|
+
log_if_slow,
|
|
40
|
+
slow_ms,
|
|
41
|
+
)
|
|
42
|
+
from code_atlas.storage.store import Store
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from http.server import BaseHTTPRequestHandler
|
|
46
|
+
|
|
47
|
+
from code_atlas.api.server import _AtlasHttpd
|
|
48
|
+
|
|
49
|
+
_MAX_LIMIT = 200
|
|
50
|
+
_QNAME = re.compile(r"^[a-zA-Z0-9_:.<>$/\-]{1,300}$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _qs(handler: BaseHTTPRequestHandler) -> dict[str, str]:
|
|
54
|
+
raw = parse_qs(urlparse(handler.path).query)
|
|
55
|
+
return {k: v[0] for k, v in raw.items() if v}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _int(params: dict[str, str], key: str, default: int = 0,
|
|
59
|
+
cap: int = _MAX_LIMIT) -> int:
|
|
60
|
+
try:
|
|
61
|
+
v = int(params.get(key, "") or default)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise ApiError("bad-param", f"parâmetro {key} não é inteiro") from exc
|
|
64
|
+
return max(0, min(v, cap))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _repo(conn: Any, params: dict[str, str]) -> int:
|
|
68
|
+
"""Resolve repository_id — escopo obrigatório (Cycle 5.1)."""
|
|
69
|
+
store = Store(conn)
|
|
70
|
+
rid = params.get("repository_id", "")
|
|
71
|
+
if rid:
|
|
72
|
+
try:
|
|
73
|
+
rid_int = int(rid)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise ApiError("bad-param", "repository_id inválido") from exc
|
|
76
|
+
if store.repository_by_id(rid_int) is None:
|
|
77
|
+
raise ApiError("repo-not-found", "repositório inexistente", 404)
|
|
78
|
+
return rid_int
|
|
79
|
+
repos = [r for r in _all_repos(store)]
|
|
80
|
+
if not repos:
|
|
81
|
+
raise ApiError("no-repository", "nenhum repositório indexado", 404)
|
|
82
|
+
return repos[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _all_repos(store: Store) -> list[int]:
|
|
86
|
+
rows = store.conn.execute(
|
|
87
|
+
"SELECT id FROM repositories ORDER BY id").fetchall()
|
|
88
|
+
return [int(r["id"]) for r in rows]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _snapshot(conn: Any, repo_id: int, params: dict[str, str]) -> int:
|
|
92
|
+
store = Store(conn)
|
|
93
|
+
sid = params.get("snapshot_id", "")
|
|
94
|
+
if sid:
|
|
95
|
+
try:
|
|
96
|
+
snap = store.snapshot_by_id(int(sid))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise ApiError("bad-param", "snapshot_id inválido") from exc
|
|
99
|
+
if snap is None or snap.repository_id != repo_id:
|
|
100
|
+
raise ApiError("snapshot-not-found",
|
|
101
|
+
"snapshot inexistente no repositório", 404)
|
|
102
|
+
if snap.status != "READY":
|
|
103
|
+
raise ApiError("snapshot-not-ready",
|
|
104
|
+
f"snapshot {snap.id} está {snap.status}", 409)
|
|
105
|
+
assert snap.id is not None
|
|
106
|
+
return snap.id
|
|
107
|
+
snap = store.latest_ready_snapshot(repo_id)
|
|
108
|
+
if snap is None:
|
|
109
|
+
raise ApiError("snapshot-not-ready", "nenhum snapshot READY", 409)
|
|
110
|
+
assert snap.id is not None
|
|
111
|
+
return snap.id
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_NODE_SELECT = (
|
|
115
|
+
"SELECT o.id AS occurrence_id, o.logical_symbol_id AS symbol_id, "
|
|
116
|
+
"s.qualified_name AS qname, s.kind AS kind, s.language AS language, "
|
|
117
|
+
"f.relative_path AS path, o.span_start, o.span_end, o.parse_status "
|
|
118
|
+
"FROM symbol_occurrences o "
|
|
119
|
+
"JOIN symbols s ON s.id = o.logical_symbol_id "
|
|
120
|
+
"JOIN source_files f ON f.id = o.source_file_id ")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _row_to_node(row: Any) -> dict[str, Any]:
|
|
124
|
+
return {
|
|
125
|
+
"id": f"occ:{row['occurrence_id']}",
|
|
126
|
+
"occurrence_id": row["occurrence_id"],
|
|
127
|
+
"symbol_id": row["symbol_id"],
|
|
128
|
+
"qname": row["qname"],
|
|
129
|
+
"kind": row["kind"],
|
|
130
|
+
"language": row["language"],
|
|
131
|
+
"path": row["path"],
|
|
132
|
+
"span": {"start": row["span_start"], "end": row["span_end"]},
|
|
133
|
+
"parse_status": row["parse_status"],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_NODES_IN_QUERY = (
|
|
138
|
+
_NODE_SELECT +
|
|
139
|
+
"WHERE o.snapshot_id = ? AND o.id IN (SELECT value FROM json_each(?))")
|
|
140
|
+
_NODE_BY_ID_QUERY = _NODE_SELECT + "WHERE o.snapshot_id = ? AND o.id = ?"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_COUNT_QUERIES = {
|
|
144
|
+
"file_inventory":
|
|
145
|
+
"SELECT COUNT(*) AS n FROM file_inventory WHERE snapshot_id = ?",
|
|
146
|
+
"symbol_occurrences":
|
|
147
|
+
"SELECT COUNT(*) AS n FROM symbol_occurrences WHERE snapshot_id = ?",
|
|
148
|
+
"relations":
|
|
149
|
+
"SELECT COUNT(*) AS n FROM relations WHERE snapshot_id = ?",
|
|
150
|
+
"evidence":
|
|
151
|
+
"SELECT COUNT(*) AS n FROM evidence WHERE snapshot_id = ?",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- handlers -----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _health(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
159
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
160
|
+
return {"status": "ok", "service": "code-atlas-api", "version": 1}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _capabilities(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
164
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
165
|
+
from code_atlas.search.engine import SearchEngine
|
|
166
|
+
caps = dataclasses.asdict(SearchEngine(conn).capabilities())
|
|
167
|
+
return {"search": caps, "api_version": 1, "readonly": True}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _repositories(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
171
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
172
|
+
rows = conn.execute(
|
|
173
|
+
"SELECT id, identity_key, root_handle FROM repositories ORDER BY id"
|
|
174
|
+
).fetchall()
|
|
175
|
+
items = [{"id": r["id"], "identity_key": r["identity_key"],
|
|
176
|
+
"root_handle": r["root_handle"]} for r in rows]
|
|
177
|
+
return {"items": items, "total": len(items)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _snapshots(server: _AtlasHttpd, conn: Any, repo_id: str,
|
|
181
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
182
|
+
try:
|
|
183
|
+
rid = int(repo_id)
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise ApiError("bad-param", "repository_id inválido") from exc
|
|
186
|
+
rows = conn.execute(
|
|
187
|
+
"SELECT id, status, commit_sha, git_ref, created_at FROM snapshots "
|
|
188
|
+
"WHERE repository_id = ? ORDER BY id DESC LIMIT 100", (rid,)).fetchall()
|
|
189
|
+
return {"items": [dict(r) for r in rows]}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _graph_overview(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
193
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
194
|
+
"""Visão macro: nós agregados por arquivo — nunca o grafo inteiro."""
|
|
195
|
+
rid = _repo(conn, p)
|
|
196
|
+
sid = _snapshot(conn, rid, p)
|
|
197
|
+
limit = _int(p, "limit", 500, cap=2000)
|
|
198
|
+
rows = conn.execute(
|
|
199
|
+
"SELECT f.id AS file_id, f.relative_path AS path, "
|
|
200
|
+
"COUNT(*) AS symbols, MIN(s.language) AS language "
|
|
201
|
+
"FROM symbol_occurrences o "
|
|
202
|
+
"JOIN symbols s ON s.id = o.logical_symbol_id "
|
|
203
|
+
"JOIN source_files f ON f.id = o.source_file_id "
|
|
204
|
+
"WHERE o.snapshot_id = ? "
|
|
205
|
+
"GROUP BY f.id ORDER BY symbols DESC LIMIT ?",
|
|
206
|
+
(sid, limit)).fetchall()
|
|
207
|
+
total = conn.execute(
|
|
208
|
+
"SELECT COUNT(DISTINCT source_file_id) AS n FROM symbol_occurrences "
|
|
209
|
+
"WHERE snapshot_id = ?", (sid,)).fetchone()["n"]
|
|
210
|
+
return {"snapshot_id": sid, "repository_id": rid,
|
|
211
|
+
"items": [dict(r) for r in rows], "total_files": total,
|
|
212
|
+
"truncated": len(rows) < total}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _row_to_edge(row: Any, snapshot_id: int) -> dict[str, Any]:
|
|
216
|
+
"""DTO completo de aresta — prova da relação, nunca só os endpoints."""
|
|
217
|
+
return {
|
|
218
|
+
"id": row["id"],
|
|
219
|
+
"relation_id": row["id"],
|
|
220
|
+
"snapshot_id": snapshot_id,
|
|
221
|
+
"from_id": row["from_id"],
|
|
222
|
+
"to_id": row["to_id"],
|
|
223
|
+
"to_unresolved": row["to_unresolved"],
|
|
224
|
+
"type": row["type"],
|
|
225
|
+
"resolution_status": row["resolution_status"],
|
|
226
|
+
"confidence_class": row["confidence_class"],
|
|
227
|
+
"resolver": row["resolver"],
|
|
228
|
+
"evidence_ids": json.loads(row["evidence_ids"] or "[]"),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _graph_neighbors(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
233
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
234
|
+
rid = _repo(conn, p)
|
|
235
|
+
sid = _snapshot(conn, rid, p)
|
|
236
|
+
direction = p.get("direction", "out")
|
|
237
|
+
if direction not in ("in", "out", "both"):
|
|
238
|
+
raise ApiError("bad-param", "direction deve ser in|out|both")
|
|
239
|
+
limit = _int(p, "limit", 50)
|
|
240
|
+
occ_id = _int(p, "occurrence_id", 0, cap=10**9)
|
|
241
|
+
if not occ_id:
|
|
242
|
+
raise ApiError("bad-param", "occurrence_id obrigatório")
|
|
243
|
+
edges = conn.execute(
|
|
244
|
+
"SELECT id, from_id, to_id, to_unresolved, type, resolution_status, "
|
|
245
|
+
"confidence_class, resolver, evidence_ids "
|
|
246
|
+
"FROM relations WHERE snapshot_id = ? AND ("
|
|
247
|
+
" (? = 'out' AND from_id = ?) "
|
|
248
|
+
" OR (? = 'in' AND to_id = ?) "
|
|
249
|
+
" OR (? = 'both' AND (from_id = ? OR to_id = ?))"
|
|
250
|
+
") ORDER BY id LIMIT ?",
|
|
251
|
+
(sid, direction, occ_id, direction, occ_id,
|
|
252
|
+
direction, occ_id, occ_id, limit)).fetchall()
|
|
253
|
+
occ_ids = {e["from_id"] for e in edges} | {e["to_id"] for e in edges}
|
|
254
|
+
occ_ids.discard(0)
|
|
255
|
+
occ_ids.discard(None)
|
|
256
|
+
nodes = []
|
|
257
|
+
if occ_ids:
|
|
258
|
+
nodes = conn.execute(
|
|
259
|
+
_NODES_IN_QUERY,
|
|
260
|
+
(sid, json.dumps(sorted(occ_ids)))).fetchall()
|
|
261
|
+
return {"snapshot_id": sid,
|
|
262
|
+
"edges": [_row_to_edge(e, sid) for e in edges],
|
|
263
|
+
"nodes": [_row_to_node(n) for n in nodes],
|
|
264
|
+
"truncated": len(edges) == limit}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _file_occurrences(server: _AtlasHttpd, conn: Any, file_id: str,
|
|
268
|
+
p: dict[str, str]) -> dict[str, Any]:
|
|
269
|
+
"""Ocorrências de símbolo de um arquivo — escopo snapshot do repo dele."""
|
|
270
|
+
try:
|
|
271
|
+
fid = int(file_id)
|
|
272
|
+
except ValueError as exc:
|
|
273
|
+
raise ApiError("bad-param", "file_id inválido") from exc
|
|
274
|
+
frow = conn.execute(
|
|
275
|
+
"SELECT id, repository_id, relative_path, language FROM source_files "
|
|
276
|
+
"WHERE id = ?", (fid,)).fetchone()
|
|
277
|
+
if frow is None:
|
|
278
|
+
raise ApiError("file-not-found", "arquivo inexistente", 404)
|
|
279
|
+
rid = int(frow["repository_id"])
|
|
280
|
+
sid = _snapshot(conn, rid, p)
|
|
281
|
+
limit = _int(p, "limit", 200, cap=500)
|
|
282
|
+
offset = _int(p, "offset", 0, cap=10**9)
|
|
283
|
+
rows = conn.execute(
|
|
284
|
+
"SELECT o.id AS occurrence_id, o.logical_symbol_id AS symbol_id, "
|
|
285
|
+
"s.qualified_name AS qname, s.kind AS kind, o.span_start, o.span_end, "
|
|
286
|
+
"o.line, o.col, o.content_hash, o.parse_status "
|
|
287
|
+
"FROM symbol_occurrences o "
|
|
288
|
+
"JOIN symbols s ON s.id = o.logical_symbol_id "
|
|
289
|
+
"WHERE o.snapshot_id = ? AND o.source_file_id = ? "
|
|
290
|
+
"ORDER BY o.line, o.col, o.id LIMIT ? OFFSET ?",
|
|
291
|
+
(sid, fid, limit, offset)).fetchall()
|
|
292
|
+
total = conn.execute(
|
|
293
|
+
"SELECT COUNT(*) AS n FROM symbol_occurrences "
|
|
294
|
+
"WHERE snapshot_id = ? AND source_file_id = ?",
|
|
295
|
+
(sid, fid)).fetchone()["n"]
|
|
296
|
+
items = [{
|
|
297
|
+
"id": f"occ:{r['occurrence_id']}",
|
|
298
|
+
"occurrence_id": r["occurrence_id"],
|
|
299
|
+
"symbol_id": r["symbol_id"],
|
|
300
|
+
"qualified_name": r["qname"],
|
|
301
|
+
"kind": r["kind"],
|
|
302
|
+
"span_start": r["span_start"],
|
|
303
|
+
"span_end": r["span_end"],
|
|
304
|
+
"line": r["line"],
|
|
305
|
+
"col": r["col"],
|
|
306
|
+
"content_hash": r["content_hash"],
|
|
307
|
+
"parse_status": r["parse_status"],
|
|
308
|
+
} for r in rows]
|
|
309
|
+
return {"file_id": fid, "repository_id": rid, "snapshot_id": sid,
|
|
310
|
+
"path": frow["relative_path"], "language": frow["language"],
|
|
311
|
+
"items": items, "total": total, "limit": limit, "offset": offset,
|
|
312
|
+
"truncated": offset + len(items) < total}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _symbol(server: _AtlasHttpd, conn: Any, sym_id: str,
|
|
316
|
+
p: dict[str, str]) -> dict[str, Any]:
|
|
317
|
+
rid = _repo(conn, p)
|
|
318
|
+
sid = _snapshot(conn, rid, p)
|
|
319
|
+
try:
|
|
320
|
+
occ_id = int(sym_id)
|
|
321
|
+
except ValueError as exc:
|
|
322
|
+
raise ApiError("bad-param", "id de símbolo deve ser occurrence_id") from exc
|
|
323
|
+
row = conn.execute(_NODE_BY_ID_QUERY, (sid, occ_id)).fetchone()
|
|
324
|
+
if row is None:
|
|
325
|
+
raise ApiError("symbol-not-found", "símbolo inexistente", 404)
|
|
326
|
+
counts = conn.execute(
|
|
327
|
+
"SELECT type, resolution_status, COUNT(*) AS n FROM relations "
|
|
328
|
+
"WHERE snapshot_id = ? AND (from_id = ? OR to_id = ?) GROUP BY 1,2",
|
|
329
|
+
(sid, occ_id, occ_id)).fetchall()
|
|
330
|
+
out = _row_to_node(row)
|
|
331
|
+
out["relations_summary"] = [dict(c) for c in counts]
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _search_strategy(p: dict[str, str]) -> str:
|
|
336
|
+
"""Estratégia pedida — default hybrid, valor desconhecido → hybrid."""
|
|
337
|
+
s = p.get("strategy", "hybrid")
|
|
338
|
+
return s if s in (
|
|
339
|
+
"lexical", "structural", "hybrid", "vector", "graph") else "hybrid"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _search_repo_filter(conn: Any, p: dict[str, str]) -> int | None:
|
|
343
|
+
"""``repository_id`` opcional validado — narrowing por repositório."""
|
|
344
|
+
rid = p.get("repository_id", "")
|
|
345
|
+
if not rid:
|
|
346
|
+
return None
|
|
347
|
+
try:
|
|
348
|
+
repo_id = int(rid)
|
|
349
|
+
except ValueError as exc:
|
|
350
|
+
raise ApiError("bad-param", "repository_id inválido") from exc
|
|
351
|
+
if Store(conn).repository_by_id(repo_id) is None:
|
|
352
|
+
raise ApiError("repo-not-found", "repositório inexistente", 404)
|
|
353
|
+
return repo_id
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _search(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
357
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
358
|
+
"""Busca de símbolos + faceta de domínio (lf-0119).
|
|
359
|
+
|
|
360
|
+
``workspace=``/``federation=`` ativa a busca federada (mesmo
|
|
361
|
+
envelope da família search + identidade da view); ``repository_id``
|
|
362
|
+
seleciona/narrow o repo; ``domain`` filtra a faceta de domínio;
|
|
363
|
+
``pin=repo:snap[,…]`` aplica overrides da view.
|
|
364
|
+
"""
|
|
365
|
+
from code_atlas.search.contracts import SearchRequest
|
|
366
|
+
from code_atlas.search.engine import SearchEngine
|
|
367
|
+
q = p.get("q", "")
|
|
368
|
+
if not q or len(q) > 300:
|
|
369
|
+
raise ApiError("bad-param", "q obrigatório (≤300 chars)")
|
|
370
|
+
store = Store(conn)
|
|
371
|
+
workspace = p.get("workspace") or p.get("federation") or ""
|
|
372
|
+
repo_filter = _search_repo_filter(conn, p)
|
|
373
|
+
doms = _csv_filter(p.get("domain", ""), "domain", max_items=1)
|
|
374
|
+
req = SearchRequest(
|
|
375
|
+
query=q,
|
|
376
|
+
strategy=_search_strategy(p),
|
|
377
|
+
snapshot_id=_int(p, "snapshot_id", 0, cap=10**9) or None,
|
|
378
|
+
limit=_int(p, "limit", 20, cap=100) or 20,
|
|
379
|
+
cursor=p.get("cursor") or None,
|
|
380
|
+
language=p.get("language") or None,
|
|
381
|
+
kind=p.get("kind") or None,
|
|
382
|
+
path=p.get("path") or None,
|
|
383
|
+
domain=doms[0] if doms else None)
|
|
384
|
+
if workspace:
|
|
385
|
+
from code_atlas.search.federated import FederatedSearchEngine
|
|
386
|
+
pins = _parse_pins(p)
|
|
387
|
+
return _fed_call(
|
|
388
|
+
lambda: FederatedSearchEngine(conn).search(
|
|
389
|
+
req, workspace, pins, repository_id=repo_filter
|
|
390
|
+
).to_dict())
|
|
391
|
+
# repo escolhido: repository_id (explícito) > repository_root > 1º repo
|
|
392
|
+
root = p.get("repository_root", "")
|
|
393
|
+
if repo_filter is not None:
|
|
394
|
+
repo = store.repository_by_id(repo_filter)
|
|
395
|
+
assert repo is not None # validado em _search_repo_filter
|
|
396
|
+
root = repo.root_handle
|
|
397
|
+
elif not root:
|
|
398
|
+
first = conn.execute(
|
|
399
|
+
"SELECT root_handle FROM repositories ORDER BY id LIMIT 1"
|
|
400
|
+
).fetchone()
|
|
401
|
+
root = str(first["root_handle"]) if first else ""
|
|
402
|
+
elif store.repository_id_for(root) is None:
|
|
403
|
+
raise ApiError("repo-not-indexed", "repositório não indexado", 404)
|
|
404
|
+
res = SearchEngine(conn).search(
|
|
405
|
+
dataclasses.replace(req, repository_root=root))
|
|
406
|
+
return dataclasses.asdict(res)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _context(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
410
|
+
body: dict[str, Any] | None) -> dict[str, Any]:
|
|
411
|
+
from code_atlas.context.contracts import ContextRequest
|
|
412
|
+
from code_atlas.context.engine import ContextEngine
|
|
413
|
+
body = body or {}
|
|
414
|
+
task = str(body.get("task", ""))[:2000]
|
|
415
|
+
if not task:
|
|
416
|
+
raise ApiError("bad-param", "task obrigatória")
|
|
417
|
+
pack = ContextEngine(conn).build(ContextRequest(
|
|
418
|
+
task=task,
|
|
419
|
+
repository_root=str(body.get("repository_root", ""))[:1000],
|
|
420
|
+
snapshot_id=body.get("snapshot_id"),
|
|
421
|
+
level=body.get("level", "focus"),
|
|
422
|
+
budget_tokens=min(int(body.get("budget_tokens", 4000)), 64000),
|
|
423
|
+
max_symbols=min(int(body.get("max_symbols", 50)), 200)))
|
|
424
|
+
return pack.to_dict()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _impact(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
428
|
+
body: dict[str, Any] | None) -> dict[str, Any]:
|
|
429
|
+
from code_atlas.impact.contracts import ImpactRequest
|
|
430
|
+
from code_atlas.impact.engine import ImpactEngine
|
|
431
|
+
body = body or {}
|
|
432
|
+
root = str(body.get("repository_root", ""))
|
|
433
|
+
if not root or len(root) > 1000:
|
|
434
|
+
raise ApiError("bad-param", "repository_root obrigatório")
|
|
435
|
+
res = ImpactEngine(conn).analyze(ImpactRequest(
|
|
436
|
+
repository_root=root,
|
|
437
|
+
base_revision=str(body.get("base_revision", "HEAD"))[:200],
|
|
438
|
+
target_revision=str(body.get("target_revision", ""))[:200],
|
|
439
|
+
symbol=str(body.get("symbol"))[:300] if body.get("symbol") else None,
|
|
440
|
+
file=str(body.get("file"))[:1000] if body.get("file") else None,
|
|
441
|
+
base_snapshot_id=body.get("base_snapshot_id"),
|
|
442
|
+
target_snapshot_id=body.get("target_snapshot_id"),
|
|
443
|
+
include_possible=bool(body.get("include_possible", True)),
|
|
444
|
+
known_only=bool(body.get("known_only", False))))
|
|
445
|
+
return dataclasses.asdict(res)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _evidence(server: _AtlasHttpd, conn: Any, ev_id: str,
|
|
449
|
+
p: dict[str, str]) -> dict[str, Any]:
|
|
450
|
+
rid = _repo(conn, p)
|
|
451
|
+
sid = _snapshot(conn, rid, p)
|
|
452
|
+
try:
|
|
453
|
+
occ_id = int(ev_id)
|
|
454
|
+
except ValueError as exc:
|
|
455
|
+
raise ApiError("bad-param", "id de evidência inválido") from exc
|
|
456
|
+
row = conn.execute(
|
|
457
|
+
"SELECT id, extractor, extractor_version, notes, path, span, "
|
|
458
|
+
"content_hash FROM evidence WHERE snapshot_id = ? AND id = ?",
|
|
459
|
+
(sid, occ_id)).fetchone()
|
|
460
|
+
if row is None:
|
|
461
|
+
raise ApiError("evidence-not-found", "evidência inexistente", 404)
|
|
462
|
+
refs = conn.execute(
|
|
463
|
+
"SELECT id, from_id, to_id, type, resolution_status FROM relations "
|
|
464
|
+
"WHERE snapshot_id = ? AND evidence_ids LIKE ? LIMIT 100",
|
|
465
|
+
(sid, f"%{occ_id}%")).fetchall()
|
|
466
|
+
return {"snapshot_id": sid, "evidence": dict(row),
|
|
467
|
+
"referencing_relations": [dict(r) for r in refs]}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _index_status(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
471
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
472
|
+
repos = conn.execute(
|
|
473
|
+
"SELECT r.id, r.root_handle, "
|
|
474
|
+
"(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
|
|
475
|
+
" AND s.status = 'READY') AS ready, "
|
|
476
|
+
"(SELECT MAX(id) FROM snapshots s WHERE s.repository_id = r.id "
|
|
477
|
+
" AND s.status = 'READY') AS latest_ready "
|
|
478
|
+
"FROM repositories r ORDER BY r.id").fetchall()
|
|
479
|
+
items = []
|
|
480
|
+
for r in repos:
|
|
481
|
+
sid = r["latest_ready"]
|
|
482
|
+
counts = {}
|
|
483
|
+
if sid:
|
|
484
|
+
for tbl, sql in _COUNT_QUERIES.items():
|
|
485
|
+
counts[tbl] = conn.execute(sql, (sid,)).fetchone()["n"]
|
|
486
|
+
items.append({"repository_id": r["id"], "root_handle": r["root_handle"],
|
|
487
|
+
"ready_snapshots": r["ready"], "latest_ready": sid,
|
|
488
|
+
"counts": counts})
|
|
489
|
+
return {"items": items}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# --- diagnósticos operacionais (lf-0141, Cycle 8 Phase 15) --------------
|
|
493
|
+
#
|
|
494
|
+
# ``/diagnostics`` é o painel do operador: versão, uptime, schema,
|
|
495
|
+
# contagens por repositório, inventário de adapters, FTS, tamanho do
|
|
496
|
+
# índice em disco. Estritamente read-only e barato — metadados
|
|
497
|
+
# persistidos + ``stat()`` do arquivo, nunca scan de workspace nem
|
|
498
|
+
# re-execução de adapter/resolver. Bancos pré-v9/v10 podem não ter as
|
|
499
|
+
# tabelas de domínio/federação — presença é conferida em
|
|
500
|
+
# ``sqlite_master`` antes de contar.
|
|
501
|
+
|
|
502
|
+
_LOG = get_logger("code_atlas.api")
|
|
503
|
+
|
|
504
|
+
_DIAG_COUNT_QUERIES = {
|
|
505
|
+
"domain_entity_occurrences":
|
|
506
|
+
"SELECT COUNT(*) AS n FROM domain_entity_occurrences "
|
|
507
|
+
"WHERE snapshot_id = ?",
|
|
508
|
+
"domain_relations":
|
|
509
|
+
"SELECT COUNT(*) AS n FROM domain_relations WHERE snapshot_id = ?",
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _fts5_build_probe() -> bool:
|
|
514
|
+
"""Capacidade FTS5 desta build do SQLite — ``True``/``False`` honesto.
|
|
515
|
+
|
|
516
|
+
A conexão da API é ``query_only`` — nem vtable temporária pode ser
|
|
517
|
+
criada nela (``attempt to write a readonly database``). A probe roda
|
|
518
|
+
num ``:memory:`` descartável — mesmo teste do ``doctor`` da CLI.
|
|
519
|
+
"""
|
|
520
|
+
try:
|
|
521
|
+
probe = sqlite3.connect(":memory:")
|
|
522
|
+
try:
|
|
523
|
+
probe.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
|
|
524
|
+
finally:
|
|
525
|
+
probe.close()
|
|
526
|
+
return True
|
|
527
|
+
except sqlite3.Error:
|
|
528
|
+
return False
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _diag_repo_job(conn: Any, repo_id: int) -> dict[str, Any] | None:
|
|
532
|
+
"""Último ``index_jobs`` do repo — com ``stage_ms_*`` decodificados.
|
|
533
|
+
|
|
534
|
+
``stage_ms`` é persistido desde o Cycle 2 dentro de ``counts``;
|
|
535
|
+
aqui ele sai do envelope genérico e vira campo próprio inspecionável.
|
|
536
|
+
"""
|
|
537
|
+
job = conn.execute(
|
|
538
|
+
"SELECT id, status, started_at, finished_at, counts "
|
|
539
|
+
"FROM index_jobs WHERE repository_id = ? "
|
|
540
|
+
"ORDER BY id DESC LIMIT 1", (repo_id,)).fetchone()
|
|
541
|
+
if job is None:
|
|
542
|
+
return None
|
|
543
|
+
jcounts = json.loads(job["counts"] or "{}")
|
|
544
|
+
return {
|
|
545
|
+
"id": job["id"], "status": job["status"],
|
|
546
|
+
"started_at": job["started_at"],
|
|
547
|
+
"finished_at": job["finished_at"],
|
|
548
|
+
"stage_ms": {
|
|
549
|
+
k.removeprefix("stage_ms_"): v
|
|
550
|
+
for k, v in jcounts.items() if k.startswith("stage_ms_")
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _diagnostics(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
556
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
557
|
+
from code_atlas.search.engine import SearchEngine
|
|
558
|
+
from code_atlas.storage.migrations import schema_version
|
|
559
|
+
|
|
560
|
+
tables = {
|
|
561
|
+
str(r["name"]) for r in conn.execute(
|
|
562
|
+
"SELECT name FROM sqlite_master WHERE type IN ('table','view')")
|
|
563
|
+
}
|
|
564
|
+
db_file = Path(server.db_path)
|
|
565
|
+
wal_file = db_file.with_name(db_file.name + "-wal")
|
|
566
|
+
repos: list[dict[str, Any]] = []
|
|
567
|
+
for r in conn.execute(
|
|
568
|
+
"SELECT r.id, r.identity_key, r.root_handle, "
|
|
569
|
+
"(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
|
|
570
|
+
" AND s.status = 'READY') AS ready, "
|
|
571
|
+
"(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
|
|
572
|
+
" AND s.status = 'FAILED') AS failed, "
|
|
573
|
+
"(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
|
|
574
|
+
" AND s.status = 'STALE') AS stale, "
|
|
575
|
+
"(SELECT COUNT(*) FROM snapshots s WHERE s.repository_id = r.id "
|
|
576
|
+
" AND s.status = 'BUILDING') AS building, "
|
|
577
|
+
"(SELECT MAX(id) FROM snapshots s WHERE s.repository_id = r.id "
|
|
578
|
+
" AND s.status = 'READY') AS latest_ready "
|
|
579
|
+
"FROM repositories r ORDER BY r.id").fetchall():
|
|
580
|
+
sid = r["latest_ready"]
|
|
581
|
+
counts: dict[str, int] = {}
|
|
582
|
+
analyzers: dict[str, str] = {}
|
|
583
|
+
created_at = None
|
|
584
|
+
if sid:
|
|
585
|
+
for tbl, sql in _COUNT_QUERIES.items():
|
|
586
|
+
counts[tbl] = conn.execute(sql, (sid,)).fetchone()["n"]
|
|
587
|
+
for tbl, sql in _DIAG_COUNT_QUERIES.items():
|
|
588
|
+
if tbl in tables:
|
|
589
|
+
counts[tbl] = conn.execute(
|
|
590
|
+
sql, (sid,)).fetchone()["n"]
|
|
591
|
+
srow = conn.execute(
|
|
592
|
+
"SELECT analyzer_versions, created_at FROM snapshots "
|
|
593
|
+
"WHERE id = ?", (sid,)).fetchone()
|
|
594
|
+
if srow is not None:
|
|
595
|
+
analyzers = json.loads(srow["analyzer_versions"] or "{}")
|
|
596
|
+
created_at = srow["created_at"]
|
|
597
|
+
repos.append({
|
|
598
|
+
"repository_id": r["id"],
|
|
599
|
+
"identity_key": r["identity_key"],
|
|
600
|
+
"root_handle": r["root_handle"],
|
|
601
|
+
"snapshots": {"ready": r["ready"], "failed": r["failed"],
|
|
602
|
+
"stale": r["stale"], "building": r["building"]},
|
|
603
|
+
"latest_ready": sid,
|
|
604
|
+
"latest_ready_created_at": created_at,
|
|
605
|
+
"counts": counts,
|
|
606
|
+
# versões reais que produziram o READY — fonte autoritativa
|
|
607
|
+
# de "quais parsers/resolvers/domínios construíram isto".
|
|
608
|
+
"analyzer_versions": analyzers,
|
|
609
|
+
"last_job": _diag_repo_job(conn, r["id"]),
|
|
610
|
+
})
|
|
611
|
+
adapters: list[dict[str, Any]] = []
|
|
612
|
+
adapters_error = ""
|
|
613
|
+
try:
|
|
614
|
+
from code_atlas.domains.cli_surface import adapters_report
|
|
615
|
+
adapters = adapters_report()
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
# inventário é metadado de adapters built-in — uma falha de
|
|
618
|
+
# importação nunca derruba o endpoint; vira campo honesto.
|
|
619
|
+
adapters_error = str(exc)[:200]
|
|
620
|
+
started = getattr(server, "started_at", None)
|
|
621
|
+
return {
|
|
622
|
+
"status": "ok",
|
|
623
|
+
"service": "code-atlas-api",
|
|
624
|
+
"api_version": 1,
|
|
625
|
+
"version": __version__,
|
|
626
|
+
"readonly": True,
|
|
627
|
+
"uptime_s": (round(time.monotonic() - started, 3)
|
|
628
|
+
if isinstance(started, (int, float)) else None),
|
|
629
|
+
"runtime": {"python": sys.version.split()[0],
|
|
630
|
+
"sqlite": sqlite3.sqlite_version},
|
|
631
|
+
"schema_version": schema_version(conn),
|
|
632
|
+
"database": {
|
|
633
|
+
"path": str(db_file),
|
|
634
|
+
"size_bytes": (db_file.stat().st_size
|
|
635
|
+
if db_file.is_file() else 0),
|
|
636
|
+
"wal_bytes": (wal_file.stat().st_size
|
|
637
|
+
if wal_file.is_file() else 0),
|
|
638
|
+
"journal_mode": str(conn.execute(
|
|
639
|
+
"PRAGMA journal_mode").fetchone()[0]),
|
|
640
|
+
"page_count": int(conn.execute(
|
|
641
|
+
"PRAGMA page_count").fetchone()[0]),
|
|
642
|
+
},
|
|
643
|
+
"fts": {
|
|
644
|
+
"fts5_build": _fts5_build_probe(),
|
|
645
|
+
"fts_symbols": "fts_symbols" in tables,
|
|
646
|
+
"fts_occurrences": "fts_occurrences" in tables,
|
|
647
|
+
"domain_fts": "domain_fts" in tables,
|
|
648
|
+
},
|
|
649
|
+
"search": dataclasses.asdict(SearchEngine(conn).capabilities()),
|
|
650
|
+
"repositories": repos,
|
|
651
|
+
"federation": {
|
|
652
|
+
"workspaces": (
|
|
653
|
+
conn.execute("SELECT COUNT(*) AS n "
|
|
654
|
+
"FROM federation_workspaces").fetchone()["n"]
|
|
655
|
+
if "federation_workspaces" in tables else 0),
|
|
656
|
+
},
|
|
657
|
+
"adapters": {"domain": adapters, "error": adapters_error},
|
|
658
|
+
"observability": {
|
|
659
|
+
"log_level": os.environ.get("CODE_ATLAS_LOG_LEVEL", "WARNING"),
|
|
660
|
+
"log_format": os.environ.get("CODE_ATLAS_LOG_FORMAT", "text"),
|
|
661
|
+
"slow_ms": slow_ms(),
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# --- federação multi-repo (lf-0118, Phase 10 — somente leitura) -------
|
|
667
|
+
#
|
|
668
|
+
# Views/lookups/graphs são GET sobre conexão ``query_only``; criar/
|
|
669
|
+
# editar workspaces é superfície exclusiva da CLI — POST de federação
|
|
670
|
+
# não existe por desenho nesta fase.
|
|
671
|
+
|
|
672
|
+
_FED_ERROR_MAP: tuple[tuple[type[FederationError], str, int], ...] = (
|
|
673
|
+
(WorkspaceNotFoundError, "workspace-not-found", 404),
|
|
674
|
+
(WorkspaceSizeError, "federation-too-small", 409),
|
|
675
|
+
(WorkspaceExistsError, "workspace-exists", 409),
|
|
676
|
+
(MemberNotFoundError, "member-not-found", 404),
|
|
677
|
+
(RepositoryNotFoundError, "repo-not-found", 404),
|
|
678
|
+
(SnapshotNotFoundError, "snapshot-not-found", 404),
|
|
679
|
+
(SnapshotNotReadyError, "snapshot-not-ready", 409),
|
|
680
|
+
(InvalidPinError, "bad-pin", 400),
|
|
681
|
+
(InvalidKeyError, "bad-key", 400),
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _fed_call[T](fn: Callable[[], T]) -> T:
|
|
686
|
+
"""Mapeia ``FederationError`` tipadas para ``ApiError``."""
|
|
687
|
+
try:
|
|
688
|
+
return fn()
|
|
689
|
+
except FederationError as exc:
|
|
690
|
+
for cls, code, status in _FED_ERROR_MAP:
|
|
691
|
+
if isinstance(exc, cls):
|
|
692
|
+
raise ApiError(code, str(exc)[:300], status) from exc
|
|
693
|
+
raise ApiError("federation-error", str(exc)[:300]) from exc
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _parse_pins(p: dict[str, str]) -> dict[int, int]:
|
|
697
|
+
"""``?pin=<repo>:<snap>[,<repo>:<snap>…]`` — overrides da view."""
|
|
698
|
+
raw = p.get("pin", "")
|
|
699
|
+
out: dict[int, int] = {}
|
|
700
|
+
if not raw.strip():
|
|
701
|
+
return out
|
|
702
|
+
for part in raw.split(","):
|
|
703
|
+
seg = part.strip()
|
|
704
|
+
if not seg:
|
|
705
|
+
continue
|
|
706
|
+
repo, sep, snap = seg.partition(":")
|
|
707
|
+
if not sep or not repo.isdigit() or not snap.isdigit():
|
|
708
|
+
raise ApiError(
|
|
709
|
+
"bad-param",
|
|
710
|
+
"pin deve ser repo:snapshot[,repo:snapshot…]",
|
|
711
|
+
)
|
|
712
|
+
out[int(repo)] = int(snap)
|
|
713
|
+
return out
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _fed_workspaces(server: _AtlasHttpd, conn: Any, _p: dict[str, str],
|
|
717
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
718
|
+
rows = conn.execute(
|
|
719
|
+
"SELECT w.id, w.name, w.created_at, "
|
|
720
|
+
"(SELECT COUNT(*) FROM federation_members m "
|
|
721
|
+
" WHERE m.workspace_id = w.id) AS members "
|
|
722
|
+
"FROM federation_workspaces w ORDER BY w.name").fetchall()
|
|
723
|
+
return {"items": [dict(r) for r in rows], "total": len(rows)}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _fed_workspace(server: _AtlasHttpd, conn: Any, ws_id: str,
|
|
727
|
+
_p: dict[str, str]) -> dict[str, Any]:
|
|
728
|
+
fed = Federation(conn)
|
|
729
|
+
ws = _fed_call(lambda: fed.get_workspace(int(ws_id)))
|
|
730
|
+
rows = conn.execute(
|
|
731
|
+
"SELECT m.repository_id, m.pinned_snapshot_id, m.added_at, "
|
|
732
|
+
"r.root_handle, r.identity_key "
|
|
733
|
+
"FROM federation_members m "
|
|
734
|
+
"JOIN repositories r ON r.id = m.repository_id "
|
|
735
|
+
"WHERE m.workspace_id = ? ORDER BY m.repository_id",
|
|
736
|
+
(ws.id,)).fetchall()
|
|
737
|
+
return {"id": ws.id, "name": ws.name, "created_at": ws.created_at,
|
|
738
|
+
"members": [dict(r) for r in rows]}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _fed_view(server: _AtlasHttpd, conn: Any, ws_id: str,
|
|
742
|
+
p: dict[str, str]) -> dict[str, Any]:
|
|
743
|
+
fed = Federation(conn)
|
|
744
|
+
pins = _parse_pins(p)
|
|
745
|
+
return _fed_call(
|
|
746
|
+
lambda: fed.resolve_view(fed.get_workspace(int(ws_id)), pins)
|
|
747
|
+
.to_dict()
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _fed_entity_occurrences(server: _AtlasHttpd, conn: Any, ws_id: str,
|
|
752
|
+
key: str, p: dict[str, str]) -> dict[str, Any]:
|
|
753
|
+
fed = Federation(conn)
|
|
754
|
+
pins = _parse_pins(p)
|
|
755
|
+
logical_key = unquote(key)[:2000]
|
|
756
|
+
return _fed_call(
|
|
757
|
+
lambda: fed.entity_occurrences(
|
|
758
|
+
fed.get_workspace(int(ws_id)), logical_key, pins
|
|
759
|
+
)
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _fed_graph(server: _AtlasHttpd, conn: Any, ws_id: str,
|
|
764
|
+
p: dict[str, str]) -> dict[str, Any]:
|
|
765
|
+
fed = Federation(conn)
|
|
766
|
+
pins = _parse_pins(p)
|
|
767
|
+
depth = _int(p, "depth", 1, cap=8)
|
|
768
|
+
limit = _int(p, "limit", 500, cap=5000)
|
|
769
|
+
return _fed_call(
|
|
770
|
+
lambda: fed.graph(
|
|
771
|
+
fed.get_workspace(int(ws_id)), depth=depth, limit=limit,
|
|
772
|
+
pins=pins,
|
|
773
|
+
).to_dict()
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _fed_search(server: _AtlasHttpd, conn: Any, ws_id: str,
|
|
778
|
+
p: dict[str, str]) -> dict[str, Any]:
|
|
779
|
+
"""Busca federada num workspace (lf-0119).
|
|
780
|
+
|
|
781
|
+
Cada membro é pesquisado no PRÓPRIO escopo ``(repo, snapshot)``
|
|
782
|
+
resolvido pela view — merge posicional determinístico, items
|
|
783
|
+
tagueados ``repository_id``+``snapshot_id``, homônimos cross-repo
|
|
784
|
+
nunca colapsam. ``pin=`` e ``snapshot_id`` ajustam a view;
|
|
785
|
+
``repository_id`` restringe a um membro.
|
|
786
|
+
"""
|
|
787
|
+
from code_atlas.search.contracts import SearchRequest
|
|
788
|
+
from code_atlas.search.federated import FederatedSearchEngine
|
|
789
|
+
q = p.get("q", "")
|
|
790
|
+
if not q or len(q) > 300:
|
|
791
|
+
raise ApiError("bad-param", "q obrigatório (≤300 chars)")
|
|
792
|
+
repo_filter = _search_repo_filter(conn, p)
|
|
793
|
+
doms = _csv_filter(p.get("domain", ""), "domain", max_items=1)
|
|
794
|
+
req = SearchRequest(
|
|
795
|
+
query=q,
|
|
796
|
+
strategy=_search_strategy(p),
|
|
797
|
+
snapshot_id=_int(p, "snapshot_id", 0, cap=10**9) or None,
|
|
798
|
+
limit=_int(p, "limit", 20, cap=100) or 20,
|
|
799
|
+
cursor=p.get("cursor") or None,
|
|
800
|
+
language=p.get("language") or None,
|
|
801
|
+
kind=p.get("kind") or None,
|
|
802
|
+
path=p.get("path") or None,
|
|
803
|
+
domain=doms[0] if doms else None)
|
|
804
|
+
pins = _parse_pins(p)
|
|
805
|
+
return _fed_call(
|
|
806
|
+
lambda: FederatedSearchEngine(conn).search(
|
|
807
|
+
req, int(ws_id), pins, repository_id=repo_filter
|
|
808
|
+
).to_dict())
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# --- domínios (lf-0122, Phase 14 — somente leitura) --------------------
|
|
812
|
+
#
|
|
813
|
+
# Superfície read-only sobre ``domain_entities`` /
|
|
814
|
+
# ``domain_entity_occurrences`` / ``domain_relations`` (schema v9). Todo
|
|
815
|
+
# endpoint resolve ``repository_id`` + ``snapshot_id`` pelo mesmo caminho
|
|
816
|
+
# dos demais handlers — isolamento de snapshot preservado, nada é
|
|
817
|
+
# agregado entre snapshots ou repositórios.
|
|
818
|
+
|
|
819
|
+
_DOMAIN_PARAM = re.compile(r"^[a-zA-Z0-9_.\-]{1,100}$")
|
|
820
|
+
_LINEAGE_KINDS = (
|
|
821
|
+
"data_table", "data_view", "iceberg_table", "data_column",
|
|
822
|
+
"data_transformation",
|
|
823
|
+
)
|
|
824
|
+
# Tipos que carregam fluxo de dados — a travessia upstream/downstream do
|
|
825
|
+
# lineage só segue este vocabulário (READS_FROM/WRITES_TO/TRANSFORMS/
|
|
826
|
+
# DERIVES_FROM), nunca arestas estruturais.
|
|
827
|
+
_LINEAGE_READ = ("READS_FROM", "DERIVES_FROM")
|
|
828
|
+
_LINEAGE_WRITE = ("WRITES_TO", "TRANSFORMS")
|
|
829
|
+
_LINEAGE_MAX_NODES = 300
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _csv_filter(raw: str, param: str, max_items: int = 8) -> list[str]:
|
|
833
|
+
"""Filtro ``a,b,c`` validado — lista vazia significa "sem filtro"."""
|
|
834
|
+
out: list[str] = []
|
|
835
|
+
for part in (raw or "").split(","):
|
|
836
|
+
value = part.strip()
|
|
837
|
+
if not value:
|
|
838
|
+
continue
|
|
839
|
+
if not _DOMAIN_PARAM.fullmatch(value):
|
|
840
|
+
raise ApiError(
|
|
841
|
+
"bad-param", f"parâmetro {param} inválido: {value!r}")
|
|
842
|
+
out.append(value)
|
|
843
|
+
if len(out) > max_items:
|
|
844
|
+
raise ApiError(
|
|
845
|
+
"bad-param",
|
|
846
|
+
f"parâmetro {param} aceita no máximo {max_items} valores",
|
|
847
|
+
)
|
|
848
|
+
return out
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _in_clause(values: list[str]) -> str:
|
|
852
|
+
return ", ".join("?" for _ in values)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _domain_entity_dto(row: Any) -> dict[str, Any]:
|
|
856
|
+
try:
|
|
857
|
+
attrs = json.loads(row["attributes"] or "{}")
|
|
858
|
+
except json.JSONDecodeError:
|
|
859
|
+
attrs = {} # atributo corrompido nunca derruba a leitura
|
|
860
|
+
return {
|
|
861
|
+
"id": row["id"],
|
|
862
|
+
"domain": row["domain"],
|
|
863
|
+
"kind": row["kind"],
|
|
864
|
+
"qualified_name": row["qualified_name"],
|
|
865
|
+
"name": row["name"],
|
|
866
|
+
"catalog": row["catalog"],
|
|
867
|
+
"namespace": row["namespace"],
|
|
868
|
+
"environment": row["environment"],
|
|
869
|
+
"attributes": attrs,
|
|
870
|
+
"occurrences": row["occurrences"],
|
|
871
|
+
"first_occurrence_id": row["first_occurrence_id"],
|
|
872
|
+
"path": row["path"],
|
|
873
|
+
"line": row["line"],
|
|
874
|
+
"extraction_status": row["extraction_status"],
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _domains(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
879
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
880
|
+
"""Inventário de domínios: kinds presentes + contagens honestas.
|
|
881
|
+
|
|
882
|
+
``entities`` conta as entidades lógicas do repositório (estáveis
|
|
883
|
+
entre snapshots); ``occurrences`` conta as observadas no snapshot
|
|
884
|
+
resolvido — 0 é resposta válida, nunca omitida.
|
|
885
|
+
"""
|
|
886
|
+
rid = _repo(conn, p)
|
|
887
|
+
sid = _snapshot(conn, rid, p)
|
|
888
|
+
rows = conn.execute(
|
|
889
|
+
"SELECT de.domain, de.kind, COUNT(DISTINCT de.id) AS entities, "
|
|
890
|
+
"COUNT(deo.id) AS occurrences "
|
|
891
|
+
"FROM domain_entities de "
|
|
892
|
+
"LEFT JOIN domain_entity_occurrences deo "
|
|
893
|
+
" ON deo.entity_id = de.id AND deo.snapshot_id = ? "
|
|
894
|
+
"WHERE de.repository_id = ? "
|
|
895
|
+
"GROUP BY de.domain, de.kind ORDER BY de.domain, de.kind",
|
|
896
|
+
(sid, rid)).fetchall()
|
|
897
|
+
domains: dict[str, dict[str, Any]] = {}
|
|
898
|
+
for r in rows:
|
|
899
|
+
dom = domains.setdefault(r["domain"], {"domain": r["domain"],
|
|
900
|
+
"kinds": []})
|
|
901
|
+
dom["kinds"].append({"kind": r["kind"],
|
|
902
|
+
"entities": r["entities"],
|
|
903
|
+
"occurrences": r["occurrences"]})
|
|
904
|
+
items = [domains[d] for d in sorted(domains)]
|
|
905
|
+
return {"repository_id": rid, "snapshot_id": sid,
|
|
906
|
+
"items": items, "total_domains": len(items)}
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _domain_entities(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
910
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
911
|
+
"""Entidades de domínio com ocorrência no snapshot — paginado."""
|
|
912
|
+
rid = _repo(conn, p)
|
|
913
|
+
sid = _snapshot(conn, rid, p)
|
|
914
|
+
doms = _csv_filter(p.get("domain", ""), "domain")
|
|
915
|
+
kinds = _csv_filter(p.get("kind", ""), "kind")
|
|
916
|
+
q = p.get("q", "")[:300].strip()
|
|
917
|
+
limit = _int(p, "limit", 100)
|
|
918
|
+
offset = _int(p, "offset", 0, cap=10**9)
|
|
919
|
+
|
|
920
|
+
where = ["de.repository_id = ?"]
|
|
921
|
+
args: list[Any] = [sid, rid]
|
|
922
|
+
if doms:
|
|
923
|
+
where.append(f"de.domain IN ({_in_clause(doms)})")
|
|
924
|
+
args += doms
|
|
925
|
+
if kinds:
|
|
926
|
+
where.append(f"de.kind IN ({_in_clause(kinds)})")
|
|
927
|
+
args += kinds
|
|
928
|
+
if q:
|
|
929
|
+
like = ("%" + q.replace("\\", "\\\\").replace("%", "\\%")
|
|
930
|
+
.replace("_", "\\_") + "%")
|
|
931
|
+
where.append("(de.qualified_name LIKE ? ESCAPE '\\' "
|
|
932
|
+
"OR de.name LIKE ? ESCAPE '\\')")
|
|
933
|
+
args += [like, like]
|
|
934
|
+
where_sql = " AND ".join(where)
|
|
935
|
+
|
|
936
|
+
# SQLite garante que colunas "bare" num GROUP BY com MIN() vêm da row
|
|
937
|
+
# do mínimo — path/line/extraction_status descrevem a PRIMEIRA
|
|
938
|
+
# ocorrência da entidade no snapshot, de forma determinística.
|
|
939
|
+
entities_sql = (
|
|
940
|
+
"SELECT de.id, de.domain, de.kind, de.qualified_name, de.name, "
|
|
941
|
+
"de.catalog, de.namespace, de.environment, de.attributes, "
|
|
942
|
+
"COUNT(*) AS occurrences, MIN(deo.id) AS first_occurrence_id, "
|
|
943
|
+
"sf.relative_path AS path, deo.line AS line, "
|
|
944
|
+
"deo.extraction_status AS extraction_status "
|
|
945
|
+
"FROM domain_entities de "
|
|
946
|
+
"JOIN domain_entity_occurrences deo "
|
|
947
|
+
" ON deo.entity_id = de.id AND deo.snapshot_id = ? "
|
|
948
|
+
"LEFT JOIN source_files sf ON sf.id = deo.source_file_id "
|
|
949
|
+
f"WHERE {where_sql} "
|
|
950
|
+
"GROUP BY de.id "
|
|
951
|
+
"ORDER BY de.domain, de.kind, de.qualified_name, de.id "
|
|
952
|
+
"LIMIT ? OFFSET ?")
|
|
953
|
+
rows = conn.execute(entities_sql, (*args, limit, offset)).fetchall()
|
|
954
|
+
count_sql = (
|
|
955
|
+
"SELECT COUNT(DISTINCT de.id) AS n "
|
|
956
|
+
"FROM domain_entities de "
|
|
957
|
+
"JOIN domain_entity_occurrences deo "
|
|
958
|
+
" ON deo.entity_id = de.id AND deo.snapshot_id = ? "
|
|
959
|
+
f"WHERE {where_sql}")
|
|
960
|
+
total = conn.execute(count_sql, args).fetchone()["n"]
|
|
961
|
+
return {"repository_id": rid, "snapshot_id": sid,
|
|
962
|
+
"items": [_domain_entity_dto(r) for r in rows],
|
|
963
|
+
"total": total, "limit": limit, "offset": offset,
|
|
964
|
+
"truncated": offset + len(rows) < total}
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
_DOMAIN_EDGE_SELECT = (
|
|
968
|
+
"SELECT id, snapshot_id, from_entity_occ_id, from_symbol_occ_id, "
|
|
969
|
+
"to_entity_occ_id, to_symbol_occ_id, to_unresolved, type, "
|
|
970
|
+
"resolution_status, confidence_class, resolver, evidence_ids "
|
|
971
|
+
"FROM domain_relations ")
|
|
972
|
+
|
|
973
|
+
_DOMAIN_OCC_SELECT = (
|
|
974
|
+
"SELECT deo.id AS occ_id, deo.entity_id, deo.extraction_status, "
|
|
975
|
+
"deo.line, de.domain, de.kind AS entity_kind, de.qualified_name, "
|
|
976
|
+
"de.name, sf.relative_path AS path "
|
|
977
|
+
"FROM domain_entity_occurrences deo "
|
|
978
|
+
"JOIN domain_entities de ON de.id = deo.entity_id "
|
|
979
|
+
"LEFT JOIN source_files sf ON sf.id = deo.source_file_id ")
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _hydrate_domain_endpoints(
|
|
983
|
+
conn: Any, sid: int, rows: list[Any]
|
|
984
|
+
) -> tuple[dict[int, dict[str, Any]], dict[int, dict[str, Any]]]:
|
|
985
|
+
"""Resolve os endpoints de um lote de arestas — 2 queries no máximo."""
|
|
986
|
+
ent_ids = sorted(
|
|
987
|
+
({r["from_entity_occ_id"] for r in rows}
|
|
988
|
+
| {r["to_entity_occ_id"] for r in rows}) - {None})
|
|
989
|
+
sym_ids = sorted(
|
|
990
|
+
({r["from_symbol_occ_id"] for r in rows}
|
|
991
|
+
| {r["to_symbol_occ_id"] for r in rows}) - {None})
|
|
992
|
+
ents: dict[int, dict[str, Any]] = {}
|
|
993
|
+
if ent_ids:
|
|
994
|
+
occ_by_ids_sql = (
|
|
995
|
+
_DOMAIN_OCC_SELECT +
|
|
996
|
+
"WHERE deo.snapshot_id = ? "
|
|
997
|
+
"AND deo.id IN (SELECT value FROM json_each(?))")
|
|
998
|
+
for r in conn.execute(
|
|
999
|
+
occ_by_ids_sql, (sid, json.dumps(ent_ids))).fetchall():
|
|
1000
|
+
occ = int(r["occ_id"])
|
|
1001
|
+
eid = int(r["entity_id"])
|
|
1002
|
+
ents[occ] = {
|
|
1003
|
+
"ref": f"de:{eid}",
|
|
1004
|
+
"endpoint": "entity",
|
|
1005
|
+
"occurrence_id": occ,
|
|
1006
|
+
"entity_id": eid,
|
|
1007
|
+
"domain": r["domain"],
|
|
1008
|
+
"entity_kind": r["entity_kind"],
|
|
1009
|
+
"qualified_name": r["qualified_name"],
|
|
1010
|
+
"name": r["name"],
|
|
1011
|
+
"path": r["path"],
|
|
1012
|
+
"line": r["line"],
|
|
1013
|
+
"extraction_status": r["extraction_status"],
|
|
1014
|
+
}
|
|
1015
|
+
syms: dict[int, dict[str, Any]] = {}
|
|
1016
|
+
if sym_ids:
|
|
1017
|
+
for r in conn.execute(
|
|
1018
|
+
_NODES_IN_QUERY, (sid, json.dumps(sym_ids))).fetchall():
|
|
1019
|
+
occ = int(r["occurrence_id"])
|
|
1020
|
+
node = _row_to_node(r)
|
|
1021
|
+
syms[occ] = {
|
|
1022
|
+
"ref": node["id"], # occ:{id}
|
|
1023
|
+
"endpoint": "symbol",
|
|
1024
|
+
"occurrence_id": occ,
|
|
1025
|
+
"qualified_name": node["qname"],
|
|
1026
|
+
"kind": node["kind"],
|
|
1027
|
+
"language": node["language"],
|
|
1028
|
+
"path": node["path"],
|
|
1029
|
+
}
|
|
1030
|
+
return ents, syms
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _domain_endpoint(
|
|
1034
|
+
row: Any, side: str,
|
|
1035
|
+
ents: dict[int, dict[str, Any]],
|
|
1036
|
+
syms: dict[int, dict[str, Any]],
|
|
1037
|
+
) -> dict[str, Any]:
|
|
1038
|
+
"""Endpoint de uma aresta — entity | symbol | unresolved | stale.
|
|
1039
|
+
|
|
1040
|
+
``stale`` declara endpoint persistido fora do snapshot resolvido
|
|
1041
|
+
(aresta cross-snapshot auditável) — nunca confundido com resolved.
|
|
1042
|
+
"""
|
|
1043
|
+
eid = row[f"{side}_entity_occ_id"]
|
|
1044
|
+
if eid is not None:
|
|
1045
|
+
found = ents.get(int(eid))
|
|
1046
|
+
if found is not None:
|
|
1047
|
+
return found
|
|
1048
|
+
return {"ref": f"de_occ:{eid}", "endpoint": "entity",
|
|
1049
|
+
"occurrence_id": int(eid), "stale": True}
|
|
1050
|
+
oid = row[f"{side}_symbol_occ_id"]
|
|
1051
|
+
if oid is not None:
|
|
1052
|
+
found = syms.get(int(oid))
|
|
1053
|
+
if found is not None:
|
|
1054
|
+
return found
|
|
1055
|
+
return {"ref": f"occ:{oid}", "endpoint": "symbol",
|
|
1056
|
+
"occurrence_id": int(oid), "stale": True}
|
|
1057
|
+
if side == "to" and row["to_unresolved"]:
|
|
1058
|
+
label = str(row["to_unresolved"])
|
|
1059
|
+
return {"ref": f"unresolved:{label}", "endpoint": "unresolved",
|
|
1060
|
+
"label": label}
|
|
1061
|
+
return {"ref": f"{side}:none", "endpoint": "unknown"}
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _domain_edge_dto(
|
|
1065
|
+
row: Any,
|
|
1066
|
+
ents: dict[int, dict[str, Any]],
|
|
1067
|
+
syms: dict[int, dict[str, Any]],
|
|
1068
|
+
) -> dict[str, Any]:
|
|
1069
|
+
"""DTO completo da aresta de domínio — prova, nunca só endpoints."""
|
|
1070
|
+
return {
|
|
1071
|
+
"id": row["id"],
|
|
1072
|
+
"relation_id": row["id"],
|
|
1073
|
+
"snapshot_id": row["snapshot_id"],
|
|
1074
|
+
"type": row["type"],
|
|
1075
|
+
"resolution_status": row["resolution_status"],
|
|
1076
|
+
"confidence_class": row["confidence_class"],
|
|
1077
|
+
"resolver": row["resolver"],
|
|
1078
|
+
"evidence_ids": json.loads(row["evidence_ids"] or "[]"),
|
|
1079
|
+
"from": _domain_endpoint(row, "from", ents, syms),
|
|
1080
|
+
"to": _domain_endpoint(row, "to", ents, syms),
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _domain_edges_dto(conn: Any, sid: int, rows: list[Any]) -> list[dict[str, Any]]:
|
|
1085
|
+
ents, syms = _hydrate_domain_endpoints(conn, sid, rows)
|
|
1086
|
+
return [_domain_edge_dto(r, ents, syms) for r in rows]
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _domain_relations(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
1090
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
1091
|
+
"""Arestas de domínio do snapshot — filtro por ``type=a,b``."""
|
|
1092
|
+
rid = _repo(conn, p)
|
|
1093
|
+
sid = _snapshot(conn, rid, p)
|
|
1094
|
+
types = _csv_filter(p.get("type", ""), "type", max_items=20)
|
|
1095
|
+
limit = _int(p, "limit", 100)
|
|
1096
|
+
offset = _int(p, "offset", 0, cap=10**9)
|
|
1097
|
+
where = "WHERE snapshot_id = ?"
|
|
1098
|
+
args: list[Any] = [sid]
|
|
1099
|
+
if types:
|
|
1100
|
+
where += f" AND type IN ({_in_clause(types)})"
|
|
1101
|
+
args += types
|
|
1102
|
+
edges_sql = _DOMAIN_EDGE_SELECT + where + " ORDER BY id LIMIT ? OFFSET ?"
|
|
1103
|
+
rows = conn.execute(edges_sql, (*args, limit, offset)).fetchall()
|
|
1104
|
+
count_sql = f"SELECT COUNT(*) AS n FROM domain_relations {where}"
|
|
1105
|
+
total = conn.execute(count_sql, args).fetchone()["n"]
|
|
1106
|
+
return {"repository_id": rid, "snapshot_id": sid,
|
|
1107
|
+
"edges": _domain_edges_dto(conn, sid, rows),
|
|
1108
|
+
"total": total, "limit": limit, "offset": offset,
|
|
1109
|
+
"truncated": offset + len(rows) < total}
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _domain_entity_neighbors(server: _AtlasHttpd, conn: Any,
|
|
1113
|
+
entity_id: str, p: dict[str, str],
|
|
1114
|
+
) -> dict[str, Any]:
|
|
1115
|
+
"""Ego de uma entidade em ``domain_relations`` (por ocorrências)."""
|
|
1116
|
+
try:
|
|
1117
|
+
eid = int(entity_id)
|
|
1118
|
+
except ValueError as exc:
|
|
1119
|
+
raise ApiError("bad-param", "id de entidade inválido") from exc
|
|
1120
|
+
ent = conn.execute(
|
|
1121
|
+
"SELECT id, repository_id, domain, kind, identity_key, "
|
|
1122
|
+
"qualified_name, name, catalog, namespace, environment, "
|
|
1123
|
+
"attributes FROM domain_entities WHERE id = ?",
|
|
1124
|
+
(eid,)).fetchone()
|
|
1125
|
+
if ent is None:
|
|
1126
|
+
raise ApiError("domain-entity-not-found", "entidade inexistente", 404)
|
|
1127
|
+
rid = _repo(conn, p)
|
|
1128
|
+
if rid != int(ent["repository_id"]):
|
|
1129
|
+
raise ApiError("domain-entity-not-found",
|
|
1130
|
+
"entidade fora do repositório ativo", 404)
|
|
1131
|
+
sid = _snapshot(conn, rid, p)
|
|
1132
|
+
direction = p.get("direction", "both")
|
|
1133
|
+
if direction not in ("in", "out", "both"):
|
|
1134
|
+
raise ApiError("bad-param", "direction deve ser in|out|both")
|
|
1135
|
+
limit = _int(p, "limit", 100)
|
|
1136
|
+
occ_ids = Store(conn).domain_occurrences_for(sid, eid)
|
|
1137
|
+
edges: list[Any] = []
|
|
1138
|
+
if occ_ids:
|
|
1139
|
+
marks = _in_clause([str(o) for o in occ_ids])
|
|
1140
|
+
conds: list[str] = []
|
|
1141
|
+
args: list[Any] = [sid]
|
|
1142
|
+
if direction in ("out", "both"):
|
|
1143
|
+
conds.append(f"from_entity_occ_id IN ({marks})")
|
|
1144
|
+
args += occ_ids
|
|
1145
|
+
if direction in ("in", "both"):
|
|
1146
|
+
conds.append(f"to_entity_occ_id IN ({marks})")
|
|
1147
|
+
args += occ_ids
|
|
1148
|
+
ego_sql = (
|
|
1149
|
+
_DOMAIN_EDGE_SELECT +
|
|
1150
|
+
f"WHERE snapshot_id = ? AND ({' OR '.join(conds)}) "
|
|
1151
|
+
"ORDER BY id LIMIT ?")
|
|
1152
|
+
edges = conn.execute(ego_sql, (*args, limit)).fetchall()
|
|
1153
|
+
entity_dto = {
|
|
1154
|
+
"id": eid, "domain": ent["domain"], "kind": ent["kind"],
|
|
1155
|
+
"qualified_name": ent["qualified_name"], "name": ent["name"],
|
|
1156
|
+
"catalog": ent["catalog"], "namespace": ent["namespace"],
|
|
1157
|
+
"environment": ent["environment"],
|
|
1158
|
+
"attributes": json.loads(ent["attributes"] or "{}"),
|
|
1159
|
+
"occurrences": len(occ_ids),
|
|
1160
|
+
}
|
|
1161
|
+
return {"repository_id": rid, "snapshot_id": sid,
|
|
1162
|
+
"entity": entity_dto,
|
|
1163
|
+
"edges": _domain_edges_dto(conn, sid, edges),
|
|
1164
|
+
"truncated": len(edges) == limit}
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _lineage_root(conn: Any, rid: int, table: str) -> Any:
|
|
1168
|
+
"""Resolve ``?table=`` — id numérico ou qualified_name/name exato."""
|
|
1169
|
+
if table.isdigit():
|
|
1170
|
+
row = conn.execute(
|
|
1171
|
+
"SELECT * FROM domain_entities WHERE id = ? AND repository_id = ?",
|
|
1172
|
+
(int(table), rid)).fetchone()
|
|
1173
|
+
if row is None:
|
|
1174
|
+
raise ApiError("lineage-target-not-found",
|
|
1175
|
+
f"entidade {table} inexistente no repositório", 404)
|
|
1176
|
+
return row
|
|
1177
|
+
root_sql = (
|
|
1178
|
+
"SELECT * FROM domain_entities WHERE repository_id = ? "
|
|
1179
|
+
f"AND kind IN ({_in_clause(list(_LINEAGE_KINDS))}) "
|
|
1180
|
+
"AND (qualified_name = ? OR name = ?) ORDER BY id")
|
|
1181
|
+
rows = conn.execute(
|
|
1182
|
+
root_sql, (rid, *_LINEAGE_KINDS, table, table)).fetchall()
|
|
1183
|
+
if not rows:
|
|
1184
|
+
raise ApiError(
|
|
1185
|
+
"lineage-target-not-found",
|
|
1186
|
+
f"tabela {table!r} não encontrada — use o id da entidade "
|
|
1187
|
+
"ou o qualified_name exato", 404)
|
|
1188
|
+
if len(rows) > 1:
|
|
1189
|
+
cands = ", ".join(
|
|
1190
|
+
f"#{r['id']} {r['qualified_name']}" for r in rows[:5])
|
|
1191
|
+
raise ApiError(
|
|
1192
|
+
"lineage-target-ambiguous",
|
|
1193
|
+
f"{table!r} é ambíguo ({len(rows)} entidades: {cands}) — "
|
|
1194
|
+
"refaça com o id da entidade", 409)
|
|
1195
|
+
return rows[0]
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _lineage_step(
|
|
1199
|
+
conn: Any, sid: int, ent_occs: list[int], sym_occs: list[int],
|
|
1200
|
+
upstream: bool,
|
|
1201
|
+
) -> list[Any]:
|
|
1202
|
+
"""Um passo de BFS — regras de direção por tipo de aresta.
|
|
1203
|
+
|
|
1204
|
+
upstream (contra o fluxo): ``N READS_FROM X``/``N DERIVES_FROM X``
|
|
1205
|
+
→ X; ``tr WRITES_TO N``/``tr TRANSFORMS N`` → tr. Downstream é o
|
|
1206
|
+
espelho exato. Nada além do vocabulário de dados é atravessado.
|
|
1207
|
+
"""
|
|
1208
|
+
if not ent_occs and not sym_occs:
|
|
1209
|
+
return []
|
|
1210
|
+
e_json = json.dumps(ent_occs)
|
|
1211
|
+
s_json = json.dumps(sym_occs)
|
|
1212
|
+
if upstream:
|
|
1213
|
+
cond = ("(type IN ('WRITES_TO','TRANSFORMS') AND ("
|
|
1214
|
+
"to_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
|
|
1215
|
+
"to_symbol_occ_id IN (SELECT value FROM json_each(?)))) "
|
|
1216
|
+
"OR (type IN ('READS_FROM','DERIVES_FROM') AND ("
|
|
1217
|
+
"from_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
|
|
1218
|
+
"from_symbol_occ_id IN (SELECT value FROM json_each(?))))")
|
|
1219
|
+
else:
|
|
1220
|
+
cond = ("(type IN ('WRITES_TO','TRANSFORMS') AND ("
|
|
1221
|
+
"from_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
|
|
1222
|
+
"from_symbol_occ_id IN (SELECT value FROM json_each(?)))) "
|
|
1223
|
+
"OR (type IN ('READS_FROM','DERIVES_FROM') AND ("
|
|
1224
|
+
"to_entity_occ_id IN (SELECT value FROM json_each(?)) OR "
|
|
1225
|
+
"to_symbol_occ_id IN (SELECT value FROM json_each(?))))")
|
|
1226
|
+
step_sql = (
|
|
1227
|
+
_DOMAIN_EDGE_SELECT +
|
|
1228
|
+
f"WHERE snapshot_id = ? AND ({cond}) ORDER BY id")
|
|
1229
|
+
rows: list[Any] = conn.execute(
|
|
1230
|
+
step_sql, (sid, e_json, s_json, e_json, s_json)).fetchall()
|
|
1231
|
+
return rows
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _lineage_neighbor(row: Any, upstream: bool) -> tuple[str, Any, str]:
|
|
1235
|
+
"""Endpoint vizinho de um passo — ``(ref, id, side)``.
|
|
1236
|
+
|
|
1237
|
+
``side`` diz qual endpoint da aresta é o vizinho: 'from' quando a
|
|
1238
|
+
fronteira tocou o ``to``, 'to' quando tocou o ``from``.
|
|
1239
|
+
"""
|
|
1240
|
+
if upstream:
|
|
1241
|
+
if row["type"] in _LINEAGE_WRITE:
|
|
1242
|
+
return ("entity", row["from_entity_occ_id"], "from") \
|
|
1243
|
+
if row["from_entity_occ_id"] is not None else (
|
|
1244
|
+
"symbol", row["from_symbol_occ_id"], "from")
|
|
1245
|
+
return ("entity", row["to_entity_occ_id"], "to") \
|
|
1246
|
+
if row["to_entity_occ_id"] is not None else (
|
|
1247
|
+
"symbol", row["to_symbol_occ_id"], "to")
|
|
1248
|
+
if row["type"] in _LINEAGE_WRITE:
|
|
1249
|
+
return ("entity", row["to_entity_occ_id"], "to") \
|
|
1250
|
+
if row["to_entity_occ_id"] is not None else (
|
|
1251
|
+
"symbol", row["to_symbol_occ_id"], "to")
|
|
1252
|
+
return ("entity", row["from_entity_occ_id"], "from") \
|
|
1253
|
+
if row["from_entity_occ_id"] is not None else (
|
|
1254
|
+
"symbol", row["from_symbol_occ_id"], "from")
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _lineage_absorb(
|
|
1258
|
+
conn: Any, store: Store, sid: int, row: Any, upstream: bool,
|
|
1259
|
+
dist: int, lane: str,
|
|
1260
|
+
nodes: dict[str, dict[str, Any]], visited: set[str],
|
|
1261
|
+
queue: list[tuple[str, list[int], list[int], int, str]],
|
|
1262
|
+
) -> bool:
|
|
1263
|
+
"""Absorve o vizinho de uma aresta — False quando o cap de nós bate."""
|
|
1264
|
+
kind, nid, _side = _lineage_neighbor(row, upstream)
|
|
1265
|
+
if nid is None:
|
|
1266
|
+
# alvo textual não resolvido — entra como nó honesto, nunca expande
|
|
1267
|
+
nref = f"unresolved:{row['id']}"
|
|
1268
|
+
nodes.setdefault(nref, {
|
|
1269
|
+
"ref": nref, "endpoint": "unresolved",
|
|
1270
|
+
"label": str(row["to_unresolved"] or ""),
|
|
1271
|
+
"distance": dist + 1, "lane": lane})
|
|
1272
|
+
return True
|
|
1273
|
+
eid: int | None = None
|
|
1274
|
+
if kind == "entity":
|
|
1275
|
+
# dedup por ENTIDADE — uma entidade pode ter várias ocorrências;
|
|
1276
|
+
# o ref público é ``de:{entity_id}``.
|
|
1277
|
+
erow = conn.execute(
|
|
1278
|
+
"SELECT entity_id FROM domain_entity_occurrences WHERE id = ?",
|
|
1279
|
+
(nid,)).fetchone()
|
|
1280
|
+
if erow is None:
|
|
1281
|
+
return True # endpoint fora do snapshot — aresta já consta
|
|
1282
|
+
eid = int(erow["entity_id"])
|
|
1283
|
+
nref = f"de:{eid}"
|
|
1284
|
+
else:
|
|
1285
|
+
nref = f"occ:{nid}"
|
|
1286
|
+
if nref in visited:
|
|
1287
|
+
return True
|
|
1288
|
+
visited.add(nref)
|
|
1289
|
+
if len(visited) > _LINEAGE_MAX_NODES:
|
|
1290
|
+
return False
|
|
1291
|
+
nodes[nref] = {"ref": nref, "occurrence_id": int(nid),
|
|
1292
|
+
"entity_id": eid, "distance": dist + 1, "lane": lane}
|
|
1293
|
+
if kind == "entity" and eid is not None:
|
|
1294
|
+
queue.append((nref, store.domain_occurrences_for(sid, eid), [],
|
|
1295
|
+
dist + 1, lane))
|
|
1296
|
+
else:
|
|
1297
|
+
queue.append((nref, [], [int(nid)], dist + 1, lane))
|
|
1298
|
+
return True
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _lineage_bfs(
|
|
1302
|
+
conn: Any, sid: int, root: Any, direction: str, depth: int,
|
|
1303
|
+
limit: int,
|
|
1304
|
+
) -> tuple[list[Any], dict[str, dict[str, Any]], bool]:
|
|
1305
|
+
"""BFS bounded — retorna ``(edge_rows, nodes, truncated)``."""
|
|
1306
|
+
store = Store(conn)
|
|
1307
|
+
root_ref = f"de:{root['id']}"
|
|
1308
|
+
edges_by_id: dict[int, Any] = {}
|
|
1309
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
1310
|
+
visited: set[str] = {root_ref}
|
|
1311
|
+
truncated = False
|
|
1312
|
+
queue: list[tuple[str, list[int], list[int], int, str]] = [
|
|
1313
|
+
(root_ref, store.domain_occurrences_for(sid, int(root["id"])),
|
|
1314
|
+
[], 0, "root"),
|
|
1315
|
+
]
|
|
1316
|
+
while queue and not truncated:
|
|
1317
|
+
_ref, ent_occs, sym_occs, dist, _lane = queue.pop(0)
|
|
1318
|
+
if dist >= depth:
|
|
1319
|
+
continue
|
|
1320
|
+
for upstream, lane in ((True, "upstream"), (False, "downstream")):
|
|
1321
|
+
if direction != "both" and direction != lane:
|
|
1322
|
+
continue
|
|
1323
|
+
for row in _lineage_step(
|
|
1324
|
+
conn, sid, ent_occs, sym_occs, upstream):
|
|
1325
|
+
if len(edges_by_id) >= limit:
|
|
1326
|
+
truncated = True
|
|
1327
|
+
break
|
|
1328
|
+
edges_by_id.setdefault(int(row["id"]), row)
|
|
1329
|
+
if not _lineage_absorb(conn, store, sid, row, upstream,
|
|
1330
|
+
dist, lane, nodes, visited, queue):
|
|
1331
|
+
truncated = True
|
|
1332
|
+
break
|
|
1333
|
+
if truncated:
|
|
1334
|
+
break
|
|
1335
|
+
return ([edges_by_id[k] for k in sorted(edges_by_id)],
|
|
1336
|
+
nodes, truncated)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _domain_lineage(server: _AtlasHttpd, conn: Any, p: dict[str, str],
|
|
1340
|
+
_b: dict[str, Any] | None) -> dict[str, Any]:
|
|
1341
|
+
"""Linhagem bounded: BFS upstream/downstream sobre arestas de dados."""
|
|
1342
|
+
rid = _repo(conn, p)
|
|
1343
|
+
sid = _snapshot(conn, rid, p)
|
|
1344
|
+
table = p.get("table", "").strip()
|
|
1345
|
+
if not table or len(table) > 300:
|
|
1346
|
+
raise ApiError("bad-param", "table obrigatório (≤300 chars)")
|
|
1347
|
+
direction = p.get("direction", "both")
|
|
1348
|
+
if direction not in ("upstream", "downstream", "both"):
|
|
1349
|
+
raise ApiError("bad-param",
|
|
1350
|
+
"direction deve ser upstream|downstream|both")
|
|
1351
|
+
depth = _int(p, "depth", 4, cap=8)
|
|
1352
|
+
limit = _int(p, "limit", 200, cap=500) or 200
|
|
1353
|
+
|
|
1354
|
+
root = _lineage_root(conn, rid, table)
|
|
1355
|
+
root_ref = f"de:{root['id']}"
|
|
1356
|
+
edge_rows, nodes, truncated = _lineage_bfs(
|
|
1357
|
+
conn, sid, root, direction, depth, limit)
|
|
1358
|
+
edge_dtos = _domain_edges_dto(conn, sid, edge_rows)
|
|
1359
|
+
|
|
1360
|
+
# enriquece nós entity/symbol com os descritores já resolvidos
|
|
1361
|
+
endpoint_by_ref: dict[str, dict[str, Any]] = {}
|
|
1362
|
+
for dto in edge_dtos:
|
|
1363
|
+
for side in ("from", "to"):
|
|
1364
|
+
ep = dto[side]
|
|
1365
|
+
if ep["endpoint"] == "entity" and ep.get("entity_id") is not None:
|
|
1366
|
+
endpoint_by_ref.setdefault(f"de:{ep['entity_id']}", ep)
|
|
1367
|
+
elif (ep["endpoint"] == "symbol"
|
|
1368
|
+
and ep.get("occurrence_id") is not None):
|
|
1369
|
+
endpoint_by_ref.setdefault(f"occ:{ep['occurrence_id']}", ep)
|
|
1370
|
+
out_nodes: list[dict[str, Any]] = []
|
|
1371
|
+
for nref, meta in nodes.items():
|
|
1372
|
+
merged = dict(endpoint_by_ref.get(nref, {}))
|
|
1373
|
+
merged.update({k: v for k, v in meta.items() if k not in merged
|
|
1374
|
+
or merged.get(k) is None})
|
|
1375
|
+
merged["distance"] = meta["distance"]
|
|
1376
|
+
merged["lane"] = meta["lane"]
|
|
1377
|
+
out_nodes.append(merged)
|
|
1378
|
+
lane_rank = {"root": 0, "upstream": 1, "downstream": 2}
|
|
1379
|
+
out_nodes.sort(key=lambda n: (
|
|
1380
|
+
lane_rank.get(str(n.get("lane")), 9), int(n.get("distance", 0)),
|
|
1381
|
+
str(n.get("qualified_name") or n.get("label") or n.get("ref"))))
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
"repository_id": rid, "snapshot_id": sid,
|
|
1385
|
+
"root": {
|
|
1386
|
+
"ref": root_ref, "endpoint": "entity",
|
|
1387
|
+
"entity_id": int(root["id"]), "domain": root["domain"],
|
|
1388
|
+
"entity_kind": root["kind"],
|
|
1389
|
+
"qualified_name": root["qualified_name"],
|
|
1390
|
+
"name": root["name"], "distance": 0, "lane": "root",
|
|
1391
|
+
},
|
|
1392
|
+
"direction": direction, "depth": depth,
|
|
1393
|
+
"nodes": out_nodes, "edges": edge_dtos,
|
|
1394
|
+
"truncated": truncated,
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
_NO_MATCH = object() # sentinela: rota não é do grupo parametrizado
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _route_get_detail(server: _AtlasHttpd, conn: Any, route: str,
|
|
1402
|
+
p: dict[str, str], body: Any) -> Any:
|
|
1403
|
+
"""Despacha rotas GET ``/<recurso>/<id>`` — ``_NO_MATCH`` fora do escopo."""
|
|
1404
|
+
m = re.fullmatch(r"/repositories/(\d+)/snapshots", route)
|
|
1405
|
+
if m:
|
|
1406
|
+
return _snapshots(server, conn, m.group(1), body)
|
|
1407
|
+
m = re.fullmatch(r"/symbols/(\d+)", route)
|
|
1408
|
+
if m:
|
|
1409
|
+
return _symbol(server, conn, m.group(1), p)
|
|
1410
|
+
m = re.fullmatch(r"/evidence/(\d+)", route)
|
|
1411
|
+
if m:
|
|
1412
|
+
return _evidence(server, conn, m.group(1), p)
|
|
1413
|
+
m = re.fullmatch(r"/files/(\d+)/occurrences", route)
|
|
1414
|
+
if m:
|
|
1415
|
+
return _file_occurrences(server, conn, m.group(1), p)
|
|
1416
|
+
return _NO_MATCH
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _route_domains(server: _AtlasHttpd, conn: Any, route: str,
|
|
1420
|
+
p: dict[str, str]) -> Any:
|
|
1421
|
+
"""Despacha rotas GET de domínio — ``_NO_MATCH`` fora do escopo."""
|
|
1422
|
+
m = re.fullmatch(r"/domains/entities/(\d+)/neighbors", route)
|
|
1423
|
+
if m:
|
|
1424
|
+
return _domain_entity_neighbors(server, conn, m.group(1), p)
|
|
1425
|
+
return _NO_MATCH
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _route_federation(server: _AtlasHttpd, conn: Any, route: str,
|
|
1429
|
+
p: dict[str, str]) -> Any:
|
|
1430
|
+
"""Despacha rotas GET de federação — ``_NO_MATCH`` fora do escopo."""
|
|
1431
|
+
if route == "/federation/workspaces":
|
|
1432
|
+
return _fed_workspaces(server, conn, p, None)
|
|
1433
|
+
m = re.fullmatch(r"/federation/workspaces/(\d+)/view", route)
|
|
1434
|
+
if m:
|
|
1435
|
+
return _fed_view(server, conn, m.group(1), p)
|
|
1436
|
+
m = re.fullmatch(
|
|
1437
|
+
r"/federation/workspaces/(\d+)/entities/([^/]+)/occurrences",
|
|
1438
|
+
route)
|
|
1439
|
+
if m:
|
|
1440
|
+
return _fed_entity_occurrences(
|
|
1441
|
+
server, conn, m.group(1), m.group(2), p)
|
|
1442
|
+
m = re.fullmatch(r"/federation/workspaces/(\d+)/graph", route)
|
|
1443
|
+
if m:
|
|
1444
|
+
return _fed_graph(server, conn, m.group(1), p)
|
|
1445
|
+
m = re.fullmatch(r"/federation/workspaces/(\d+)/search", route)
|
|
1446
|
+
if m:
|
|
1447
|
+
return _fed_search(server, conn, m.group(1), p)
|
|
1448
|
+
m = re.fullmatch(r"/federation/workspaces/(\d+)", route)
|
|
1449
|
+
if m:
|
|
1450
|
+
return _fed_workspace(server, conn, m.group(1), p)
|
|
1451
|
+
return _NO_MATCH
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def handle(server: _AtlasHttpd, method: str, route: str,
|
|
1455
|
+
handler: BaseHTTPRequestHandler) -> Any:
|
|
1456
|
+
conn = server.connect()
|
|
1457
|
+
t0 = time.monotonic()
|
|
1458
|
+
try:
|
|
1459
|
+
p = _qs(handler)
|
|
1460
|
+
body = server_body(handler) if method == "POST" else None
|
|
1461
|
+
if method == "GET":
|
|
1462
|
+
detail = _route_get_detail(server, conn, route, p, body)
|
|
1463
|
+
if detail is not _NO_MATCH:
|
|
1464
|
+
return detail
|
|
1465
|
+
# federação (lf-0118) — rotas GET read-only
|
|
1466
|
+
fed_result = _route_federation(server, conn, route, p)
|
|
1467
|
+
if fed_result is not _NO_MATCH:
|
|
1468
|
+
return fed_result
|
|
1469
|
+
# domínios (lf-0122) — rotas GET read-only
|
|
1470
|
+
dom_result = _route_domains(server, conn, route, p)
|
|
1471
|
+
if dom_result is not _NO_MATCH:
|
|
1472
|
+
return dom_result
|
|
1473
|
+
simple = {
|
|
1474
|
+
"/health": _health, "/capabilities": _capabilities,
|
|
1475
|
+
"/diagnostics": _diagnostics,
|
|
1476
|
+
"/repositories": _repositories,
|
|
1477
|
+
"/graph/overview": _graph_overview,
|
|
1478
|
+
"/graph/neighbors": _graph_neighbors,
|
|
1479
|
+
"/search": _search, "/index/status": _index_status,
|
|
1480
|
+
"/domains": _domains,
|
|
1481
|
+
"/domains/entities": _domain_entities,
|
|
1482
|
+
"/domains/relations": _domain_relations,
|
|
1483
|
+
"/domains/lineage": _domain_lineage,
|
|
1484
|
+
}
|
|
1485
|
+
fn = simple.get(route)
|
|
1486
|
+
if fn:
|
|
1487
|
+
return fn(server, conn, p, body)
|
|
1488
|
+
elif method == "POST":
|
|
1489
|
+
if route == "/context":
|
|
1490
|
+
return _context(server, conn, p, body)
|
|
1491
|
+
if route == "/impact/analyze":
|
|
1492
|
+
return _impact(server, conn, p, body)
|
|
1493
|
+
raise ApiError("not-found", f"{method} {route} inexistente", 404)
|
|
1494
|
+
finally:
|
|
1495
|
+
conn.close()
|
|
1496
|
+
# lf-0141: slow-query guard — o request inteiro é "a query" para
|
|
1497
|
+
# o operador; acima de CODE_ATLAS_SLOW_MS vira linha DEBUG com a
|
|
1498
|
+
# rota crua (query string passa pelo RedactionFilter do logger).
|
|
1499
|
+
log_if_slow(_LOG, "request", t0,
|
|
1500
|
+
f"{method} {getattr(handler, 'path', route)}")
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def server_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
|
|
1504
|
+
from code_atlas.api.server import _content_length
|
|
1505
|
+
length = _content_length(handler.headers.get("Content-Length"))
|
|
1506
|
+
import json
|
|
1507
|
+
raw = handler.rfile.read(length) if length else b"{}"
|
|
1508
|
+
try:
|
|
1509
|
+
data = json.loads(raw)
|
|
1510
|
+
except json.JSONDecodeError as exc:
|
|
1511
|
+
raise ApiError("bad-json", "corpo não é JSON válido") from exc
|
|
1512
|
+
if not isinstance(data, dict):
|
|
1513
|
+
raise ApiError("bad-json", "corpo deve ser objeto JSON")
|
|
1514
|
+
return data
|