code-atlas-forge 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ }