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,739 @@
|
|
|
1
|
+
"""Superfície read-only de consulta de domínio (lf-0123, Cycle 7 Phase 15).
|
|
2
|
+
|
|
3
|
+
Consultas determinísticas sobre ``domain_entities`` /
|
|
4
|
+
``domain_entity_occurrences`` / ``domain_relations`` — camada
|
|
5
|
+
compartilhada entre ``cli.py`` e ``mcp.py`` para que os dois
|
|
6
|
+
transportes respondam a mesma verdade.
|
|
7
|
+
|
|
8
|
+
Regras inegociáveis (spec Cycle 7):
|
|
9
|
+
|
|
10
|
+
- **somente leitura**: nenhuma escrita, nenhum adapter é executado,
|
|
11
|
+
nenhum artefato é relido — só o estado persistido do snapshot;
|
|
12
|
+
- **snapshot-scoped**: toda resposta ancora num snapshot resolvido pelo
|
|
13
|
+
caller (isolamento entre repositórios preservado);
|
|
14
|
+
- **honestidade de ausência**: adapters são opt-in — zero rows de
|
|
15
|
+
domínio é resposta vazia com nota, nunca erro;
|
|
16
|
+
- **bounded**: todo listing/traversal tem ``limit``/``depth`` com teto;
|
|
17
|
+
- **determinístico**: ordenação por colunas estáveis — mesma pergunta,
|
|
18
|
+
mesma resposta.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sqlite3
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from code_atlas.domains.contracts import DomainAdapter
|
|
30
|
+
from code_atlas.domains.registry import DomainRegistry
|
|
31
|
+
|
|
32
|
+
# Teto de profundidade do traversal de linhagem — mesmo limite duro do
|
|
33
|
+
# BFS de ``dependencies`` (lf-0022): bounded por construção.
|
|
34
|
+
MAX_LINEAGE_DEPTH = 8
|
|
35
|
+
|
|
36
|
+
# Relações que carregam fluxo de dados (leitura/escrita/derivação) —
|
|
37
|
+
# a linhagem nunca percorre DEPENDS_ON/REFERENCES/CONTAINS etc.
|
|
38
|
+
DATA_FLOW_TYPES: frozenset[str] = frozenset(
|
|
39
|
+
{"READS_FROM", "WRITES_TO", "TRANSFORMS", "DERIVES_FROM"}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Tipos cujo endpoint ``to`` é o produtor (fluxo ``to → from``).
|
|
43
|
+
_UPSTREAM_TARGET_TYPES: frozenset[str] = frozenset(
|
|
44
|
+
{"READS_FROM", "DERIVES_FROM"}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
NOTE_OPT_IN = (
|
|
48
|
+
"domain adapters are opt-in: no domain rows means extraction was "
|
|
49
|
+
"not enabled for this index (see `code-atlas index --domains`) — "
|
|
50
|
+
"absence is not an error"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _display_name(row: sqlite3.Row | dict[str, Any]) -> str:
|
|
55
|
+
"""``namespace.qualified_name`` quando há namespace — forma humana."""
|
|
56
|
+
qname = str(row["qualified_name"])
|
|
57
|
+
ns = str(row["namespace"] or "")
|
|
58
|
+
return f"{ns}.{qname}" if ns else qname
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- registro built-in -------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def builtin_adapter_factories(
|
|
65
|
+
root: Path | None = None,
|
|
66
|
+
) -> dict[str, Callable[[], DomainAdapter]]:
|
|
67
|
+
"""``domain_id → fábrica nullary`` dos adapters built-in.
|
|
68
|
+
|
|
69
|
+
``root`` vincula o adapter ao workspace indexado (``base_path`` /
|
|
70
|
+
``workspace_root``). Para listagem (``domains list``) qualquer path
|
|
71
|
+
serve — os adapters só leem artefatos dentro de ``extract`` e a
|
|
72
|
+
listagem nunca extrai.
|
|
73
|
+
"""
|
|
74
|
+
from code_atlas.domains.api import ApiContractAdapter
|
|
75
|
+
from code_atlas.domains.aws import AwsDataAdapter
|
|
76
|
+
from code_atlas.domains.cicd import GitHubActionsAdapter
|
|
77
|
+
from code_atlas.domains.spark import PySparkAdapter
|
|
78
|
+
from code_atlas.domains.sql import SqlDomainAdapter
|
|
79
|
+
from code_atlas.domains.terraform import TerraformAdapter
|
|
80
|
+
|
|
81
|
+
base = root if root is not None else Path(".")
|
|
82
|
+
return {
|
|
83
|
+
"api": lambda: ApiContractAdapter(workspace_root=base),
|
|
84
|
+
"aws-data": lambda: AwsDataAdapter(base_path=base),
|
|
85
|
+
"cicd": lambda: GitHubActionsAdapter.for_workspace(base),
|
|
86
|
+
"pyspark": lambda: PySparkAdapter(base_path=base),
|
|
87
|
+
"sql": lambda: SqlDomainAdapter(base_path=base),
|
|
88
|
+
"terraform": lambda: TerraformAdapter(base_path=base),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def builtin_registry(root: Path | None = None) -> DomainRegistry:
|
|
93
|
+
"""Registry com todos os adapters built-in habilitados."""
|
|
94
|
+
reg = DomainRegistry()
|
|
95
|
+
for name in sorted(builtin_adapter_factories(root)):
|
|
96
|
+
reg.register(builtin_adapter_factories(root)[name]())
|
|
97
|
+
return reg
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def select_domain_registry(
|
|
101
|
+
spec: str, root: Path | None = None
|
|
102
|
+
) -> tuple[DomainRegistry | None, str]:
|
|
103
|
+
"""``--domains sql,pyspark``/``all`` → registry ou erro tipado.
|
|
104
|
+
|
|
105
|
+
Ids desconhecidos são recusados (nunca flag que silenciosamente não
|
|
106
|
+
faz nada — mesma disciplina de ``--resolver``). ``all`` expande o
|
|
107
|
+
conjunto registrado; combinar ``all`` com ids explícitos é erro.
|
|
108
|
+
"""
|
|
109
|
+
factories = builtin_adapter_factories(root)
|
|
110
|
+
names = [n.strip() for n in spec.split(",") if n.strip()]
|
|
111
|
+
if not names:
|
|
112
|
+
return None, "--domains: empty selection"
|
|
113
|
+
if "all" in names:
|
|
114
|
+
if len(names) > 1:
|
|
115
|
+
return None, "--domains: 'all' cannot be combined with ids"
|
|
116
|
+
names = sorted(factories)
|
|
117
|
+
unknown = [n for n in names if n not in factories]
|
|
118
|
+
if unknown:
|
|
119
|
+
return None, (
|
|
120
|
+
f"--domains: unknown domain(s) "
|
|
121
|
+
f"{', '.join(sorted(set(unknown)))} "
|
|
122
|
+
f"(registered: {', '.join(sorted(factories))})"
|
|
123
|
+
)
|
|
124
|
+
reg = DomainRegistry()
|
|
125
|
+
for n in dict.fromkeys(names): # dedup preservando ordem
|
|
126
|
+
reg.register(factories[n]())
|
|
127
|
+
return reg, ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def adapters_report(reg: DomainRegistry | None = None) -> list[dict[str, Any]]:
|
|
131
|
+
"""Inventário dos adapters registrados (``domains list``/``atlas_domains``)."""
|
|
132
|
+
reg = reg or builtin_registry()
|
|
133
|
+
out: list[dict[str, Any]] = []
|
|
134
|
+
for a in sorted(
|
|
135
|
+
reg.adapters(), key=lambda x: (x.domain_id, x.adapter_version)
|
|
136
|
+
):
|
|
137
|
+
sup = a.supported_artifacts()
|
|
138
|
+
cm = a.confidence_model()
|
|
139
|
+
out.append(
|
|
140
|
+
{
|
|
141
|
+
"domain_id": a.domain_id,
|
|
142
|
+
"adapter_version": a.adapter_version,
|
|
143
|
+
"enabled": reg.is_enabled(a.domain_id),
|
|
144
|
+
"versions": list(reg.versions(a.domain_id)),
|
|
145
|
+
"artifact_support": {
|
|
146
|
+
"extensions": sorted(sup.extensions),
|
|
147
|
+
"globs": list(sup.globs),
|
|
148
|
+
},
|
|
149
|
+
"entity_kinds": sorted(str(k) for k in a.entity_kinds()),
|
|
150
|
+
"relation_kinds": sorted(a.relation_kinds()),
|
|
151
|
+
"confidence": {
|
|
152
|
+
"ceiling": str(cm.ceiling),
|
|
153
|
+
"default": str(cm.default),
|
|
154
|
+
"allows_inferred": cm.allows_inferred,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- resolução de repositório/entidade ---------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def repo_for_ident(conn: sqlite3.Connection, ident: str) -> tuple[int | None, str]:
|
|
165
|
+
"""Deriva ``repository_id`` da própria referência de entidade.
|
|
166
|
+
|
|
167
|
+
- id numérico → ``repository_id`` da row;
|
|
168
|
+
- chave ``dom:…`` → segmento ``repository`` decodificado →
|
|
169
|
+
``repositories.identity_key``;
|
|
170
|
+
- nome nu → único repo que contém a entidade; múltiplos → erro de
|
|
171
|
+
ambiguidade honesto (o caller pede ``--repo``).
|
|
172
|
+
|
|
173
|
+
``(None, "")`` = não derivável — o caller cai no ``--root`` default.
|
|
174
|
+
"""
|
|
175
|
+
ident = ident.strip()
|
|
176
|
+
if not ident:
|
|
177
|
+
return None, ""
|
|
178
|
+
if ident.isdigit():
|
|
179
|
+
row = conn.execute(
|
|
180
|
+
"SELECT repository_id FROM domain_entities WHERE id = ?",
|
|
181
|
+
(int(ident),),
|
|
182
|
+
).fetchone()
|
|
183
|
+
return (int(row["repository_id"]) if row else None), ""
|
|
184
|
+
if ident.startswith("dom:"):
|
|
185
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
186
|
+
|
|
187
|
+
segs = DomainIdentity.parse_key(ident)
|
|
188
|
+
if segs is not None and len(segs) >= 2:
|
|
189
|
+
row = conn.execute(
|
|
190
|
+
"SELECT id FROM repositories WHERE identity_key = ?",
|
|
191
|
+
(segs[1],),
|
|
192
|
+
).fetchone()
|
|
193
|
+
if row is not None:
|
|
194
|
+
return int(row["id"]), ""
|
|
195
|
+
return None, ""
|
|
196
|
+
rows = conn.execute(
|
|
197
|
+
"SELECT DISTINCT repository_id FROM domain_entities "
|
|
198
|
+
"WHERE qualified_name = ? OR name = ? "
|
|
199
|
+
"OR (namespace || '.' || qualified_name) = ? ORDER BY repository_id",
|
|
200
|
+
(ident, ident, ident),
|
|
201
|
+
).fetchall()
|
|
202
|
+
if len(rows) == 1:
|
|
203
|
+
return int(rows[0]["repository_id"]), ""
|
|
204
|
+
if len(rows) > 1:
|
|
205
|
+
repos = ", ".join(str(int(r["repository_id"])) for r in rows)
|
|
206
|
+
return None, (
|
|
207
|
+
f"entity ref {ident!r} exists in repositories {repos} — "
|
|
208
|
+
"pass --repo to disambiguate"
|
|
209
|
+
)
|
|
210
|
+
return None, ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def find_entities(
|
|
214
|
+
conn: sqlite3.Connection, repo_id: int | None, ident: str
|
|
215
|
+
) -> list[sqlite3.Row]:
|
|
216
|
+
"""Candidatos por id numérico, ``identity_key`` ou nome qualificado.
|
|
217
|
+
|
|
218
|
+
Nome aceita ``qualified_name``, ``name`` e a forma humana
|
|
219
|
+
``namespace.qualified_name`` — nunca substring solta (lookup por
|
|
220
|
+
substring fabricaria candidatos).
|
|
221
|
+
"""
|
|
222
|
+
ident = ident.strip()
|
|
223
|
+
conds: list[str] = []
|
|
224
|
+
params: list[Any] = []
|
|
225
|
+
if repo_id is not None:
|
|
226
|
+
conds.append("repository_id = ?")
|
|
227
|
+
params.append(repo_id)
|
|
228
|
+
if ident.isdigit():
|
|
229
|
+
conds.append("id = ?")
|
|
230
|
+
params.append(int(ident))
|
|
231
|
+
elif ident.startswith("dom:"):
|
|
232
|
+
conds.append("identity_key = ?")
|
|
233
|
+
params.append(ident)
|
|
234
|
+
else:
|
|
235
|
+
conds.append(
|
|
236
|
+
"(qualified_name = ? OR name = ? "
|
|
237
|
+
"OR (namespace || '.' || qualified_name) = ?)"
|
|
238
|
+
)
|
|
239
|
+
params.extend([ident, ident, ident])
|
|
240
|
+
where = " AND ".join(conds) if conds else "1=1"
|
|
241
|
+
entity_sql = (
|
|
242
|
+
"SELECT * FROM domain_entities WHERE " + where + " ORDER BY id"
|
|
243
|
+
)
|
|
244
|
+
return conn.execute(entity_sql, params).fetchall()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def resolve_single_entity(
|
|
248
|
+
conn: sqlite3.Connection, repo_id: int | None, ident: str
|
|
249
|
+
) -> tuple[sqlite3.Row | None, str]:
|
|
250
|
+
"""Exatamente um candidato — ausência e ambiguidade são honestas."""
|
|
251
|
+
rows = find_entities(conn, repo_id, ident)
|
|
252
|
+
if not rows:
|
|
253
|
+
scope = "" if repo_id is None else f" in repository {repo_id}"
|
|
254
|
+
return None, (
|
|
255
|
+
f"domain entity not found: {ident!r}{scope} — {NOTE_OPT_IN}"
|
|
256
|
+
)
|
|
257
|
+
if len(rows) > 1:
|
|
258
|
+
cand = ", ".join(
|
|
259
|
+
f"#{int(r['id'])} {_display_name(r)}"
|
|
260
|
+
f" (repo={int(r['repository_id'])})"
|
|
261
|
+
for r in rows[:10]
|
|
262
|
+
)
|
|
263
|
+
return None, f"ambiguous entity ref {ident!r}: {cand}"
|
|
264
|
+
return rows[0], ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --- listagem -----------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def list_entities(
|
|
271
|
+
conn: sqlite3.Connection,
|
|
272
|
+
repo_id: int,
|
|
273
|
+
snapshot_id: int,
|
|
274
|
+
*,
|
|
275
|
+
domain: str = "",
|
|
276
|
+
kind: str = "",
|
|
277
|
+
q: str = "",
|
|
278
|
+
limit: int = 50,
|
|
279
|
+
offset: int = 0,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""Entidades com ≥1 ocorrência no snapshot — listagem determinística."""
|
|
282
|
+
from code_atlas.search.structural import _escape_like
|
|
283
|
+
|
|
284
|
+
conds = ["deo.snapshot_id = ?", "de.repository_id = ?"]
|
|
285
|
+
params: list[Any] = [snapshot_id, repo_id]
|
|
286
|
+
if domain:
|
|
287
|
+
conds.append("de.domain = ?")
|
|
288
|
+
params.append(domain)
|
|
289
|
+
if kind:
|
|
290
|
+
conds.append("de.kind = ?")
|
|
291
|
+
params.append(kind)
|
|
292
|
+
if q:
|
|
293
|
+
like = f"%{_escape_like(q)}%"
|
|
294
|
+
conds.append(
|
|
295
|
+
"(de.qualified_name LIKE ? ESCAPE '\\' "
|
|
296
|
+
"OR de.name LIKE ? ESCAPE '\\' "
|
|
297
|
+
"OR (de.namespace || '.' || de.qualified_name) "
|
|
298
|
+
"LIKE ? ESCAPE '\\')"
|
|
299
|
+
)
|
|
300
|
+
params.extend([like, like, like])
|
|
301
|
+
where = " AND ".join(conds)
|
|
302
|
+
join = (
|
|
303
|
+
"FROM domain_entity_occurrences deo "
|
|
304
|
+
"JOIN domain_entities de ON de.id = deo.entity_id "
|
|
305
|
+
"LEFT JOIN source_files sf ON sf.id = deo.source_file_id "
|
|
306
|
+
f"WHERE {where}"
|
|
307
|
+
)
|
|
308
|
+
count_sql = "SELECT COUNT(DISTINCT de.id) AS c " + join
|
|
309
|
+
total = int(
|
|
310
|
+
conn.execute(count_sql, params).fetchone()["c"]
|
|
311
|
+
)
|
|
312
|
+
list_sql = (
|
|
313
|
+
"SELECT de.id, de.domain, de.kind, de.qualified_name, de.name,"
|
|
314
|
+
" de.catalog, de.namespace, de.environment, de.identity_key,"
|
|
315
|
+
" COUNT(deo.id) AS occ_count,"
|
|
316
|
+
" MIN(sf.relative_path) AS first_path "
|
|
317
|
+
+ join
|
|
318
|
+
+ " GROUP BY de.id"
|
|
319
|
+
" ORDER BY de.domain, de.kind, de.qualified_name, de.id"
|
|
320
|
+
" LIMIT ? OFFSET ?"
|
|
321
|
+
)
|
|
322
|
+
rows = conn.execute(
|
|
323
|
+
list_sql,
|
|
324
|
+
[*params, limit, offset],
|
|
325
|
+
).fetchall()
|
|
326
|
+
items = [
|
|
327
|
+
{
|
|
328
|
+
"entity_id": int(r["id"]),
|
|
329
|
+
"domain": r["domain"],
|
|
330
|
+
"kind": r["kind"],
|
|
331
|
+
"qualified_name": r["qualified_name"],
|
|
332
|
+
"display_name": _display_name(r),
|
|
333
|
+
"name": r["name"],
|
|
334
|
+
"catalog": r["catalog"],
|
|
335
|
+
"namespace": r["namespace"],
|
|
336
|
+
"environment": r["environment"],
|
|
337
|
+
"identity_key": r["identity_key"],
|
|
338
|
+
"occurrences": int(r["occ_count"]),
|
|
339
|
+
"path": r["first_path"],
|
|
340
|
+
}
|
|
341
|
+
for r in rows
|
|
342
|
+
]
|
|
343
|
+
return {
|
|
344
|
+
"total": total,
|
|
345
|
+
"truncated": (offset + len(items)) < total,
|
|
346
|
+
"items": items,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --- detalhe --------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def entity_detail(
|
|
354
|
+
conn: sqlite3.Connection, entity: sqlite3.Row, snapshot_id: int
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
"""Cartão da entidade: identidade, ocorrências, relações, evidência."""
|
|
357
|
+
entity_id = int(entity["id"])
|
|
358
|
+
occ_rows = conn.execute(
|
|
359
|
+
"SELECT deo.id, deo.line, deo.col, deo.extraction_status,"
|
|
360
|
+
" sf.relative_path AS path"
|
|
361
|
+
" FROM domain_entity_occurrences deo"
|
|
362
|
+
" LEFT JOIN source_files sf ON sf.id = deo.source_file_id"
|
|
363
|
+
" WHERE deo.entity_id = ? AND deo.snapshot_id = ?"
|
|
364
|
+
" ORDER BY deo.id",
|
|
365
|
+
(entity_id, snapshot_id),
|
|
366
|
+
).fetchall()
|
|
367
|
+
occ_ids = [int(r["id"]) for r in occ_rows]
|
|
368
|
+
occurrences = [
|
|
369
|
+
{
|
|
370
|
+
"occurrence_id": int(r["id"]),
|
|
371
|
+
"path": r["path"],
|
|
372
|
+
"line": r["line"],
|
|
373
|
+
"col": r["col"],
|
|
374
|
+
"extraction_status": r["extraction_status"],
|
|
375
|
+
}
|
|
376
|
+
for r in occ_rows
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
summary: dict[tuple[str, str, str], int] = {}
|
|
380
|
+
evidence_count = 0
|
|
381
|
+
if occ_ids:
|
|
382
|
+
ph = ",".join("?" for _ in occ_ids)
|
|
383
|
+
for direction, col in (
|
|
384
|
+
("out", "from_entity_occ_id"),
|
|
385
|
+
("in", "to_entity_occ_id"),
|
|
386
|
+
):
|
|
387
|
+
rel_sql = (
|
|
388
|
+
"SELECT type, resolution_status, COUNT(*) AS c"
|
|
389
|
+
" FROM domain_relations"
|
|
390
|
+
f" WHERE snapshot_id = ? AND {col} IN ({ph})"
|
|
391
|
+
" GROUP BY type, resolution_status"
|
|
392
|
+
" ORDER BY type, resolution_status"
|
|
393
|
+
)
|
|
394
|
+
rows = conn.execute(
|
|
395
|
+
rel_sql,
|
|
396
|
+
[snapshot_id, *occ_ids],
|
|
397
|
+
).fetchall()
|
|
398
|
+
for r in rows:
|
|
399
|
+
key = (direction, r["type"], r["resolution_status"])
|
|
400
|
+
summary[key] = summary.get(key, 0) + int(r["c"])
|
|
401
|
+
ev_sql = (
|
|
402
|
+
"SELECT COUNT(DISTINCT j.value) AS c"
|
|
403
|
+
" FROM domain_relations r, json_each(r.evidence_ids) j"
|
|
404
|
+
f" WHERE r.snapshot_id = ? AND (r.from_entity_occ_id IN ({ph})"
|
|
405
|
+
f" OR r.to_entity_occ_id IN ({ph}))"
|
|
406
|
+
)
|
|
407
|
+
evidence_count = int(
|
|
408
|
+
conn.execute(
|
|
409
|
+
ev_sql,
|
|
410
|
+
[snapshot_id, *occ_ids, *occ_ids],
|
|
411
|
+
).fetchone()["c"]
|
|
412
|
+
)
|
|
413
|
+
relations = [
|
|
414
|
+
{"direction": d, "type": t, "status": s, "count": c}
|
|
415
|
+
for (d, t, s), c in sorted(summary.items())
|
|
416
|
+
]
|
|
417
|
+
try:
|
|
418
|
+
attributes = json.loads(entity["attributes"] or "{}")
|
|
419
|
+
except (TypeError, json.JSONDecodeError):
|
|
420
|
+
attributes = {}
|
|
421
|
+
return {
|
|
422
|
+
"entity_id": entity_id,
|
|
423
|
+
"repository_id": int(entity["repository_id"]),
|
|
424
|
+
"identity": {
|
|
425
|
+
"domain": entity["domain"],
|
|
426
|
+
"kind": entity["kind"],
|
|
427
|
+
"qualified_name": entity["qualified_name"],
|
|
428
|
+
"display_name": _display_name(entity),
|
|
429
|
+
"name": entity["name"],
|
|
430
|
+
"catalog": entity["catalog"],
|
|
431
|
+
"namespace": entity["namespace"],
|
|
432
|
+
"environment": entity["environment"],
|
|
433
|
+
"identity_key": entity["identity_key"],
|
|
434
|
+
},
|
|
435
|
+
"attributes": attributes,
|
|
436
|
+
"snapshot_id": snapshot_id,
|
|
437
|
+
"occurrences": occurrences,
|
|
438
|
+
"relations_summary": relations,
|
|
439
|
+
"evidence_count": evidence_count,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# --- linhagem ---------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _entity_label_map(
|
|
447
|
+
conn: sqlite3.Connection, entity_ids: set[int]
|
|
448
|
+
) -> dict[int, sqlite3.Row]:
|
|
449
|
+
if not entity_ids:
|
|
450
|
+
return {}
|
|
451
|
+
ph = ",".join("?" for _ in entity_ids)
|
|
452
|
+
label_sql = (
|
|
453
|
+
"SELECT id, domain, kind, qualified_name, name, catalog,"
|
|
454
|
+
" namespace, environment FROM domain_entities"
|
|
455
|
+
f" WHERE id IN ({ph})"
|
|
456
|
+
)
|
|
457
|
+
rows = conn.execute(label_sql, sorted(entity_ids)).fetchall()
|
|
458
|
+
return {int(r["id"]): r for r in rows}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _node_key(
|
|
462
|
+
entity_id: int | None, symbol: str | None, unresolved: str | None
|
|
463
|
+
) -> str:
|
|
464
|
+
"""Chave do nó no traversal — entidade, símbolo ou alvo honesto."""
|
|
465
|
+
if entity_id is not None:
|
|
466
|
+
return f"e:{entity_id}"
|
|
467
|
+
if symbol:
|
|
468
|
+
return f"symbol:{symbol}"
|
|
469
|
+
if unresolved:
|
|
470
|
+
return f"unresolved:{unresolved}"
|
|
471
|
+
return "missing:endpoint" # endpoint órfão — nunca inventado
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _flow_edges(
|
|
475
|
+
conn: sqlite3.Connection, snapshot_id: int
|
|
476
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
477
|
+
"""Arestas de fluxo normalizadas ``producer → consumer`` do snapshot.
|
|
478
|
+
|
|
479
|
+
``READS_FROM``/``DERIVES_FROM`` fluem ``to → from`` (a origem lê ou
|
|
480
|
+
deriva do alvo); ``WRITES_TO``/``TRANSFORMS`` fluem ``from → to``
|
|
481
|
+
(a origem escreve/muta o alvo). Endpoints de símbolo e alvos
|
|
482
|
+
``unresolved`` viram nós-folha declarados — nunca atravessados.
|
|
483
|
+
"""
|
|
484
|
+
ph = ",".join("?" for _ in DATA_FLOW_TYPES)
|
|
485
|
+
edges_sql = (
|
|
486
|
+
"SELECT r.id, r.type, r.resolution_status, r.resolver,"
|
|
487
|
+
" r.from_entity_occ_id, r.to_entity_occ_id, r.to_unresolved,"
|
|
488
|
+
" fe.entity_id AS from_entity_id, te.entity_id AS to_entity_id,"
|
|
489
|
+
" fs.qualified_name AS from_symbol,"
|
|
490
|
+
" ts.qualified_name AS to_symbol"
|
|
491
|
+
" FROM domain_relations r"
|
|
492
|
+
" LEFT JOIN domain_entity_occurrences fe"
|
|
493
|
+
" ON fe.id = r.from_entity_occ_id"
|
|
494
|
+
" LEFT JOIN domain_entity_occurrences te"
|
|
495
|
+
" ON te.id = r.to_entity_occ_id"
|
|
496
|
+
" LEFT JOIN symbol_occurrences fso"
|
|
497
|
+
" ON fso.id = r.from_symbol_occ_id"
|
|
498
|
+
" LEFT JOIN symbols fs ON fs.id = fso.logical_symbol_id"
|
|
499
|
+
" LEFT JOIN symbol_occurrences tso"
|
|
500
|
+
" ON tso.id = r.to_symbol_occ_id"
|
|
501
|
+
" LEFT JOIN symbols ts ON ts.id = tso.logical_symbol_id"
|
|
502
|
+
f" WHERE r.snapshot_id = ? AND r.type IN ({ph})"
|
|
503
|
+
" ORDER BY r.id"
|
|
504
|
+
)
|
|
505
|
+
rows = conn.execute(
|
|
506
|
+
edges_sql,
|
|
507
|
+
[snapshot_id, *sorted(DATA_FLOW_TYPES)],
|
|
508
|
+
).fetchall()
|
|
509
|
+
edges: list[dict[str, Any]] = []
|
|
510
|
+
entity_ids: set[int] = set()
|
|
511
|
+
for r in rows:
|
|
512
|
+
fe = r["from_entity_id"]
|
|
513
|
+
te = r["to_entity_id"]
|
|
514
|
+
frm = _node_key(
|
|
515
|
+
int(fe) if fe is not None else None,
|
|
516
|
+
r["from_symbol"],
|
|
517
|
+
None,
|
|
518
|
+
)
|
|
519
|
+
to = _node_key(
|
|
520
|
+
int(te) if te is not None else None,
|
|
521
|
+
r["to_symbol"],
|
|
522
|
+
r["to_unresolved"],
|
|
523
|
+
)
|
|
524
|
+
if fe is not None:
|
|
525
|
+
entity_ids.add(int(fe))
|
|
526
|
+
if te is not None:
|
|
527
|
+
entity_ids.add(int(te))
|
|
528
|
+
# normalização do fluxo: upstream (producer) → downstream (consumer)
|
|
529
|
+
if r["type"] in _UPSTREAM_TARGET_TYPES:
|
|
530
|
+
u, v = to, frm # alvo alimenta a origem
|
|
531
|
+
ue, ve = te, fe
|
|
532
|
+
else:
|
|
533
|
+
u, v = frm, to
|
|
534
|
+
ue, ve = fe, te
|
|
535
|
+
edges.append(
|
|
536
|
+
{
|
|
537
|
+
"u": u,
|
|
538
|
+
"v": v,
|
|
539
|
+
"u_entity": int(ue) if ue is not None else None,
|
|
540
|
+
"v_entity": int(ve) if ve is not None else None,
|
|
541
|
+
"relation_id": int(r["id"]),
|
|
542
|
+
"type": r["type"],
|
|
543
|
+
"resolution_status": r["resolution_status"],
|
|
544
|
+
"resolver": r["resolver"],
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
return edges, entity_ids
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _lineage_bfs(
|
|
551
|
+
seed_key: str,
|
|
552
|
+
up_adj: dict[str, list[dict[str, Any]]],
|
|
553
|
+
down_adj: dict[str, list[dict[str, Any]]],
|
|
554
|
+
*,
|
|
555
|
+
direction: str,
|
|
556
|
+
depth: int,
|
|
557
|
+
limit: int,
|
|
558
|
+
label: Callable[[str], str],
|
|
559
|
+
) -> tuple[dict[str, int], dict[str, str], list[dict[str, Any]], bool]:
|
|
560
|
+
"""BFS bounded sobre adjacências de fluxo — ciclo-safe e ordenado.
|
|
561
|
+
|
|
562
|
+
Retorna ``(node_depth, node_via, edges, truncated)``. Só nós de
|
|
563
|
+
entidade (``e:<id>``) propagam — folhas ``symbol:``/``unresolved:``/
|
|
564
|
+
``missing:`` são terminais declarados, nunca atravessados.
|
|
565
|
+
"""
|
|
566
|
+
node_depth: dict[str, int] = {seed_key: 0}
|
|
567
|
+
node_via: dict[str, str] = {seed_key: "seed"}
|
|
568
|
+
out_edges: list[dict[str, Any]] = []
|
|
569
|
+
truncated = False
|
|
570
|
+
# a direção do seed é o próprio ``direction`` — ``up`` nunca expande
|
|
571
|
+
# downstream no primeiro hop nem nos seguintes (via propaga).
|
|
572
|
+
frontier = [(seed_key, direction)]
|
|
573
|
+
for d in range(1, depth + 1):
|
|
574
|
+
candidates = _bfs_candidates(frontier, up_adj, down_adj, label)
|
|
575
|
+
next_frontier, hit = _bfs_visit(
|
|
576
|
+
candidates, node_depth, node_via, out_edges,
|
|
577
|
+
limit=limit, label=label, depth_now=d)
|
|
578
|
+
truncated = truncated or hit
|
|
579
|
+
frontier = next_frontier
|
|
580
|
+
if truncated:
|
|
581
|
+
break
|
|
582
|
+
return node_depth, node_via, out_edges, truncated
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _bfs_candidates(
|
|
586
|
+
frontier: list[tuple[str, str]],
|
|
587
|
+
up_adj: dict[str, list[dict[str, Any]]],
|
|
588
|
+
down_adj: dict[str, list[dict[str, Any]]],
|
|
589
|
+
label: Callable[[str], str],
|
|
590
|
+
) -> list[tuple[str, dict[str, Any], str]]:
|
|
591
|
+
"""Vizinhos do nível — ordenação determinística por (label, rel_id)."""
|
|
592
|
+
candidates: list[tuple[str, dict[str, Any], str]] = []
|
|
593
|
+
for node, dirn in frontier:
|
|
594
|
+
if dirn in ("up", "both"):
|
|
595
|
+
for e in up_adj.get(node, ()):
|
|
596
|
+
candidates.append((e["u"], e, "up"))
|
|
597
|
+
if dirn in ("down", "both"):
|
|
598
|
+
for e in down_adj.get(node, ()):
|
|
599
|
+
candidates.append((e["v"], e, "down"))
|
|
600
|
+
candidates.sort(key=lambda c: (label(c[0]), c[1]["relation_id"]))
|
|
601
|
+
return candidates
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _bfs_visit(
|
|
605
|
+
candidates: list[tuple[str, dict[str, Any], str]],
|
|
606
|
+
node_depth: dict[str, int],
|
|
607
|
+
node_via: dict[str, str],
|
|
608
|
+
out_edges: list[dict[str, Any]],
|
|
609
|
+
*,
|
|
610
|
+
limit: int,
|
|
611
|
+
label: Callable[[str], str],
|
|
612
|
+
depth_now: int,
|
|
613
|
+
) -> tuple[list[tuple[str, str]], bool]:
|
|
614
|
+
"""Visita um nível: registra nós/arestas novos, propaga entidades."""
|
|
615
|
+
next_frontier: list[tuple[str, str]] = []
|
|
616
|
+
truncated = False
|
|
617
|
+
for nkey, edge, via in candidates:
|
|
618
|
+
if len(node_depth) >= limit and nkey not in node_depth:
|
|
619
|
+
truncated = True
|
|
620
|
+
continue
|
|
621
|
+
first = nkey not in node_depth
|
|
622
|
+
if first:
|
|
623
|
+
node_depth[nkey] = depth_now
|
|
624
|
+
node_via[nkey] = via
|
|
625
|
+
# aresta reportada quando o par (rel, nó-extremo) é novo
|
|
626
|
+
if nkey.startswith("e:") or first:
|
|
627
|
+
entry = {
|
|
628
|
+
"from": label(edge["u"]),
|
|
629
|
+
"to": label(edge["v"]),
|
|
630
|
+
"from_entity_id": edge["u_entity"],
|
|
631
|
+
"to_entity_id": edge["v_entity"],
|
|
632
|
+
"type": edge["type"],
|
|
633
|
+
"resolution_status": edge["resolution_status"],
|
|
634
|
+
"resolver": edge["resolver"],
|
|
635
|
+
"relation_id": edge["relation_id"],
|
|
636
|
+
"depth": depth_now,
|
|
637
|
+
"via": via,
|
|
638
|
+
}
|
|
639
|
+
if entry not in out_edges:
|
|
640
|
+
out_edges.append(entry)
|
|
641
|
+
if first and nkey.startswith("e:"):
|
|
642
|
+
next_frontier.append((nkey, via))
|
|
643
|
+
return next_frontier, truncated
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _lineage_nodes(
|
|
647
|
+
node_depth: dict[str, int],
|
|
648
|
+
node_via: dict[str, str],
|
|
649
|
+
labels: dict[int, sqlite3.Row],
|
|
650
|
+
label: Callable[[str], str],
|
|
651
|
+
) -> list[dict[str, Any]]:
|
|
652
|
+
"""Nós do traversal ordenados por (depth, label) — determinístico."""
|
|
653
|
+
nodes: list[dict[str, Any]] = []
|
|
654
|
+
for k in sorted(node_depth, key=lambda k: (node_depth[k], label(k))):
|
|
655
|
+
node: dict[str, Any] = {
|
|
656
|
+
"ref": label(k),
|
|
657
|
+
"entity_id": int(k[2:]) if k.startswith("e:") else None,
|
|
658
|
+
"synthetic": (
|
|
659
|
+
None if k.startswith("e:")
|
|
660
|
+
else k.split(":", 1)[0] # symbol|unresolved|missing
|
|
661
|
+
),
|
|
662
|
+
"depth": node_depth[k],
|
|
663
|
+
"via": node_via[k],
|
|
664
|
+
"kind": None,
|
|
665
|
+
"domain": None,
|
|
666
|
+
}
|
|
667
|
+
if k.startswith("e:") and int(k[2:]) in labels:
|
|
668
|
+
row = labels[int(k[2:])]
|
|
669
|
+
node["kind"] = row["kind"]
|
|
670
|
+
node["domain"] = row["domain"]
|
|
671
|
+
nodes.append(node)
|
|
672
|
+
return nodes
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def lineage(
|
|
676
|
+
conn: sqlite3.Connection,
|
|
677
|
+
entity: sqlite3.Row,
|
|
678
|
+
snapshot_id: int,
|
|
679
|
+
*,
|
|
680
|
+
direction: str = "both",
|
|
681
|
+
depth: int = 2,
|
|
682
|
+
limit: int = 500,
|
|
683
|
+
) -> dict[str, Any]:
|
|
684
|
+
"""Traversal BFS limitado de fluxo de dados a partir da entidade.
|
|
685
|
+
|
|
686
|
+
``direction``: ``up`` (o que alimenta a entidade — produtores),
|
|
687
|
+
``down`` (o que a entidade alimenta — consumidores) ou ``both``.
|
|
688
|
+
Ciclo-safe (visited) e bounded (``depth`` + ``limit``); nós-folha
|
|
689
|
+
``symbol:``/``unresolved:``/``missing:`` são declarados, nunca
|
|
690
|
+
atravessados.
|
|
691
|
+
"""
|
|
692
|
+
direction = direction if direction in ("up", "down", "both") else "both"
|
|
693
|
+
depth = max(1, min(int(depth), MAX_LINEAGE_DEPTH))
|
|
694
|
+
limit = max(1, int(limit))
|
|
695
|
+
edges, entity_ids = _flow_edges(conn, snapshot_id)
|
|
696
|
+
seed = int(entity["id"])
|
|
697
|
+
entity_ids.add(seed)
|
|
698
|
+
labels = _entity_label_map(conn, entity_ids)
|
|
699
|
+
|
|
700
|
+
def label(key: str) -> str:
|
|
701
|
+
if key.startswith("e:"):
|
|
702
|
+
row = labels.get(int(key[2:]))
|
|
703
|
+
return _display_name(row) if row is not None else key
|
|
704
|
+
return key
|
|
705
|
+
|
|
706
|
+
up_adj: dict[str, list[dict[str, Any]]] = {}
|
|
707
|
+
down_adj: dict[str, list[dict[str, Any]]] = {}
|
|
708
|
+
for e in edges:
|
|
709
|
+
up_adj.setdefault(e["v"], []).append(e) # u alimenta v
|
|
710
|
+
down_adj.setdefault(e["u"], []).append(e)
|
|
711
|
+
|
|
712
|
+
node_depth, node_via, out_edges, truncated = _lineage_bfs(
|
|
713
|
+
f"e:{seed}", up_adj, down_adj,
|
|
714
|
+
direction=direction, depth=depth, limit=limit, label=label)
|
|
715
|
+
nodes = _lineage_nodes(node_depth, node_via, labels, label)
|
|
716
|
+
out_edges.sort(key=lambda e: (e["depth"], e["relation_id"], e["via"]))
|
|
717
|
+
return {
|
|
718
|
+
"entity": {
|
|
719
|
+
"entity_id": seed,
|
|
720
|
+
"qualified_name": entity["qualified_name"],
|
|
721
|
+
"display_name": _display_name(entity),
|
|
722
|
+
"kind": entity["kind"],
|
|
723
|
+
"domain": entity["domain"],
|
|
724
|
+
"identity_key": entity["identity_key"],
|
|
725
|
+
},
|
|
726
|
+
"snapshot_id": snapshot_id,
|
|
727
|
+
"direction": direction,
|
|
728
|
+
"depth": depth,
|
|
729
|
+
"limit": limit,
|
|
730
|
+
"truncated": truncated,
|
|
731
|
+
"nodes": nodes,
|
|
732
|
+
"edges": out_edges,
|
|
733
|
+
"upstream": [
|
|
734
|
+
n for n in nodes if n["via"] in ("up",)
|
|
735
|
+
],
|
|
736
|
+
"downstream": [
|
|
737
|
+
n for n in nodes if n["via"] in ("down",)
|
|
738
|
+
],
|
|
739
|
+
}
|