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,758 @@
1
+ """lf-0135 (Cycle 8 Phase 9) — blast radius contra verdade-terreno rotulada.
2
+
3
+ Corpus de cenários em ``evaluation/impact_truth/scenarios.json``: cada
4
+ cenário rotula **à mão** o conjunto esperado de impacto
5
+ (``direct``/``transitive``/``absent``) a partir da leitura da fixture e
6
+ da semântica declarada de travessia — arestas ``*_DEPENDS_ON`` propagam
7
+ ``to → from``, arestas acopladas (``WRITES_TO``, ``TRANSFORMS``,
8
+ ``PROVISIONS``, ``TRIGGERS``, ``DEPLOYS``, ``DEFINES``) propagam nos
9
+ dois sentidos, ``REFERENCES`` só quando é par de identidade.
10
+
11
+ O que é medido por cenário (pipeline REAL — o binário
12
+ ``code-atlas``/``cli.main`` dirigido in-process com
13
+ ``CODE_ATLAS_DATA_DIR``/``CODE_ATLAS_CONFIG_DIR`` isolados num
14
+ workspace temporário; nada é executado dos repos indexados):
15
+
16
+ - ``index <fixture> --domains <ids> [--cross-domain]`` — indexação real;
17
+ - ``impact symbol <seed> --root <fixture> --format json`` — modo de
18
+ domínio: a ref de entidade atravessa ``resolve_seeds`` (via
19
+ ``ImpactRequest.symbol``, a fiação mínima do lf-0120);
20
+ - precision/recall do conjunto alcançável (deduplicado por nó) vs
21
+ rótulo — por classe (direct/transitive) e agregado;
22
+ - **false-confidence**: todo candidato ``verified`` precisa ter
23
+ ``fully_verified`` + hops ``resolved``; itens rotulados possíveis
24
+ jamais podem subir a verified — orçamento ZERO, asserção dura;
25
+ - **evidence validity**: todo ``edge_refs`` aponta para uma row real do
26
+ snapshot alvo (``dom:N`` → ``domain_relations``, ``rel:N`` →
27
+ ``relations``), ``hops == len(edge_refs) == len(statuses)`` e a
28
+ ocorrência do candidato existe no snapshot;
29
+ - **truncation honesty**: re-runs com ``--max-paths`` no limite exato
30
+ e com folga — ``truncated``/``truncation_reasons`` coerentes com a
31
+ união dos bounds que cortaram trabalho (lf-0151);
32
+ - ``unknown_coverage`` e ``diagnostics`` reportados verbatim.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import io
39
+ import json
40
+ import sqlite3
41
+ from collections.abc import Mapping, Sequence
42
+ from contextlib import redirect_stderr, redirect_stdout
43
+ from dataclasses import dataclass, field
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ SCENARIO_SCHEMA = 1
48
+ RESULTS_SCHEMA = 1
49
+
50
+
51
+ # Classe de impacto em ordem decrescente de "alcançável verificado".
52
+ _IMPACT_RANK = {"direct": 0, "transitive": 1, "possible": 2, "unknown": 3}
53
+ _STRENGTH_RANK = {"verified": 0, "supported": 1, "possible": 2,
54
+ "unknown": 3}
55
+
56
+ #: Folga acima do total de linhas no probe "over-cap" — o traversal
57
+ #: termina naturalmente, ``truncated`` deve ser False.
58
+ TRUNCATION_OVERCAP_SLACK = 3
59
+
60
+ #: Razões ligadas ao bound variado pelas sondas (``--max-paths``) —
61
+ #: todas as outras independem do cap de emissão.
62
+ _EMISSION_REASONS = frozenset({"max-paths"})
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ImpactScenario:
67
+ """Cenário rotulado — fixture + seed + conjuntos esperados."""
68
+
69
+ scenario_id: str
70
+ prompt_class: str
71
+ fixture: str
72
+ domains: tuple[str, ...]
73
+ cross_domain: bool
74
+ seed: str
75
+ task: str
76
+ expected_direct: tuple[str, ...]
77
+ expected_transitive: tuple[str, ...]
78
+ expected_absent: tuple[str, ...]
79
+ notes: str = ""
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class CliRun:
84
+ """Resultado de uma invocação in-process do CLI real."""
85
+
86
+ argv: tuple[str, ...]
87
+ exit_code: int
88
+ stdout: str
89
+ stderr: str
90
+
91
+
92
+ def load_scenarios(path: Path) -> list[ImpactScenario]:
93
+ """Carrega ``scenarios.json`` — validação estrutural mínima."""
94
+ raw = json.loads(path.read_text(encoding="utf-8"))
95
+ if int(raw.get("schema", 0)) != SCENARIO_SCHEMA:
96
+ raise ValueError(f"impact truth schema != {SCENARIO_SCHEMA}")
97
+ out: list[ImpactScenario] = []
98
+ for s in raw["scenarios"]:
99
+ exp = s["expected"]
100
+ out.append(ImpactScenario(
101
+ scenario_id=str(s["id"]),
102
+ prompt_class=str(s.get("prompt_class", "")),
103
+ fixture=str(s["fixture"]),
104
+ domains=tuple(str(d) for d in s["domains"]),
105
+ cross_domain=bool(s.get("cross_domain", False)),
106
+ seed=str(s["seed"]),
107
+ task=str(s.get("task", "")),
108
+ expected_direct=tuple(str(x) for x in exp.get("direct", ())),
109
+ expected_transitive=tuple(
110
+ str(x) for x in exp.get("transitive", ())),
111
+ expected_absent=tuple(str(x) for x in exp.get("absent", ())),
112
+ notes=str(s.get("notes", "")),
113
+ ))
114
+ return out
115
+
116
+
117
+ def validate_scenario(repo_root: Path, sc: ImpactScenario) -> list[str]:
118
+ """Higiene do rótulo: conjuntos disjuntos, fixture existente."""
119
+ errs: list[str] = []
120
+ if not sc.seed.strip():
121
+ errs.append("seed vazia")
122
+ if not sc.domains:
123
+ errs.append("domains vazio — o cenário precisa de adapters")
124
+ if sc.cross_domain and len(sc.domains) < 2:
125
+ errs.append("cross_domain com <2 adapters")
126
+ if not (repo_root / sc.fixture).is_dir():
127
+ errs.append(f"fixture ausente: {sc.fixture}")
128
+ seen: dict[str, str] = {}
129
+ for label, items in (
130
+ ("direct", sc.expected_direct),
131
+ ("transitive", sc.expected_transitive),
132
+ ("absent", sc.expected_absent),
133
+ ):
134
+ for name in items:
135
+ if name in seen:
136
+ errs.append(
137
+ f"'{name}' rotulado em {seen[name]} e {label}")
138
+ seen[name] = label
139
+ return errs
140
+
141
+
142
+ # ---------------------------------------------------------------------
143
+ # harness — invoca o CLI real in-process, isolado por env
144
+ # ---------------------------------------------------------------------
145
+
146
+
147
+ def run_cli(
148
+ argv: Sequence[str], *, data_dir: Path, config_dir: Path
149
+ ) -> CliRun:
150
+ """Executa ``code-atlas <argv>`` in-process (argparse → run_* →
151
+ stdout), com ``CODE_ATLAS_DATA_DIR``/``CODE_ATLAS_CONFIG_DIR``
152
+ apontados para o workspace temporário. É a mesma superfície do
153
+ binário — só o transporte é in-process."""
154
+ from code_atlas.cli import main as cli_main
155
+ from code_atlas.env_override import patched_env
156
+
157
+ out_buf, err_buf = io.StringIO(), io.StringIO()
158
+ with patched_env({
159
+ "CODE_ATLAS_DATA_DIR": str(data_dir),
160
+ "CODE_ATLAS_CONFIG_DIR": str(config_dir),
161
+ }), redirect_stdout(out_buf), redirect_stderr(err_buf):
162
+ code = int(cli_main(list(argv)))
163
+ return CliRun(tuple(argv), code, out_buf.getvalue(),
164
+ err_buf.getvalue())
165
+
166
+
167
+ def index_fixture(
168
+ fixture: Path, domains: Sequence[str], cross_domain: bool,
169
+ *, data_dir: Path, config_dir: Path,
170
+ ) -> CliRun:
171
+ """``index <fixture> --domains ...`` — pipeline real de indexação."""
172
+ argv = ["index", str(fixture)]
173
+ if domains:
174
+ argv += ["--domains", ",".join(domains)]
175
+ if cross_domain:
176
+ argv += ["--cross-domain"]
177
+ argv.append("--json")
178
+ return run_cli(argv, data_dir=data_dir, config_dir=config_dir)
179
+
180
+
181
+ def run_impact(
182
+ fixture: Path, seed: str, *, data_dir: Path, config_dir: Path,
183
+ extra_args: Sequence[str] = (),
184
+ ) -> tuple[CliRun, dict[str, Any] | None]:
185
+ """``impact symbol <seed> --root <fixture> --format json`` → payload."""
186
+ argv = ["impact", "symbol", seed, "--root", str(fixture),
187
+ "--format", "json", *extra_args]
188
+ run = run_cli(argv, data_dir=data_dir, config_dir=config_dir)
189
+ payload: dict[str, Any] | None = None
190
+ text = run.stdout.strip()
191
+ if text.startswith("{"):
192
+ try:
193
+ parsed = json.loads(text)
194
+ except json.JSONDecodeError:
195
+ parsed = None
196
+ if isinstance(parsed, dict):
197
+ payload = parsed
198
+ return run, payload
199
+
200
+
201
+ # ---------------------------------------------------------------------
202
+ # dedup + métricas
203
+ # ---------------------------------------------------------------------
204
+
205
+
206
+ @dataclass
207
+ class _Node:
208
+ """Vista deduplicada de um nó impactado (várias linhas por
209
+ caminhos alternativos colapsam aqui)."""
210
+
211
+ name: str
212
+ node_type: str
213
+ kind: str
214
+ best_impact: str = "unknown"
215
+ best_strength: str = "unknown"
216
+ min_hops: int = 0
217
+ min_verified_hops: int | None = None
218
+ rows: int = 0
219
+ fully_verified_any: bool = False
220
+
221
+ def absorb(self, cand: Mapping[str, Any]) -> None:
222
+ impact = str(cand.get("impact", "unknown"))
223
+ strength = str(cand.get("strength", "unknown"))
224
+ path = cand.get("path") or {}
225
+ hops = int(path.get("hops") or 0)
226
+ verified = bool(path.get("fully_verified"))
227
+ if _IMPACT_RANK.get(impact, 9) < _IMPACT_RANK.get(
228
+ self.best_impact, 9):
229
+ self.best_impact = impact
230
+ if _STRENGTH_RANK.get(strength, 9) < _STRENGTH_RANK.get(
231
+ self.best_strength, 9):
232
+ self.best_strength = strength
233
+ if self.rows == 0 or hops < self.min_hops:
234
+ self.min_hops = hops
235
+ if verified and (
236
+ self.min_verified_hops is None
237
+ or hops < self.min_verified_hops):
238
+ self.min_verified_hops = hops
239
+ self.fully_verified_any = self.fully_verified_any or verified
240
+ self.rows += 1
241
+
242
+
243
+ def dedup_candidates(
244
+ payload: Mapping[str, Any],
245
+ ) -> dict[tuple[str, str], _Node]:
246
+ """Colapsa linhas de ``impacts`` por ``(node_type, qualified_name)``."""
247
+ out: dict[tuple[str, str], _Node] = {}
248
+ for cand in payload.get("impacts") or ():
249
+ if not isinstance(cand, Mapping):
250
+ continue
251
+ key = (str(cand.get("node_type", "symbol")),
252
+ str(cand.get("qualified_name", "")))
253
+ node = out.get(key)
254
+ if node is None:
255
+ node = _Node(name=key[1], node_type=key[0],
256
+ kind=str(cand.get("kind", "")))
257
+ out[key] = node
258
+ node.absorb(cand)
259
+ return out
260
+
261
+
262
+ def _pr(tp: int, fp: int, fn: int) -> dict[str, Any]:
263
+ """precision/recall/f1 — ``None`` quando indefinido (nunca 1.0
264
+ fabricado por denominador vazio)."""
265
+ precision = tp / (tp + fp) if (tp + fp) else None
266
+ recall = tp / (tp + fn) if (tp + fn) else None
267
+ f1 = (2 * precision * recall / (precision + recall)
268
+ if precision and recall else
269
+ (0.0 if precision == 0.0 or recall == 0.0 else None))
270
+ return {"tp": tp, "fp": fp, "fn": fn,
271
+ "precision": precision, "recall": recall, "f1": f1}
272
+
273
+
274
+ def _class_diff(
275
+ actual: set[str], expected: Sequence[str], other_expected: set[str],
276
+ ) -> dict[str, Any]:
277
+ """Diff de uma classe: ``other_expected`` evita que um item
278
+ esperado na OUTRA classe vire FP aqui — vira class_mismatch."""
279
+ exp = set(expected)
280
+ tp = actual & exp
281
+ fp = actual - exp - other_expected
282
+ fn = exp - actual
283
+ mismatched = sorted((actual - exp) & other_expected)
284
+ rep = _pr(len(tp), len(fp), len(fn))
285
+ rep["missing"] = sorted(fn)
286
+ rep["unexpected"] = sorted(fp)
287
+ rep["class_mismatches"] = mismatched
288
+ return rep
289
+
290
+
291
+ # ---------------------------------------------------------------------
292
+ # invariantes — false-confidence + evidência + truncamento
293
+ # ---------------------------------------------------------------------
294
+
295
+
296
+ def false_confidence_leaks(payload: Mapping[str, Any]) -> list[str]:
297
+ """Invasões de confiança — ORÇAMENTO ZERO.
298
+
299
+ - ``strength=verified`` exige ``fully_verified`` e todos os hops
300
+ ``resolved``;
301
+ - ``impact=direct|transitive`` implica ``strength=verified``;
302
+ - ``impact=possible|unknown`` nunca pode ter ``strength=verified``.
303
+ """
304
+ leaks: list[str] = []
305
+ for cand in payload.get("impacts") or ():
306
+ name = str(cand.get("qualified_name", "?"))
307
+ strength = str(cand.get("strength", "unknown"))
308
+ impact = str(cand.get("impact", "unknown"))
309
+ path = cand.get("path") or {}
310
+ statuses = [str(s) for s in (path.get("resolution_statuses")
311
+ or ())]
312
+ verified_path = bool(path.get("fully_verified")) and all(
313
+ s == "resolved" for s in statuses)
314
+ if strength == "verified" and not verified_path:
315
+ leaks.append(
316
+ f"{name}: strength=verified mas path não provado "
317
+ f"(statuses={statuses}, "
318
+ f"fully_verified={path.get('fully_verified')})")
319
+ if impact in ("direct", "transitive") and strength != "verified":
320
+ leaks.append(
321
+ f"{name}: impact={impact} com strength={strength}")
322
+ if impact in ("possible", "unknown") and strength == "verified":
323
+ leaks.append(
324
+ f"{name}: impact={impact} promovido a verified")
325
+ return leaks
326
+
327
+
328
+ def _snapshot_id_sets(
329
+ conn: sqlite3.Connection, snapshot_id: int,
330
+ ) -> dict[str, set[int]]:
331
+ """Ids reais de cada tabela de evidência do snapshot alvo."""
332
+ return {
333
+ "rel": {int(r[0]) for r in conn.execute(
334
+ "SELECT id FROM relations WHERE snapshot_id = ?",
335
+ (snapshot_id,))},
336
+ "dom": {int(r[0]) for r in conn.execute(
337
+ "SELECT id FROM domain_relations WHERE snapshot_id = ?",
338
+ (snapshot_id,))},
339
+ "sym_occ": {int(r[0]) for r in conn.execute(
340
+ "SELECT id FROM symbol_occurrences WHERE snapshot_id = ?",
341
+ (snapshot_id,))},
342
+ "dom_occ": {int(r[0]) for r in conn.execute(
343
+ "SELECT id FROM domain_entity_occurrences"
344
+ " WHERE snapshot_id = ?",
345
+ (snapshot_id,))},
346
+ }
347
+
348
+
349
+ def _check_path(
350
+ name: str, key: str, cand: Mapping[str, Any],
351
+ ) -> tuple[list[str], list[str], list[str]]:
352
+ """Consistência interna do ``ImpactPath`` — retorna
353
+ ``(erros, edge_refs, statuses)``."""
354
+ errors: list[str] = []
355
+ path = cand.get("path")
356
+ if not isinstance(path, Mapping):
357
+ return [f"{name}: sem ImpactPath"], [], []
358
+ edge_refs = [str(r) for r in (path.get("edge_refs") or ())]
359
+ statuses = [str(s) for s in (path.get("resolution_statuses") or ())]
360
+ hops = int(path.get("hops") or 0)
361
+ if hops != len(edge_refs):
362
+ errors.append(
363
+ f"{name}: hops={hops} != {len(edge_refs)} edge_refs")
364
+ if hops != len(statuses):
365
+ errors.append(
366
+ f"{name}: hops={hops} != {len(statuses)} statuses")
367
+ if str(path.get("affected_symbol_key", "")) != key:
368
+ errors.append(
369
+ f"{name}: affected_symbol_key "
370
+ f"{path.get('affected_symbol_key')!r} != symbol_key {key!r}")
371
+ mids = [str(m) for m in (path.get("intermediate_keys") or ())]
372
+ if mids and mids[-1] != key:
373
+ errors.append(
374
+ f"{name}: intermediate_keys[-1]={mids[-1]!r} != "
375
+ f"symbol_key {key!r}")
376
+ if not str(path.get("origin_symbol_key", "")):
377
+ errors.append(f"{name}: origin_symbol_key vazio")
378
+ return errors, edge_refs, statuses
379
+
380
+
381
+ def _check_edge_refs(
382
+ name: str, edge_refs: list[str], ids: Mapping[str, set[int]],
383
+ snapshot_id: int,
384
+ ) -> list[str]:
385
+ """Cada ``dom:N``/``rel:N`` aponta para row real do snapshot."""
386
+ errors: list[str] = []
387
+ for ref in edge_refs:
388
+ prefix, sep, num = ref.partition(":")
389
+ if not sep or not num.isdigit():
390
+ errors.append(f"{name}: edge_ref malformado {ref!r}")
391
+ continue
392
+ table = ids.get(prefix)
393
+ if table is None:
394
+ errors.append(
395
+ f"{name}: edge_ref de tipo desconhecido {ref!r}")
396
+ elif int(num) not in table:
397
+ errors.append(
398
+ f"{name}: edge_ref {ref} não existe no snapshot "
399
+ f"{snapshot_id}")
400
+ return errors
401
+
402
+
403
+ def _check_occurrence(
404
+ name: str, cand: Mapping[str, Any], ids: Mapping[str, set[int]],
405
+ ) -> list[str]:
406
+ """A ocorrência do candidato existe no snapshot alvo (espaço de
407
+ ids certo para ``node_type``)."""
408
+ if str(cand.get("node_type", "symbol")) == "domain":
409
+ occ = cand.get("domain_occurrence_id")
410
+ errors = [] if occ is not None and int(occ) in ids["dom_occ"] \
411
+ else [f"{name}: domain_occurrence_id {occ} fora do "
412
+ "snapshot"]
413
+ if cand.get("occurrence_id") is not None:
414
+ errors.append(
415
+ f"{name}: nó de domínio com occurrence_id de símbolo")
416
+ return errors
417
+ occ = cand.get("occurrence_id")
418
+ if occ is None or int(occ) not in ids["sym_occ"]:
419
+ return [f"{name}: occurrence_id {occ} fora do snapshot"]
420
+ return []
421
+
422
+
423
+ def validate_evidence(
424
+ payload: Mapping[str, Any], conn: sqlite3.Connection,
425
+ snapshot_id: int,
426
+ ) -> list[str]:
427
+ """Cada candidato carrega prova real: path consistente, edge_refs
428
+ resolvíveis no snapshot alvo, ocorrência existente."""
429
+ errors: list[str] = []
430
+ ids = _snapshot_id_sets(conn, snapshot_id)
431
+ for cand in payload.get("impacts") or ():
432
+ name = str(cand.get("qualified_name", "?"))
433
+ key = str(cand.get("symbol_key", ""))
434
+ errs, edge_refs, _st = _check_path(name, key, cand)
435
+ errors.extend(errs)
436
+ errors.extend(
437
+ _check_edge_refs(name, edge_refs, ids, snapshot_id))
438
+ errors.extend(_check_occurrence(name, cand, ids))
439
+ return errors
440
+
441
+
442
+ def _probe_trunc(
443
+ full_rows: int, capped: Mapping[str, Any], limit: int,
444
+ ) -> dict[str, Any]:
445
+ """Uma sonda de truncamento num ``--max-paths`` dado.
446
+
447
+ Semântica medida do engine (lf-0151): ``truncated`` é a união de
448
+ ``truncation_reasons``. ``max-paths`` declara o corte de emissão
449
+ e só pode disparar com a página exatamente no limite (a emissão
450
+ para ali); os demais bounds — fronteira de depth, scan de
451
+ vizinhos, seeds, diff, testes — independem de ``--max-paths``.
452
+ Honesto = envelope coerente (boolean ⟺ razões presentes), razão
453
+ de emissão ⟺ página no limite e ``capped_rows <= limit``.
454
+ """
455
+ capped_rows = len(capped.get("impacts") or ())
456
+ declared = bool(capped.get("truncated"))
457
+ reasons = {str(r)
458
+ for r in (capped.get("truncation_reasons") or ())}
459
+ emission_cut = capped_rows == limit
460
+ non_emission = bool(reasons - _EMISSION_REASONS)
461
+ expected = emission_cut or non_emission
462
+ consistent = declared == bool(reasons)
463
+ emission_ok = ("max-paths" in reasons) == emission_cut
464
+ return {
465
+ "limit": limit,
466
+ "full_rows": full_rows,
467
+ "capped_rows": capped_rows,
468
+ "truncation_reasons": sorted(reasons),
469
+ "expected_truncated": expected,
470
+ "declared_truncated": declared,
471
+ "within_limit": capped_rows <= limit,
472
+ "envelope_consistent": consistent,
473
+ "emission_reason_ok": emission_ok,
474
+ "honest": (declared == expected and consistent
475
+ and emission_ok and capped_rows <= limit),
476
+ }
477
+
478
+
479
+ def truncation_check(
480
+ full: Mapping[str, Any],
481
+ at_cap: Mapping[str, Any] | None,
482
+ over_cap: Mapping[str, Any] | None,
483
+ ) -> dict[str, Any]:
484
+ """Honestidade de truncamento — duas sondas por cenário.
485
+
486
+ ``at_cap`` (``--max-paths = max(1, full_rows)``): a emissão bate no
487
+ cap — ``truncated=True`` com ``max-paths`` declarado é a resposta
488
+ honesta mesmo quando o conjunto completo tem exatamente
489
+ ``full_rows`` linhas (prefixo no limite é declarado, nunca
490
+ "completo" duvidoso).
491
+
492
+ ``over_cap`` (``--max-paths = full_rows + slack``): a emissão
493
+ termina naturalmente — ``max-paths`` não pode constar e todas as
494
+ ``full_rows`` linhas têm que sair (cap solto não pode cortar).
495
+ ``truncated`` ainda pode ser True por outros bounds
496
+ (``truncation_reasons`` não-emissão) — lf-0151: bounds reais são
497
+ declarados, nunca escondidos sob um cap folgado.
498
+ """
499
+ full_rows = len(full.get("impacts") or ())
500
+ cap = max(1, full_rows)
501
+ probes: dict[str, Any] = {}
502
+ honest = True
503
+ if at_cap is None:
504
+ probes["at_cap"] = {"limit": cap, "error": "sem JSON"}
505
+ honest = False
506
+ else:
507
+ probes["at_cap"] = _probe_trunc(full_rows, at_cap, cap)
508
+ honest = honest and bool(probes["at_cap"]["honest"])
509
+ free = full_rows + TRUNCATION_OVERCAP_SLACK
510
+ if over_cap is None:
511
+ probes["over_cap"] = {"limit": free, "error": "sem JSON"}
512
+ honest = False
513
+ else:
514
+ probes["over_cap"] = _probe_trunc(full_rows, over_cap, free)
515
+ oc = probes["over_cap"]
516
+ # over-cap também exige conjunto completo (cap solto ≠ corte).
517
+ oc["emitted_all"] = oc["capped_rows"] == full_rows
518
+ honest = (honest and bool(oc["honest"])
519
+ and bool(oc["emitted_all"]))
520
+ return {"probes": probes, "honest": honest}
521
+
522
+
523
+ # ---------------------------------------------------------------------
524
+ # avaliação
525
+ # ---------------------------------------------------------------------
526
+
527
+
528
+ def _seed_entity_count(
529
+ conn: sqlite3.Connection, repository_id: int, seed: str,
530
+ ) -> int:
531
+ """Quantas entidades a ref de seed casa — detecta cenário cujo
532
+ blast vazio vem de seed não resolvida (falso negativo de medição)."""
533
+ from code_atlas.impact.domain_traversal import resolve_entity_refs
534
+
535
+ rows, _how = resolve_entity_refs(conn, repository_id, seed)
536
+ return len(rows)
537
+
538
+
539
+ def evaluate_scenario(
540
+ repo_root: Path, sc: ImpactScenario, conn: sqlite3.Connection,
541
+ *, data_dir: Path, config_dir: Path,
542
+ ) -> dict[str, Any]:
543
+ """Mede um cenário num repositório JÁ indexado (``conn`` aberto no
544
+ ``atlas.db`` do workspace temporário)."""
545
+ out: dict[str, Any] = {
546
+ "id": sc.scenario_id,
547
+ "prompt_class": sc.prompt_class,
548
+ "fixture": sc.fixture,
549
+ "seed": sc.seed,
550
+ "task": sc.task,
551
+ "notes": sc.notes,
552
+ "expected": {
553
+ "direct": len(sc.expected_direct),
554
+ "transitive": len(sc.expected_transitive),
555
+ "absent": len(sc.expected_absent),
556
+ },
557
+ }
558
+ fixture = repo_root / sc.fixture
559
+
560
+ run, payload = run_impact(
561
+ fixture, sc.seed, data_dir=data_dir, config_dir=config_dir)
562
+ out["impact_exit"] = run.exit_code
563
+ if payload is None:
564
+ out["error"] = (
565
+ f"impact não retornou JSON (exit {run.exit_code}): "
566
+ f"{run.stderr.strip()[:200]}")
567
+ return out
568
+ sid = payload.get("target_snapshot_id")
569
+ if not isinstance(sid, int):
570
+ out["error"] = "impact sem target_snapshot_id"
571
+ out["diagnostics"] = [
572
+ d.get("code") for d in payload.get("diagnostics", ())
573
+ if isinstance(d, Mapping)]
574
+ return out
575
+
576
+ repo_row = conn.execute(
577
+ "SELECT repository_id FROM snapshots WHERE id = ?",
578
+ (sid,)).fetchone()
579
+ repo_id = int(repo_row[0]) if repo_row else -1
580
+
581
+ nodes = dedup_candidates(payload)
582
+ actual_direct = {k[1] for k, n in nodes.items()
583
+ if n.best_impact == "direct"}
584
+ actual_transitive = {k[1] for k, n in nodes.items()
585
+ if n.best_impact == "transitive"}
586
+ actual_possible = {k[1] for k, n in nodes.items()
587
+ if n.best_impact in ("possible", "unknown")}
588
+ actual_all = {k[1] for k in nodes}
589
+
590
+ exp_direct = set(sc.expected_direct)
591
+ exp_transitive = set(sc.expected_transitive)
592
+ exp_reachable = exp_direct | exp_transitive
593
+ exp_absent = set(sc.expected_absent)
594
+
595
+ direct = _class_diff(actual_direct, sc.expected_direct,
596
+ exp_transitive)
597
+ transitive = _class_diff(actual_transitive, sc.expected_transitive,
598
+ exp_direct)
599
+ verified_actual = actual_direct | actual_transitive
600
+ tp_v = len(verified_actual & exp_reachable)
601
+ fp_v = len(verified_actual - exp_reachable)
602
+ fn_v = len(exp_reachable - verified_actual)
603
+ verified = _pr(tp_v, fp_v, fn_v)
604
+ verified["missing"] = sorted(exp_reachable - verified_actual)
605
+ verified["unexpected"] = sorted(verified_actual - exp_reachable)
606
+
607
+ leaks = false_confidence_leaks(payload)
608
+ ev_errors = validate_evidence(payload, conn, sid)
609
+ absent_violations = sorted(actual_all & exp_absent)
610
+
611
+ # probes de truncamento — re-run no cap exato e com folga.
612
+ full_rows_n = len(payload.get("impacts") or ())
613
+ _run_cap, at_cap = run_impact(
614
+ fixture, sc.seed, data_dir=data_dir, config_dir=config_dir,
615
+ extra_args=("--max-paths", str(max(1, full_rows_n))))
616
+ _run_free, over_cap = run_impact(
617
+ fixture, sc.seed, data_dir=data_dir, config_dir=config_dir,
618
+ extra_args=("--max-paths",
619
+ str(full_rows_n + TRUNCATION_OVERCAP_SLACK)))
620
+ trunc = truncation_check(payload, at_cap, over_cap)
621
+
622
+ out.update({
623
+ "index_independent_check": {
624
+ "seed_entity_rows": _seed_entity_count(conn, repo_id,
625
+ sc.seed),
626
+ "target_snapshot_id": sid,
627
+ },
628
+ "candidates": {
629
+ "rows": len(payload.get("impacts") or ()),
630
+ "unique": len(nodes),
631
+ "by_best_impact": {
632
+ "direct": len(actual_direct),
633
+ "transitive": len(actual_transitive),
634
+ "possible_or_unknown": len(actual_possible),
635
+ },
636
+ },
637
+ "direct": direct,
638
+ "transitive": transitive,
639
+ "verified": verified,
640
+ "possible_actual": sorted(actual_possible),
641
+ "absent_violations": absent_violations,
642
+ "false_confidence": {
643
+ "leaks": leaks,
644
+ "count": len(leaks),
645
+ "rate": (len(leaks) / len(nodes)) if nodes else 0.0,
646
+ },
647
+ "evidence_errors": ev_errors,
648
+ "unknown_coverage": list(payload.get("unknown_coverage") or ()),
649
+ "truncated": bool(payload.get("truncated")),
650
+ "truncation": trunc,
651
+ "risk": dict(payload.get("risk") or {}),
652
+ "diagnostics": [
653
+ {"code": str(d.get("code")), "message": str(d.get("message"))}
654
+ for d in payload.get("diagnostics", ())
655
+ if isinstance(d, Mapping)
656
+ ],
657
+ })
658
+ return out
659
+
660
+
661
+ @dataclass
662
+ class _IndexedFixture:
663
+ """Workspace temporário já indexado para uma fixture."""
664
+
665
+ key: tuple[str, tuple[str, ...], bool]
666
+ data_dir: Path
667
+ config_dir: Path
668
+ index_run: CliRun
669
+ conn: sqlite3.Connection = field(init=False)
670
+
671
+ def open(self) -> sqlite3.Connection:
672
+ self.conn = sqlite3.connect(str(self.data_dir / "atlas.db"))
673
+ self.conn.row_factory = sqlite3.Row
674
+ return self.conn
675
+
676
+ def close(self) -> None:
677
+ with contextlib.suppress(sqlite3.Error):
678
+ self.conn.close()
679
+
680
+
681
+ def evaluate_all(
682
+ repo_root: Path, scenarios: Sequence[ImpactScenario],
683
+ work_dir: Path,
684
+ ) -> dict[str, Any]:
685
+ """Roda todos os cenários: uma indexação real por (fixture,
686
+ adapters, cross) seguida dos impacts de cada cenário nela."""
687
+ results: dict[str, Any] = {"schema": RESULTS_SCHEMA,
688
+ "scenarios": {}}
689
+ indexed: dict[tuple[str, tuple[str, ...], bool],
690
+ _IndexedFixture] = {}
691
+ for sc in scenarios:
692
+ key = (sc.fixture, sc.domains, sc.cross_domain)
693
+ fx = indexed.get(key)
694
+ if fx is None:
695
+ slot = work_dir / f"ws-{len(indexed)}"
696
+ data_dir, config_dir = slot / "data", slot / "cfg"
697
+ data_dir.mkdir(parents=True)
698
+ config_dir.mkdir(parents=True)
699
+ run = index_fixture(
700
+ repo_root / sc.fixture, sc.domains,
701
+ sc.cross_domain,
702
+ data_dir=data_dir, config_dir=config_dir)
703
+ fx = _IndexedFixture(key, data_dir, config_dir, run)
704
+ indexed[key] = fx
705
+ if fx.index_run.exit_code != 0:
706
+ results["scenarios"][sc.scenario_id] = {
707
+ "id": sc.scenario_id,
708
+ "error": f"index exit {fx.index_run.exit_code}: "
709
+ f"{fx.index_run.stderr.strip()[:200]}",
710
+ }
711
+ continue
712
+ conn = fx.open()
713
+ from code_atlas.env_override import patched_env
714
+ with patched_env({
715
+ "CODE_ATLAS_DATA_DIR": str(fx.data_dir),
716
+ "CODE_ATLAS_CONFIG_DIR": str(fx.config_dir),
717
+ }):
718
+ try:
719
+ results["scenarios"][sc.scenario_id] = \
720
+ evaluate_scenario(
721
+ repo_root, sc, conn,
722
+ data_dir=fx.data_dir,
723
+ config_dir=fx.config_dir)
724
+ finally:
725
+ fx.close()
726
+
727
+ # sumário agregado — micro-média sobre cenários com conjunto
728
+ # esperado não-vazio; vazios honestos contam como cobertura, não
729
+ # como recall inventado.
730
+ total_tp = total_fp = total_fn = 0
731
+ leaks_total = evidence_total = absent_total = 0
732
+ truncation_bad: list[str] = []
733
+ empty_but_seed_ok: list[str] = []
734
+ for sid_name, rep in results["scenarios"].items():
735
+ if "verified" not in rep:
736
+ continue
737
+ total_tp += int(rep["verified"]["tp"])
738
+ total_fp += int(rep["verified"]["fp"])
739
+ total_fn += int(rep["verified"]["fn"])
740
+ leaks_total += int(rep["false_confidence"]["count"])
741
+ evidence_total += len(rep["evidence_errors"])
742
+ absent_total += len(rep["absent_violations"])
743
+ if rep["truncation"].get("honest") is False:
744
+ truncation_bad.append(sid_name)
745
+ if rep["candidates"]["unique"] == 0 and rep[
746
+ "index_independent_check"]["seed_entity_rows"] > 0:
747
+ empty_but_seed_ok.append(sid_name)
748
+ verified_micro = _pr(total_tp, total_fp, total_fn)
749
+ results["summary"] = {
750
+ "scenarios": len(results["scenarios"]),
751
+ "verified_micro": verified_micro,
752
+ "false_confidence_leaks": leaks_total,
753
+ "evidence_errors": evidence_total,
754
+ "absent_violations": absent_total,
755
+ "truncation_dishonest": truncation_bad,
756
+ "empty_verified_blasts": empty_but_seed_ok,
757
+ }
758
+ return results