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,681 @@
1
+ """Avaliação medida da extração de domínio (lf-0124, Cycle 7 Phase 16).
2
+
3
+ Framework evidence-first de precisão medida: cada fixture versionada é
4
+ indexada pela **pipeline real** — ``IndexCoordinator`` com
5
+ ``DomainRegistry`` (e ``CrossDomainResolver`` no cenário cross-domain) —
6
+ e o estado persistido (``domain_entities`` + ``domain_relations`` +
7
+ ``domain_entity_occurrences``) é comparado contra goldens curados.
8
+
9
+ Dois níveis medidos, sem inventar nada:
10
+
11
+ - **extração**: ``adapter.extract`` sobre os artefatos que o coordinator
12
+ descobriria (``supported_artifacts`` ∩ scan) — entidades, relações e
13
+ ``DomainDiagnostic`` (que nunca são persistidos por desenho);
14
+ - **persistido**: as rows reais do snapshot — ``resolution_status``
15
+ final (``resolved``/``ambiguous``/``unresolved``/``external``),
16
+ ``edges_skipped`` (origem sem âncora) e arestas produzidas pelo
17
+ resolver cross-domain (``resolver='cross-domain'``).
18
+
19
+ Goldens (``evaluation/golden/*.json``) são inventários **exaustivos**
20
+ de arestas + contagens exatas + listas de entidades proibidas — a
21
+ comparação é conjunto-a-conjunto (``matched``/``missing``/``unexpected``),
22
+ nunca amostragem otimista. ``run_eval.py --bless`` regenera; qualquer
23
+ diff vira falha legível no pytest.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import sqlite3
30
+ import tempfile
31
+ from collections import Counter
32
+ from collections.abc import Callable, Mapping
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any, cast
36
+
37
+ from code_atlas.discovery.identity import identity_key
38
+ from code_atlas.discovery.scanner import scan as default_scan
39
+ from code_atlas.domains.contracts import (
40
+ ArtifactSupport,
41
+ DiscoveredArtifact,
42
+ DomainAdapter,
43
+ DomainExtractionRequest,
44
+ DomainExtractionResult,
45
+ )
46
+ from code_atlas.domains.crossresolve import CrossDomainResolver
47
+ from code_atlas.domains.identity import DomainIdentity
48
+ from code_atlas.domains.registry import DomainRegistry
49
+ from code_atlas.indexing import IndexCoordinator
50
+ from code_atlas.storage import connect, migrate
51
+ from code_atlas.storage.store import Store
52
+
53
+ GOLDEN_SCHEMA = 1
54
+ RESULTS_SCHEMA = 1
55
+
56
+ #: Prefixes que distinguem o tipo de endpoint na forma renderizada da
57
+ #: aresta: entidade ``dom:cat.ns.name@env``, símbolo ``sym:qname`` e
58
+ #: alvo textual honesto ``txt:...`` (unresolved/external declarado).
59
+ SYM_PREFIX = "sym:"
60
+ TXT_PREFIX = "txt:"
61
+
62
+ AdapterFactory = Callable[[Path], DomainAdapter]
63
+
64
+
65
+ def _sql(fx: Path) -> DomainAdapter:
66
+ from code_atlas.domains.sql import SqlDomainAdapter
67
+
68
+ return SqlDomainAdapter(base_path=fx)
69
+
70
+
71
+ def _spark(fx: Path) -> DomainAdapter:
72
+ from code_atlas.domains.spark import PySparkAdapter
73
+
74
+ return PySparkAdapter(base_path=fx)
75
+
76
+
77
+ def _aws(fx: Path) -> DomainAdapter:
78
+ from code_atlas.domains.aws import AwsDataAdapter
79
+
80
+ return AwsDataAdapter(base_path=fx)
81
+
82
+
83
+ def _api(fx: Path) -> DomainAdapter:
84
+ from code_atlas.domains.api import ApiContractAdapter
85
+
86
+ return ApiContractAdapter(workspace_root=fx)
87
+
88
+
89
+ def _terraform(fx: Path) -> DomainAdapter:
90
+ from code_atlas.domains.terraform import TerraformAdapter
91
+
92
+ return TerraformAdapter(base_path=fx)
93
+
94
+
95
+ def _cicd(fx: Path) -> DomainAdapter:
96
+ from code_atlas.domains.cicd import GitHubActionsAdapter
97
+
98
+ return GitHubActionsAdapter.for_workspace(fx)
99
+
100
+
101
+ _ADAPTER_FACTORIES: dict[str, AdapterFactory] = {
102
+ "sql": _sql,
103
+ "pyspark": _spark,
104
+ "aws-data": _aws,
105
+ "api": _api,
106
+ "terraform": _terraform,
107
+ "cicd": _cicd,
108
+ }
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class DomainSpec:
113
+ """Cenário de avaliação: fixture + adapters + resolução cross.
114
+
115
+ ``adapter_ids`` são os ``domain_id`` reais registrados no
116
+ ``DomainRegistry`` — para cenários cross-domain mais de um adapter
117
+ observa o mesmo snapshot. ``cross=True`` injeta o
118
+ ``CrossDomainResolver`` na pipeline (lf-0117).
119
+ """
120
+
121
+ eval_id: str
122
+ fixture: str # relativo à raiz do repositório
123
+ adapter_ids: tuple[str, ...]
124
+ cross: bool = False
125
+ note: str = ""
126
+
127
+
128
+ #: Cenários avaliados — um por fixture versionada + o workspace
129
+ #: cross-domain (lf-0124). Ordem estável = ordem do relatório.
130
+ DOMAIN_SPECS: tuple[DomainSpec, ...] = (
131
+ DomainSpec(
132
+ "sql", "tests/fixtures/sql_warehouse", ("sql",),
133
+ note="pipeline landing→staging→mart + arquivo malformado",
134
+ ),
135
+ DomainSpec(
136
+ "pyspark", "tests/fixtures/pyspark_repo", ("pyspark",),
137
+ note="jobs DataFrame/spark.sql/temp views + dinâmicos honestos",
138
+ ),
139
+ DomainSpec(
140
+ "aws", "tests/fixtures/aws_repo", ("aws-data",),
141
+ note="Glue/Athena/Iceberg/EMR + catálogo cross-account + configs",
142
+ ),
143
+ DomainSpec(
144
+ "api", "tests/fixtures/api_repo", ("api",),
145
+ note="OpenAPI + FastAPI/Express + consumidores (2 edges_skipped "
146
+ "— web.legacy sem parser JS)",
147
+ ),
148
+ DomainSpec(
149
+ "terraform", "tests/fixtures/terraform_infra", ("terraform",),
150
+ note="HCL + tf.json + tfvars + módulo local/registry + broken/",
151
+ ),
152
+ DomainSpec(
153
+ "cicd", "tests/fixtures/cicd_repo", ("cicd",),
154
+ note="GitHub Actions workflows/jobs/deps + workflow malformado",
155
+ ),
156
+ DomainSpec(
157
+ "crossdomain", "tests/fixtures/crossdomain_ws",
158
+ ("sql", "pyspark", "api"),
159
+ cross=True,
160
+ note="workspace multi-adapter: spark↔sql identidade plena e "
161
+ "parcial, consumidor↔endpoint OpenAPI (lf-0117)",
162
+ ),
163
+ )
164
+
165
+
166
+ def spec_by_id(eval_id: str) -> DomainSpec:
167
+ for spec in DOMAIN_SPECS:
168
+ if spec.eval_id == eval_id:
169
+ return spec
170
+ raise KeyError(f"unknown eval domain: {eval_id!r}")
171
+
172
+
173
+ def _display(identity_key_str: str) -> str:
174
+ """``identity_key`` canônica → forma legível ``dom:cat.ns.name@env``."""
175
+ segs = DomainIdentity.parse_key(identity_key_str)
176
+ if segs is None or len(segs) < 7:
177
+ return identity_key_str
178
+ domain, repository, root, catalog, namespace, env, qname = segs[:7]
179
+ return DomainIdentity(
180
+ domain=domain, repository=repository, root=root, catalog=catalog,
181
+ namespace=namespace, environment=env, qualified_name=qname,
182
+ ).display()
183
+
184
+
185
+ def _discover_artifacts(
186
+ files: Mapping[str, str], support: ArtifactSupport
187
+ ) -> tuple[DiscoveredArtifact, ...]:
188
+ """Mesmo recorte que ``IndexCoordinator._extract_domains`` aplica:
189
+ ``supported_artifacts`` ∩ inventário, ordenado, com sha do scan."""
190
+ return tuple(
191
+ DiscoveredArtifact(path=p, sha256=files[p])
192
+ for p in sorted(p for p in files if support.matches(p))
193
+ )
194
+
195
+
196
+ def _extract_for_diagnostics(
197
+ adapters: Mapping[str, DomainAdapter],
198
+ files: Mapping[str, str],
199
+ repository: str,
200
+ snapshot: str,
201
+ ) -> dict[str, DomainExtractionResult]:
202
+ """Re-roda ``extract`` com o MESMO request do coordinator.
203
+
204
+ ``DomainDiagnostic`` nunca é persistido (desenho da v9) — para
205
+ medi-lo reexecutamos os adapters, que são puros/determinísticos
206
+ sobre o mesmo recorte (evidence-first, sem I/O fora do request).
207
+ """
208
+ results: dict[str, DomainExtractionResult] = {}
209
+ for domain_id, adapter in adapters.items():
210
+ artifacts = _discover_artifacts(files, adapter.supported_artifacts())
211
+ results[domain_id] = adapter.extract(
212
+ DomainExtractionRequest(
213
+ repository=repository,
214
+ artifacts=artifacts,
215
+ snapshot=snapshot,
216
+ )
217
+ )
218
+ return results
219
+
220
+
221
+ def _read_persisted(
222
+ conn: sqlite3.Connection, store: Store, snapshot_id: int
223
+ ) -> dict[str, Any]:
224
+ """Estado persistido do snapshot → relatório de domínio."""
225
+ occ_display: dict[int, str] = {}
226
+ occ_kind: dict[int, str] = {}
227
+ for row in store.domain_occurrences_snapshot(snapshot_id):
228
+ occ_display[int(row["occ_id"])] = _display(str(row["identity_key"]))
229
+ occ_kind[int(row["occ_id"])] = str(row["kind"])
230
+ sym_qname: dict[int, str] = {
231
+ int(r["id"]): str(r["qualified_name"])
232
+ for r in conn.execute(
233
+ "SELECT so.id, s.qualified_name FROM symbol_occurrences so"
234
+ " JOIN symbols s ON s.id = so.logical_symbol_id"
235
+ " WHERE so.snapshot_id = ?",
236
+ (snapshot_id,),
237
+ )
238
+ }
239
+ kind_counts = {
240
+ str(r["kind"]): int(r["c"])
241
+ for r in conn.execute(
242
+ "SELECT kind, COUNT(*) c FROM domain_entities GROUP BY kind"
243
+ )
244
+ }
245
+ entities = sorted(
246
+ _display(str(r["identity_key"]))
247
+ for r in conn.execute(
248
+ "SELECT identity_key FROM domain_entities ORDER BY identity_key"
249
+ )
250
+ )
251
+ rels = store.domain_relations_snapshot(snapshot_id)
252
+
253
+ def _frm(r: sqlite3.Row) -> str:
254
+ fe = r["from_entity_occ_id"]
255
+ if fe is not None:
256
+ return occ_display.get(int(fe), f"?e{fe}")
257
+ fs = r["from_symbol_occ_id"]
258
+ return f"{SYM_PREFIX}{sym_qname.get(int(fs), fs)}"
259
+
260
+ def _to(r: sqlite3.Row) -> str:
261
+ te = r["to_entity_occ_id"]
262
+ if te is not None:
263
+ return occ_display.get(int(te), f"?e{te}")
264
+ ts = r["to_symbol_occ_id"]
265
+ if ts is not None:
266
+ return f"{SYM_PREFIX}{sym_qname.get(int(ts), ts)}"
267
+ return f"{TXT_PREFIX}{r['to_unresolved']}"
268
+
269
+ edges = [
270
+ {
271
+ "type": str(r["type"]),
272
+ "from": _frm(r),
273
+ "to": _to(r),
274
+ "status": str(r["resolution_status"]),
275
+ "confidence": str(r["confidence_class"]),
276
+ "resolver": str(r["resolver"]),
277
+ }
278
+ for r in rels
279
+ ]
280
+ edges.sort(
281
+ key=lambda e: (e["type"], e["from"], e["to"], e["status"],
282
+ e["confidence"], e["resolver"])
283
+ )
284
+ return {
285
+ "entities": entities,
286
+ "by_kind": kind_counts,
287
+ "occurrences": len(occ_display),
288
+ "edges": edges,
289
+ "by_type": dict(Counter(e["type"] for e in edges)),
290
+ "by_status": dict(Counter(e["status"] for e in edges)),
291
+ "by_confidence": dict(Counter(e["confidence"] for e in edges)),
292
+ "by_resolver": dict(Counter(e["resolver"] for e in edges)),
293
+ }
294
+
295
+
296
+ def run_domain_eval(
297
+ repo_root: Path, spec: DomainSpec, work_dir: Path
298
+ ) -> dict[str, object]:
299
+ """Executa a pipeline real sobre a fixture e devolve o relatório.
300
+
301
+ ``work_dir`` recebe o ``atlas.db`` temporário do cenário (um por
302
+ domínio — isolamento de snapshot/repositório entre fixtures).
303
+ """
304
+ fixture = Path(repo_root) / spec.fixture
305
+ scan = default_scan(fixture)
306
+ files = {f.path: (f.sha256 or "") for f in scan.files}
307
+ adapters: dict[str, DomainAdapter] = {
308
+ aid: _ADAPTER_FACTORIES[aid](fixture) for aid in spec.adapter_ids
309
+ }
310
+ registry = DomainRegistry()
311
+ for adapter in adapters.values():
312
+ registry.register(adapter)
313
+
314
+ conn = connect(Path(work_dir) / f"{spec.eval_id}.db")
315
+ migrate(conn)
316
+ try:
317
+ coord = IndexCoordinator(
318
+ conn,
319
+ domain_registry=registry,
320
+ # cross rodado manualmente abaixo — mesmas chamadas do
321
+ # coordinator, mas capturando o plano (diagnósticos+stats).
322
+ cross_resolver=None,
323
+ )
324
+ outcome = coord.index(fixture)
325
+ if outcome.status != "success" or outcome.snapshot_id is None:
326
+ return {
327
+ "domain": spec.eval_id,
328
+ "fixture": spec.fixture,
329
+ "index_status": outcome.status,
330
+ "error": outcome.error,
331
+ }
332
+ snapshot_id = outcome.snapshot_id
333
+ store = Store(conn)
334
+
335
+ cross_info: dict[str, Any] = {}
336
+ if spec.cross:
337
+ # Réplica fiel de ``_run_cross_resolution``: mesmo resolver,
338
+ # mesma ordem (plan → persist). Roda pós-publish numa
339
+ # transação própria — o estado final é idêntico ao do
340
+ # caminho inline; a diferença é só observável: o plano
341
+ # (diagnósticos + stats) fica disponível para medir.
342
+ resolver = CrossDomainResolver()
343
+ with conn:
344
+ xplan = resolver.plan(store, snapshot_id)
345
+ applied = store.persist_cross_resolutions(
346
+ snapshot_id, xplan.update_rows(),
347
+ xplan.insert_rows(snapshot_id),
348
+ )
349
+ cross_info = {
350
+ "stats": dict(xplan.stats),
351
+ "applied": dict(applied),
352
+ "diagnostics": [
353
+ {"severity": d.severity, "code": d.code,
354
+ "message": d.message}
355
+ for d in xplan.diagnostics
356
+ ],
357
+ }
358
+ persisted = _read_persisted(conn, store, snapshot_id)
359
+
360
+ # Mesmo request do coordinator (repository=ikey, snapshot=digest,
361
+ # artifacts por supported_artifacts) — re-run só para medir os
362
+ # diagnósticos, que não são persistidos por desenho.
363
+ repository = identity_key(scan.git, fixture)
364
+ digest = scan.manifest_sha256()
365
+ extractions = _extract_for_diagnostics(
366
+ adapters, files, repository, digest
367
+ )
368
+ finally:
369
+ conn.close()
370
+
371
+ diagnostics = [
372
+ {
373
+ "severity": d.severity,
374
+ "code": d.code,
375
+ "path": d.path,
376
+ "adapter": d.adapter_id or domain_id,
377
+ }
378
+ for domain_id, res in extractions.items()
379
+ for d in res.diagnostics
380
+ ]
381
+ diagnostics.extend(
382
+ {
383
+ "severity": str(d["severity"]),
384
+ "code": str(d["code"]),
385
+ "path": "",
386
+ "adapter": "cross-domain",
387
+ }
388
+ for d in cross_info.get("diagnostics", ())
389
+ )
390
+ diagnostics.sort(key=lambda d: (d["path"], d["code"], d["severity"]))
391
+ artifacts = {
392
+ domain_id: [a.path for a in _discover_artifacts(
393
+ files, adapters[domain_id].supported_artifacts()
394
+ )]
395
+ for domain_id in adapters
396
+ }
397
+ stats: dict[str, int] = {}
398
+ for domain_id, res in extractions.items():
399
+ for k, v in res.stats.items():
400
+ stats[f"{domain_id}.{k}"] = stats.get(f"{domain_id}.{k}", 0) + int(v)
401
+
402
+ # contagens de domínio/cross do job de indexação — medido, não derivado
403
+ job_counts = {
404
+ k: int(v) for k, v in (outcome.stats or {}).items()
405
+ if k.startswith(("domain_", "cross_"))
406
+ }
407
+ edges_skipped = sum(
408
+ int(v) for k, v in (outcome.stats or {}).items()
409
+ if k.startswith("domain_") and k.endswith("_edges_skipped")
410
+ )
411
+
412
+ return {
413
+ "domain": spec.eval_id,
414
+ "fixture": spec.fixture,
415
+ "note": spec.note,
416
+ "index_status": outcome.status,
417
+ "adapters": sorted(adapters),
418
+ "artifacts": artifacts,
419
+ "entities": {
420
+ "total": len(persisted["entities"]),
421
+ "by_kind": persisted["by_kind"],
422
+ "occurrences": persisted["occurrences"],
423
+ "names": persisted["entities"],
424
+ },
425
+ "relations": {
426
+ "total": len(persisted["edges"]),
427
+ "by_type": persisted["by_type"],
428
+ "by_status": persisted["by_status"],
429
+ "by_confidence": persisted["by_confidence"],
430
+ "by_resolver": persisted["by_resolver"],
431
+ "edges_skipped": edges_skipped,
432
+ },
433
+ "diagnostics": diagnostics,
434
+ "edges": persisted["edges"],
435
+ "cross": cross_info,
436
+ "job_counts": job_counts,
437
+ "extract_stats": stats,
438
+ }
439
+
440
+
441
+ # ---------------------------------------------------------------------
442
+ # goldens — comparação conjunto-a-conjunto
443
+ # ---------------------------------------------------------------------
444
+
445
+
446
+ def _edge_key(e: Mapping[str, object]) -> tuple[str, str, str, str, str, str]:
447
+ return (
448
+ str(e["type"]), str(e["from"]), str(e["to"]), str(e["status"]),
449
+ str(e["confidence"]), str(e["resolver"]),
450
+ )
451
+
452
+
453
+ def _fmt_edge(e: Mapping[str, object]) -> str:
454
+ return (
455
+ f"{e['type']} {e['from']} -> {e['to']} "
456
+ f"[{e['status']}|{e['confidence']}|{e['resolver']}]"
457
+ )
458
+
459
+
460
+ def _diag_key(d: Mapping[str, object]) -> tuple[str, str, str]:
461
+ return (str(d["severity"]), str(d["code"]), str(d["path"]))
462
+
463
+
464
+ def _fmt_diag(d: Mapping[str, object]) -> str:
465
+ return f"{d['severity']}:{d['code']}:{d['path'] or '-'}"
466
+
467
+
468
+ def _count_diff(
469
+ diffs: list[str], actual: int, want: Any, label: str
470
+ ) -> None:
471
+ if want is None:
472
+ return
473
+ if actual != int(want):
474
+ diffs.append(f"{label}: expected {want}, got {actual}")
475
+
476
+
477
+ def _diff_entities(
478
+ report: Mapping[str, Any], g_entities: Mapping[str, Any],
479
+ diffs: list[str],
480
+ ) -> None:
481
+ r_entities = cast(Mapping[str, Any], report["entities"])
482
+ _count_diff(
483
+ diffs, int(r_entities["total"]), g_entities.get("total"),
484
+ "entities.total",
485
+ )
486
+ want_kind = g_entities.get("by_kind")
487
+ if want_kind is not None and dict(
488
+ r_entities["by_kind"]
489
+ ) != dict(want_kind):
490
+ diffs.append(
491
+ "entities.by_kind: expected "
492
+ f"{dict(want_kind)}, got {dict(r_entities['by_kind'])}"
493
+ )
494
+ names = set(r_entities["names"])
495
+ for want in g_entities.get("expected", ()):
496
+ if want not in names:
497
+ diffs.append(f"entity expected but absent: {want}")
498
+ for bad in g_entities.get("forbidden", ()):
499
+ if bad in names:
500
+ diffs.append(f"entity forbidden but present: {bad}")
501
+ # inventário exato de nomes — quando presente no golden,
502
+ # rename/removal/addition vira diff explícito, não só delta
503
+ # agregado de total/by_kind
504
+ want_names = g_entities.get("names")
505
+ if want_names is not None:
506
+ for missing in sorted(set(want_names) - names):
507
+ diffs.append(f"missing entity: {missing}")
508
+ for extra in sorted(names - set(want_names)):
509
+ diffs.append(f"unexpected entity: {extra}")
510
+
511
+
512
+ def _diff_relations(
513
+ report: Mapping[str, Any], g_relations: Mapping[str, Any],
514
+ diffs: list[str],
515
+ ) -> None:
516
+ r_relations = cast(Mapping[str, Any], report["relations"])
517
+ _count_diff(
518
+ diffs, int(r_relations["total"]), g_relations.get("total"),
519
+ "relations.total",
520
+ )
521
+ for group in ("by_type", "by_status", "by_confidence", "by_resolver"):
522
+ want = g_relations.get(group)
523
+ if want is not None and dict(r_relations[group]) != dict(want):
524
+ diffs.append(
525
+ f"relations.{group}: expected {dict(want)}, "
526
+ f"got {dict(r_relations[group])}"
527
+ )
528
+ _count_diff(
529
+ diffs, int(r_relations["edges_skipped"]),
530
+ g_relations.get("edges_skipped"),
531
+ "relations.edges_skipped",
532
+ )
533
+
534
+
535
+ def _diff_edges(
536
+ report: Mapping[str, Any], golden: Mapping[str, Any],
537
+ diffs: list[str],
538
+ ) -> None:
539
+ # inventário exaustivo de arestas — matched/missing/unexpected
540
+ want_edges = {_edge_key(e) for e in golden.get("edges", ())}
541
+ got_edges = {_edge_key(e) for e in report["edges"]}
542
+ by_key = {_edge_key(e): e for e in golden.get("edges", ())}
543
+ by_key.update({_edge_key(e): e for e in report["edges"]})
544
+ for ekey in sorted(want_edges - got_edges):
545
+ diffs.append(f"missing edge: {_fmt_edge(by_key[ekey])}")
546
+ for ekey in sorted(got_edges - want_edges):
547
+ diffs.append(f"unexpected edge: {_fmt_edge(by_key[ekey])}")
548
+
549
+
550
+ def _diff_diagnostics(
551
+ report: Mapping[str, Any], golden: Mapping[str, Any],
552
+ diffs: list[str],
553
+ ) -> None:
554
+ # diagnósticos como multiconjunto (severity, code, path)
555
+ want_diags = Counter(
556
+ _diag_key(d) for d in golden.get("diagnostics", ())
557
+ )
558
+ got_diags = Counter(
559
+ _diag_key(d) for d in report["diagnostics"]
560
+ )
561
+ by_dkey: dict[tuple[str, str, str], Mapping[str, object]] = {
562
+ _diag_key(d): d for d in golden.get("diagnostics", ())
563
+ }
564
+ by_dkey.update(
565
+ {_diag_key(d): d for d in report["diagnostics"]}
566
+ )
567
+ for dkey in sorted(set(want_diags) | set(got_diags)):
568
+ if want_diags[dkey] != got_diags[dkey]:
569
+ diffs.append(
570
+ f"diagnostic {_fmt_diag(by_dkey[dkey])}: expected "
571
+ f"{want_diags[dkey]}x, got {got_diags[dkey]}x"
572
+ )
573
+
574
+
575
+ def diff_report(
576
+ report: Mapping[str, Any], golden: Mapping[str, Any]
577
+ ) -> list[str]:
578
+ """Diff legível report↔golden; ``[]`` = cenário dentro do esperado.
579
+
580
+ Checagens: contagens exatas (entidades/relations por kind/type/
581
+ status), amostras de entidades esperadas/proibidas, inventário
582
+ exaustivo de arestas (``missing``/``unexpected``) e diagnósticos.
583
+ """
584
+ diffs: list[str] = []
585
+ g_entities = golden.get("entities", {})
586
+ if isinstance(g_entities, Mapping):
587
+ _diff_entities(report, g_entities, diffs)
588
+ g_relations = golden.get("relations", {})
589
+ if isinstance(g_relations, Mapping):
590
+ _diff_relations(report, g_relations, diffs)
591
+ _diff_edges(report, golden, diffs)
592
+ _diff_diagnostics(report, golden, diffs)
593
+ return diffs
594
+
595
+
596
+ def load_golden(golden_dir: Path, eval_id: str) -> dict[str, Any] | None:
597
+ path = Path(golden_dir) / f"{eval_id}.json"
598
+ if not path.is_file():
599
+ return None
600
+ return cast("dict[str, Any]", json.loads(path.read_text(encoding="utf-8")))
601
+
602
+
603
+ def evaluate_all(
604
+ repo_root: Path,
605
+ golden_dir: Path | None = None,
606
+ *,
607
+ work_dir: Path | None = None,
608
+ ) -> dict[str, object]:
609
+ """Roda todos os ``DOMAIN_SPECS`` e compara com goldens presentes."""
610
+ tmp: tempfile.TemporaryDirectory[str] | None = None
611
+ if work_dir is None:
612
+ tmp = tempfile.TemporaryDirectory(prefix="domain-eval-")
613
+ work_dir = Path(tmp.name)
614
+ try:
615
+ domains: dict[str, object] = {}
616
+ ok = True
617
+ for spec in DOMAIN_SPECS:
618
+ report = run_domain_eval(Path(repo_root), spec, Path(work_dir))
619
+ golden = (
620
+ load_golden(Path(golden_dir), spec.eval_id)
621
+ if golden_dir is not None
622
+ else None
623
+ )
624
+ check: dict[str, object] = {"golden": golden is not None}
625
+ if golden is not None:
626
+ diffs = diff_report(report, golden)
627
+ check["diffs"] = diffs
628
+ check["pass"] = not diffs
629
+ ok = ok and not diffs
630
+ report["check"] = check
631
+ domains[spec.eval_id] = report
632
+ return {
633
+ "schema": RESULTS_SCHEMA,
634
+ "domains": domains,
635
+ "summary": {
636
+ "scenarios": len(domains),
637
+ "golden_compared": sum(
638
+ 1 for d in domains.values()
639
+ if isinstance(d, dict)
640
+ and isinstance(d.get("check"), dict)
641
+ and cast("dict[str, Any]", d["check"]).get("golden")
642
+ ),
643
+ "all_pass": ok,
644
+ },
645
+ }
646
+ finally:
647
+ if tmp is not None:
648
+ tmp.cleanup()
649
+
650
+
651
+ def report_to_golden(report: Mapping[str, Any]) -> dict[str, Any]:
652
+ """Recorte persistível de ``report`` no schema de golden (bless).
653
+
654
+ ``--bless`` é a ferramenta de curadoria: a saída DEVE ser revista
655
+ linha a linha contra a fixture antes de commitar — golden gerado
656
+ sem revisão é só um eco do comportamento corrente, não expectativa.
657
+ """
658
+ entities = cast(Mapping[str, Any], report["entities"])
659
+ relations = cast(Mapping[str, Any], report["relations"])
660
+ return {
661
+ "schema": GOLDEN_SCHEMA,
662
+ "domain": report["domain"],
663
+ "fixture": report["fixture"],
664
+ "entities": {
665
+ "total": entities["total"],
666
+ "by_kind": entities["by_kind"],
667
+ "names": entities["names"],
668
+ "expected": [],
669
+ "forbidden": [],
670
+ },
671
+ "relations": {
672
+ "total": relations["total"],
673
+ "by_type": relations["by_type"],
674
+ "by_status": relations["by_status"],
675
+ "by_confidence": relations["by_confidence"],
676
+ "by_resolver": relations["by_resolver"],
677
+ "edges_skipped": relations["edges_skipped"],
678
+ },
679
+ "diagnostics": report["diagnostics"],
680
+ "edges": report["edges"],
681
+ }