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,688 @@
1
+ """Verdade-terreno independente para adapters de domínio (lf-0129, Cycle 8).
2
+
3
+ Complemento do harness golden (lf-0124): os goldens são inventários
4
+ curados do comportamento *observado*; os truth sets em
5
+ ``evaluation/ground_truth/<domain>.json`` são rótulos **manuais**
6
+ escritos a partir da leitura direta de cada arquivo da fixture —
7
+ não derivados da saída do adapter. O diff rótulo↔extração produz
8
+ ``TRUE_POSITIVE`` / ``FALSE_POSITIVE`` / ``FALSE_NEGATIVE`` por classe
9
+ (entidade, relação, diagnóstico) e precision/recall/F1 por domínio.
10
+
11
+ Schema do truth set (``schema: 1``)::
12
+
13
+ {
14
+ "schema": 1, "domain": "sql", "fixture": "tests/fixtures/...",
15
+ "entities": {
16
+ "complete": true|false, # true = lista exaustiva rotulada
17
+ "expected": [ <regra-entidade>, ... ],
18
+ "forbidden": [ <regra-entidade>, ... ]
19
+ },
20
+ "relations": { "complete": ..., "expected": [...], "forbidden": [...] },
21
+ "diagnostics_expected": [ <regra-diag>, ... ],
22
+ "patterns_forbidden": [ {"substr": "...", "note": "..."}, ... ]
23
+ }
24
+
25
+ Semântica de casamento (por regra — todos os campos presentes devem
26
+ casar; ausente = não filtra):
27
+
28
+ - ``<campo>`` valor exato (``name``, ``type``, ``from``, ``to``,
29
+ ``code``, ``path``, ``resolver``, ``severity``);
30
+ - ``<campo>_prefix`` prefixo do valor renderizado;
31
+ - ``<campo>_contains`` substring do valor renderizado;
32
+ - ``status``/``confidence`` string ou lista de strings (qualquer uma);
33
+ - ``kind`` ``domain_entities.kind`` persistido;
34
+ - ``count`` (expected) exige exatamente N matches — a mais
35
+ vira FP (over-extraction), a menos vira FN;
36
+ - ``max_count`` (forbidden) só os matches além de N viram FP —
37
+ para casos "até N identidades legítimas" (mesma tabela, contas
38
+ distintas);
39
+ - ``min_count`` (diagnostics) exige ≥N diagnósticos casando.
40
+
41
+ Contabilização por classe:
42
+
43
+ - ``TP`` = itens extraídos cobertos por regras expected (dedup por
44
+ item) e não marcados como fabricação/kind-mismatch;
45
+ - ``FN`` = fatos rotulados ausentes (regra sem match ou shortfall de
46
+ ``count``);
47
+ - ``FP`` = itens extraídos casando regras forbidden + excedentes de
48
+ ``count`` + (``complete``) qualquer item não coberto por expected —
49
+ fabricação real, listada item a item;
50
+ - ``unaudited`` = itens fora de todo rótulo (classe não ``complete``) —
51
+ reportados como transparência, nunca pontuados;
52
+ - conflito expected∩forbidden = erro de rotulagem — o item conta FP e
53
+ é listado em ``conflicts``.
54
+
55
+ ``patterns_forbidden`` varre nomes/qualified_name/atributos de
56
+ entidades e ocorrências + endpoints de arestas + diagnósticos — cada
57
+ ocorrência do substring vira um FP de classe ``patterns`` (vazamento de
58
+ segredo/dado sensível é fabricação de conteúdo).
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import json
64
+ from collections.abc import Callable, Mapping, Sequence
65
+ from dataclasses import dataclass, field
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ TRUTH_SCHEMA = 1
70
+
71
+ #: campos textuais de aresta com trio exact/prefix/contains
72
+ _EDGE_TEXT_FIELDS = ("type", "from", "to", "resolver")
73
+ #: campos de aresta com valor único ou lista de aceitos
74
+ _EDGE_CHOICE_FIELDS = ("status", "confidence")
75
+ #: campos textuais de diagnóstico
76
+ _DIAG_TEXT_FIELDS = ("code", "path")
77
+
78
+
79
+ def _text_ok(rule: Mapping[str, Any], field_name: str, value: str) -> bool:
80
+ """Trio exact/prefix/contains — todos os presentes devem casar."""
81
+ exact = rule.get(field_name)
82
+ if exact is not None and value != str(exact):
83
+ return False
84
+ prefix = rule.get(f"{field_name}_prefix")
85
+ if prefix is not None and not value.startswith(str(prefix)):
86
+ return False
87
+ contains = rule.get(f"{field_name}_contains")
88
+ if contains is None:
89
+ return True
90
+ if isinstance(contains, str):
91
+ return contains in value
92
+ # lista: TODOS os substrings precisam aparecer no valor
93
+ return all(str(c) in value for c in contains)
94
+
95
+
96
+ def _choice_ok(rule: Mapping[str, Any], field_name: str, value: str) -> bool:
97
+ """Campo escalar ou lista-de-aceitos; ausente = não filtra."""
98
+ want = rule.get(field_name)
99
+ if want is None:
100
+ return True
101
+ if isinstance(want, str):
102
+ return value == want
103
+ if isinstance(want, Sequence):
104
+ return value in {str(w) for w in want}
105
+ return False
106
+
107
+
108
+ def entity_rule_matches(rule: Mapping[str, Any], name: str, kind_of: Callable[[str], str]) -> bool:
109
+ """``name`` casa o trio textual; ``kind`` compara o kind persistido."""
110
+ if not _text_ok(rule, "name", name):
111
+ return False
112
+ want_kind = rule.get("kind")
113
+ if want_kind is None:
114
+ return True
115
+ return kind_of(name) == str(want_kind)
116
+
117
+
118
+ def entity_rule_matches_text(rule: Mapping[str, Any], name: str) -> bool:
119
+ """Só o trio textual (kind conferido à parte — v. scorer)."""
120
+ return _text_ok(rule, "name", name)
121
+
122
+
123
+ def edge_rule_matches(rule: Mapping[str, Any], edge: Mapping[str, Any]) -> bool:
124
+ for f in _EDGE_TEXT_FIELDS:
125
+ if not _text_ok(rule, f, str(edge.get(f, ""))):
126
+ return False
127
+ return all(_choice_ok(rule, f, str(edge.get(f, ""))) for f in _EDGE_CHOICE_FIELDS)
128
+
129
+
130
+ def diagnostic_rule_matches(rule: Mapping[str, Any], diag: Mapping[str, Any]) -> bool:
131
+ for f in _DIAG_TEXT_FIELDS:
132
+ if not _text_ok(rule, f, str(diag.get(f, ""))):
133
+ return False
134
+ return _choice_ok(rule, "severity", str(diag.get("severity", "")))
135
+
136
+
137
+ def _rule_desc(rule: Mapping[str, Any]) -> str:
138
+ """Renderização legível de uma regra para listas de diff."""
139
+ parts = {k: v for k, v in rule.items() if k not in {"note", "count", "max_count", "min_count"}}
140
+ note = rule.get("note")
141
+ body = " ".join(f"{k}={v!r}" for k, v in sorted(parts.items()))
142
+ return f"{body} # {note}" if note else body
143
+
144
+
145
+ def _prf(tp: int, fp: int, fn: int) -> dict[str, float | None]:
146
+ p = tp / (tp + fp) if tp + fp else None
147
+ r = tp / (tp + fn) if tp + fn else None
148
+ f1 = 2 * p * r / (p + r) if p is not None and r is not None and p + r > 0 else None
149
+ return {
150
+ "precision": round(p, 4) if p is not None else None,
151
+ "recall": round(r, 4) if r is not None else None,
152
+ "f1": round(f1, 4) if f1 is not None else None,
153
+ }
154
+
155
+
156
+ @dataclass
157
+ class _ClassScore:
158
+ """TP/FP/FN de uma classe (entities ou relations)."""
159
+
160
+ complete: bool = False
161
+ extracted: int = 0
162
+ labeled_rules: int = 0
163
+ tp: int = 0
164
+ fp: int = 0
165
+ fn: int = 0
166
+ unaudited: int = 0
167
+ missing: list[str] = field(default_factory=list)
168
+ fabricated: list[dict[str, str]] = field(default_factory=list)
169
+ unexpected: list[str] = field(default_factory=list)
170
+ kind_mismatches: list[str] = field(default_factory=list)
171
+ conflicts: list[str] = field(default_factory=list)
172
+
173
+ def metrics(self) -> dict[str, float | None]:
174
+ return _prf(self.tp, self.fp, self.fn)
175
+
176
+ def to_dict(self) -> dict[str, Any]:
177
+ out: dict[str, Any] = {
178
+ "complete": self.complete,
179
+ "extracted": self.extracted,
180
+ "labeled_rules": self.labeled_rules,
181
+ "tp": self.tp,
182
+ "fp": self.fp,
183
+ "fn": self.fn,
184
+ "unaudited": self.unaudited,
185
+ **self.metrics(),
186
+ }
187
+ for key in (
188
+ "missing",
189
+ "fabricated",
190
+ "unexpected",
191
+ "kind_mismatches",
192
+ "conflicts",
193
+ ):
194
+ vals = getattr(self, key)
195
+ if vals:
196
+ out[key] = vals
197
+ return out
198
+
199
+
200
+ def _score_class(
201
+ items: Sequence[str],
202
+ spec: Mapping[str, Any],
203
+ match_text: Callable[[Mapping[str, Any], str], bool],
204
+ kind_bad: set[int] | None = None,
205
+ ) -> _ClassScore:
206
+ """Núcleo do diff: ``items`` são as chaves renderizadas extraídas.
207
+
208
+ ``match_text`` decide se um item casa uma regra (trio textual +
209
+ campos de escolha, SEM ``kind`` — mismatches de kind são
210
+ contabilizados à parte via ``kind_bad``).
211
+ """
212
+ score = _ClassScore(
213
+ complete=bool(spec.get("complete", False)),
214
+ extracted=len(items),
215
+ )
216
+ expected = [r for r in spec.get("expected", ()) if isinstance(r, Mapping)]
217
+ forbidden = [r for r in spec.get("forbidden", ()) if isinstance(r, Mapping)]
218
+ score.labeled_rules = len(expected) + len(forbidden)
219
+
220
+ covered: set[int] = set()
221
+ over: set[int] = set()
222
+ bad: set[int] = set()
223
+ hits_of: dict[int, list[int]] = {}
224
+
225
+ for rule in expected:
226
+ hits = sorted(i for i, it in enumerate(items) if match_text(rule, it))
227
+ want = rule.get("count")
228
+ if want is None:
229
+ if hits:
230
+ covered.update(hits)
231
+ else:
232
+ score.fn += 1
233
+ score.missing.append(_rule_desc(rule))
234
+ continue
235
+ k = int(want)
236
+ if len(hits) < k:
237
+ score.fn += k - len(hits)
238
+ score.missing.append(f"{_rule_desc(rule)} # count: expected {k}, got {len(hits)}")
239
+ covered.update(hits[:k])
240
+ over.update(hits[k:])
241
+ hits_of[id(rule)] = hits
242
+
243
+ for rule in forbidden:
244
+ hits = sorted(i for i, it in enumerate(items) if match_text(rule, it))
245
+ maxc = rule.get("max_count")
246
+ surplus = hits if maxc is None else hits[int(maxc) :]
247
+ for i in surplus:
248
+ bad.add(i)
249
+ score.fabricated.append({"item": items[i], "rule": _rule_desc(rule)})
250
+
251
+ if score.complete:
252
+ for i, it in enumerate(items):
253
+ if i not in covered and i not in over and i not in bad:
254
+ score.unexpected.append(it)
255
+ bad.add(i)
256
+
257
+ kind_bad = kind_bad or set()
258
+ fp_items = bad | over
259
+ score.conflicts = sorted(items[i] for i in covered & fp_items)
260
+ score.tp = len(covered - fp_items - kind_bad)
261
+ score.fp = len(fp_items)
262
+ score.unaudited = len(items) - len(covered | fp_items)
263
+ return score
264
+
265
+
266
+ def _kind_mismatch_idx(
267
+ expected: Sequence[Any],
268
+ entity_names: Sequence[str],
269
+ kind_of: Callable[[str], str],
270
+ ) -> set[int]:
271
+ """Índices de entidades cujo kind persistido difere do rotulado.
272
+
273
+ Match textual ok mas kind difere — o item existe (não é fabricação)
274
+ mas o fato "X com kind K" não foi extraído: sai do TP e vira linha
275
+ própria do diff (``kind_mismatches``).
276
+ """
277
+ bad: set[int] = set()
278
+ for rule in expected:
279
+ if not isinstance(rule, Mapping) or "kind" not in rule:
280
+ continue
281
+ for i, name in enumerate(entity_names):
282
+ if entity_rule_matches_text(rule, name) and kind_of(name) != str(rule["kind"]):
283
+ bad.add(i)
284
+ return bad
285
+
286
+
287
+ def _pattern_violations(
288
+ patterns: Sequence[Any],
289
+ scan_texts: Sequence[tuple[str, str]],
290
+ ) -> list[dict[str, str]]:
291
+ """Ocorrências de substrings proibidos no corpus varrido."""
292
+ hits: list[dict[str, str]] = []
293
+ for pat in patterns:
294
+ if not isinstance(pat, Mapping):
295
+ continue
296
+ needle = str(pat.get("substr", ""))
297
+ if not needle:
298
+ continue
299
+ for loc, text in scan_texts:
300
+ if needle in text:
301
+ hits.append(
302
+ {
303
+ "substr": needle,
304
+ "location": loc,
305
+ "note": str(pat.get("note", "")),
306
+ }
307
+ )
308
+ return hits
309
+
310
+
311
+ def score_domain(
312
+ truth: Mapping[str, Any],
313
+ report: Mapping[str, Any],
314
+ *,
315
+ kind_of: Callable[[str], str] | None = None,
316
+ scan_texts: Sequence[tuple[str, str]] = (),
317
+ ) -> dict[str, Any]:
318
+ """Compara um truth set com o relatório persistido do domínio.
319
+
320
+ ``report`` é o retorno de ``run_domain_eval`` (entidades como
321
+ display names, arestas renderizadas, diagnósticos). ``kind_of``
322
+ resolve display→kind persistido (para regras com ``kind`` e para
323
+ ``kind_mismatches``). ``scan_texts`` são pares ``(local, texto)``
324
+ varridos por ``patterns_forbidden``.
325
+ """
326
+ kind_of = kind_of or (lambda _name: "")
327
+ entity_names = [str(n) for n in (report.get("entities", {}) or {}).get("names", ())]
328
+ edges = [e for e in report.get("edges", ()) if isinstance(e, Mapping)]
329
+ diags = [d for d in report.get("diagnostics", ()) if isinstance(d, Mapping)]
330
+ edge_keys = [
331
+ f"{e.get('type')}|{e.get('from')}|{e.get('to')}|"
332
+ f"{e.get('status')}|{e.get('confidence')}|{e.get('resolver')}"
333
+ for e in edges
334
+ ]
335
+
336
+ e_spec = truth.get("entities", {}) or {}
337
+ kind_bad_idx = _kind_mismatch_idx(e_spec.get("expected", ()), entity_names, kind_of)
338
+ ent = _score_class(
339
+ entity_names,
340
+ e_spec,
341
+ entity_rule_matches_text,
342
+ kind_bad=kind_bad_idx,
343
+ )
344
+ # o fato "X com kind K" não existe → cada mismatch é um FN do rótulo
345
+ ent.fn += len(kind_bad_idx)
346
+ for i in sorted(kind_bad_idx):
347
+ ent.kind_mismatches.append(
348
+ f"{entity_names[i]} # kind persistido: {kind_of(entity_names[i])}"
349
+ )
350
+
351
+ rel = _score_edges(edge_keys, edges, truth.get("relations", {}) or {})
352
+
353
+ diag_score = _score_diagnostics(diags, truth.get("diagnostics_expected", ()))
354
+
355
+ pattern_hits = _pattern_violations(truth.get("patterns_forbidden", ()), scan_texts)
356
+
357
+ totals = {
358
+ "tp": ent.tp + rel.tp + diag_score["tp"],
359
+ "fp": ent.fp + rel.fp + len(pattern_hits),
360
+ "fn": ent.fn + rel.fn + diag_score["fn"],
361
+ }
362
+ overall = {**totals, **_prf(totals["tp"], totals["fp"], totals["fn"])}
363
+
364
+ return {
365
+ "domain": truth.get("domain", report.get("domain", "")),
366
+ "fixture": truth.get("fixture", report.get("fixture", "")),
367
+ "index_status": report.get("index_status", ""),
368
+ "entities": ent.to_dict(),
369
+ "relations": rel.to_dict(),
370
+ "diagnostics": diag_score,
371
+ "patterns_forbidden": {
372
+ "rules": len(truth.get("patterns_forbidden", ())),
373
+ "violations": pattern_hits,
374
+ },
375
+ "overall": overall,
376
+ }
377
+
378
+
379
+ def _score_edges(
380
+ edge_keys: Sequence[str],
381
+ edges: Sequence[Mapping[str, Any]],
382
+ spec: Mapping[str, Any],
383
+ ) -> _ClassScore:
384
+ """Mesma contabilização de ``_score_class`` mas casando dicts."""
385
+ score = _ClassScore(
386
+ complete=bool(spec.get("complete", False)),
387
+ extracted=len(edges),
388
+ )
389
+ expected = [r for r in spec.get("expected", ()) if isinstance(r, Mapping)]
390
+ forbidden = [r for r in spec.get("forbidden", ()) if isinstance(r, Mapping)]
391
+ score.labeled_rules = len(expected) + len(forbidden)
392
+
393
+ covered: set[int] = set()
394
+ over: set[int] = set()
395
+ bad: set[int] = set()
396
+
397
+ for rule in expected:
398
+ hits = sorted(i for i, e in enumerate(edges) if edge_rule_matches(rule, e))
399
+ want = rule.get("count")
400
+ if want is None:
401
+ if hits:
402
+ covered.update(hits)
403
+ else:
404
+ score.fn += 1
405
+ score.missing.append(_rule_desc(rule))
406
+ continue
407
+ k = int(want)
408
+ if len(hits) < k:
409
+ score.fn += k - len(hits)
410
+ score.missing.append(f"{_rule_desc(rule)} # count: expected {k}, got {len(hits)}")
411
+ covered.update(hits[:k])
412
+ over.update(hits[k:])
413
+
414
+ for rule in forbidden:
415
+ hits = sorted(i for i, e in enumerate(edges) if edge_rule_matches(rule, e))
416
+ maxc = rule.get("max_count")
417
+ surplus = hits if maxc is None else hits[int(maxc) :]
418
+ for i in surplus:
419
+ bad.add(i)
420
+ score.fabricated.append({"item": edge_keys[i], "rule": _rule_desc(rule)})
421
+
422
+ if score.complete:
423
+ for i, key in enumerate(edge_keys):
424
+ if i not in covered and i not in over and i not in bad:
425
+ score.unexpected.append(key)
426
+ bad.add(i)
427
+
428
+ fp_items = bad | over
429
+ score.conflicts = sorted(edge_keys[i] for i in covered & fp_items)
430
+ score.tp = len(covered - fp_items)
431
+ score.fp = len(fp_items)
432
+ score.unaudited = len(edges) - len(covered | fp_items)
433
+ return score
434
+
435
+
436
+ def _score_diagnostics(
437
+ diags: Sequence[Mapping[str, Any]],
438
+ expected: Sequence[Any],
439
+ ) -> dict[str, Any]:
440
+ rules = [r for r in expected if isinstance(r, Mapping)]
441
+ tp = fn = 0
442
+ missing: list[str] = []
443
+ for rule in rules:
444
+ need = int(rule.get("min_count", 1))
445
+ hits = sum(1 for d in diags if diagnostic_rule_matches(rule, d))
446
+ if hits >= need:
447
+ tp += 1
448
+ else:
449
+ fn += 1
450
+ missing.append(f"{_rule_desc(rule)} # min_count: expected {need}, got {hits}")
451
+ return {
452
+ "labeled_rules": len(rules),
453
+ "extracted": len(diags),
454
+ "tp": tp,
455
+ "fp": 0,
456
+ "fn": fn,
457
+ "missing": missing,
458
+ **_prf(tp, 0, fn),
459
+ }
460
+
461
+
462
+ def load_truth(truth_dir: Path, domain: str) -> dict[str, Any] | None:
463
+ path = Path(truth_dir) / f"{domain}.json"
464
+ if not path.is_file():
465
+ return None
466
+ data = json.loads(path.read_text(encoding="utf-8"))
467
+ return data if isinstance(data, dict) else None
468
+
469
+
470
+ # ---------------------------------------------------------------------
471
+ # pipeline real + extras persistidos (kind, corpus de padrões)
472
+ # ---------------------------------------------------------------------
473
+
474
+
475
+ def _display(identity_key_str: str) -> str:
476
+ """``identity_key`` canônica → display ``dom:cat.ns.name@env``."""
477
+ from code_atlas.domains.identity import DomainIdentity
478
+
479
+ segs = DomainIdentity.parse_key(identity_key_str)
480
+ if segs is None or len(segs) < 7:
481
+ return identity_key_str
482
+ domain, repository, root, catalog, namespace, env, qname = segs[:7]
483
+ return DomainIdentity(
484
+ domain=domain,
485
+ repository=repository,
486
+ root=root,
487
+ catalog=catalog,
488
+ namespace=namespace,
489
+ environment=env,
490
+ qualified_name=qname,
491
+ ).display()
492
+
493
+
494
+ def read_snapshot_extras(
495
+ db_path: Path, snapshot_id: int
496
+ ) -> tuple[dict[str, str], list[tuple[str, str]]]:
497
+ """Kind por entidade + corpus textual para ``patterns_forbidden``.
498
+
499
+ Reabre o ``atlas.db`` do cenário (persistido por ``run_domain_eval``)
500
+ e lê: ``kind`` de ``domain_entities`` (checagem de ``kind`` nas
501
+ regras) e os textos varridos por padrões proibidos — identity_key,
502
+ qualified_name, name e atributos JSON de entidades e ocorrências,
503
+ ``to_unresolved`` de arestas.
504
+ """
505
+ from code_atlas.storage import connect
506
+
507
+ kind_of: dict[str, str] = {}
508
+ corpus: list[tuple[str, str]] = []
509
+ conn = connect(db_path)
510
+ try:
511
+ for row in conn.execute(
512
+ "SELECT identity_key, kind, qualified_name, name, attributes FROM domain_entities"
513
+ ):
514
+ disp = _display(str(row["identity_key"]))
515
+ kind_of[disp] = str(row["kind"])
516
+ corpus.append((f"entity:{disp}", str(row["identity_key"])))
517
+ corpus.append((f"entity-qname:{disp}", str(row["qualified_name"])))
518
+ corpus.append((f"entity-name:{disp}", str(row["name"])))
519
+ corpus.append((f"entity-attrs:{disp}", str(row["attributes"])))
520
+ for row in conn.execute(
521
+ "SELECT deo.attributes, de.identity_key"
522
+ " FROM domain_entity_occurrences deo"
523
+ " JOIN domain_entities de ON de.id = deo.entity_id"
524
+ " WHERE deo.snapshot_id = ?",
525
+ (snapshot_id,),
526
+ ):
527
+ corpus.append(
528
+ (
529
+ "occurrence-attrs:" + _display(str(row["identity_key"])),
530
+ str(row["attributes"]),
531
+ )
532
+ )
533
+ for row in conn.execute(
534
+ "SELECT type, to_unresolved FROM domain_relations"
535
+ " WHERE snapshot_id = ? AND to_unresolved IS NOT NULL",
536
+ (snapshot_id,),
537
+ ):
538
+ corpus.append(
539
+ (
540
+ f"relation-unresolved:{row['type']}",
541
+ str(row["to_unresolved"]),
542
+ )
543
+ )
544
+ finally:
545
+ conn.close()
546
+ return kind_of, corpus
547
+
548
+
549
+ def _snapshot_id(db_path: Path) -> int | None:
550
+ from code_atlas.storage import connect
551
+
552
+ conn = connect(db_path)
553
+ try:
554
+ row = conn.execute("SELECT MAX(snapshot_id) s FROM domain_entity_occurrences").fetchone()
555
+ if row is not None and row["s"] is not None:
556
+ return int(row["s"])
557
+ row = conn.execute("SELECT MAX(id) s FROM snapshots").fetchone()
558
+ return int(row["s"]) if row is not None and row["s"] is not None else None
559
+ finally:
560
+ conn.close()
561
+
562
+
563
+ def run_truth_eval(
564
+ repo_root: Path,
565
+ eval_id: str,
566
+ work_dir: Path,
567
+ truth: Mapping[str, Any],
568
+ ) -> dict[str, Any]:
569
+ """Pipeline real (``run_domain_eval``) + diff contra o truth set.
570
+
571
+ O snapshot é isolado: um ``<eval_id>.db`` por domínio dentro de
572
+ ``work_dir`` — o mesmo isolamento do harness golden. Retorna o
573
+ bloco de score de ``score_domain`` enriquecido com os totais
574
+ extraídos (transparência do universo medido).
575
+ """
576
+ from code_atlas.eval.domain_eval import run_domain_eval, spec_by_id
577
+
578
+ spec = spec_by_id(eval_id)
579
+ report: dict[str, Any] = run_domain_eval(Path(repo_root), spec, Path(work_dir))
580
+ if report.get("index_status") != "success":
581
+ return {
582
+ "domain": eval_id,
583
+ "index_status": report.get("index_status"),
584
+ "error": report.get("error"),
585
+ }
586
+
587
+ db_path = Path(work_dir) / f"{eval_id}.db"
588
+ kind_of: dict[str, str] = {}
589
+ corpus: list[tuple[str, str]] = []
590
+ snapshot_id = _snapshot_id(db_path) if db_path.is_file() else None
591
+ if snapshot_id is not None:
592
+ kind_of, corpus = read_snapshot_extras(db_path, snapshot_id)
593
+ for e in report.get("edges", ()):
594
+ corpus.append((f"edge:{e['type']}", f"{e['from']} {e['to']}"))
595
+ for d in report.get("diagnostics", ()):
596
+ corpus.append((f"diagnostic:{d['code']}", f"{d['severity']} {d['path']}"))
597
+
598
+ scored = score_domain(
599
+ truth,
600
+ report,
601
+ kind_of=lambda n: kind_of.get(n, ""),
602
+ scan_texts=corpus,
603
+ )
604
+ entities = report.get("entities", {})
605
+ relations = report.get("relations", {})
606
+ scored["extracted_totals"] = {
607
+ "entities": entities.get("total"),
608
+ "relations": relations.get("total"),
609
+ "diagnostics": len(report.get("diagnostics", ())),
610
+ "edges_skipped": relations.get("edges_skipped"),
611
+ }
612
+ return scored
613
+
614
+
615
+ _ENT_FIELDS = {"name", "name_prefix", "name_contains", "kind", "count", "max_count", "note"}
616
+ _REL_FIELDS = {
617
+ "type",
618
+ "from",
619
+ "to",
620
+ "status",
621
+ "confidence",
622
+ "resolver",
623
+ "type_prefix",
624
+ "from_prefix",
625
+ "to_prefix",
626
+ "resolver_prefix",
627
+ "type_contains",
628
+ "from_contains",
629
+ "to_contains",
630
+ "resolver_contains",
631
+ "count",
632
+ "max_count",
633
+ "note",
634
+ }
635
+ _DIAG_FIELDS = {
636
+ "code",
637
+ "code_prefix",
638
+ "code_contains",
639
+ "path",
640
+ "path_prefix",
641
+ "path_contains",
642
+ "severity",
643
+ "min_count",
644
+ "note",
645
+ }
646
+
647
+
648
+ def _validate_class_rules(truth: Mapping[str, Any], cls: str, fields: set[str]) -> list[str]:
649
+ """Valida ``expected``/``forbidden`` de uma classe (entities/relations)."""
650
+ errs: list[str] = []
651
+ spec = truth.get(cls, {}) or {}
652
+ if not isinstance(spec, Mapping):
653
+ return [f"{cls}: not a mapping"]
654
+ for group in ("expected", "forbidden"):
655
+ for i, rule in enumerate(spec.get(group, ())):
656
+ if not isinstance(rule, Mapping):
657
+ errs.append(f"{cls}.{group}[{i}]: not a mapping")
658
+ continue
659
+ extra = set(rule) - fields
660
+ if extra:
661
+ errs.append(f"{cls}.{group}[{i}]: unknown {sorted(extra)}")
662
+ if group == "expected" and not any(
663
+ k in rule for k in fields if not k.endswith(("count", "note"))
664
+ ):
665
+ errs.append(f"{cls}.{group}[{i}]: rule has no criteria")
666
+ return errs
667
+
668
+
669
+ def validate_truth(truth: Mapping[str, Any]) -> list[str]:
670
+ """Erros estruturais do truth set — schema, campos e tipos."""
671
+ errs: list[str] = []
672
+ if int(truth.get("schema", -1)) != TRUTH_SCHEMA:
673
+ errs.append(f"schema: expected {TRUTH_SCHEMA}, got {truth.get('schema')}")
674
+ if not truth.get("fixture"):
675
+ errs.append("missing fixture")
676
+ errs += _validate_class_rules(truth, "entities", _ENT_FIELDS)
677
+ errs += _validate_class_rules(truth, "relations", _REL_FIELDS)
678
+ for i, rule in enumerate(truth.get("diagnostics_expected", ())):
679
+ if not isinstance(rule, Mapping):
680
+ errs.append(f"diagnostics_expected[{i}]: not a mapping")
681
+ continue
682
+ extra = set(rule) - _DIAG_FIELDS
683
+ if extra:
684
+ errs.append(f"diagnostics_expected[{i}]: unknown {sorted(extra)}")
685
+ for i, pat in enumerate(truth.get("patterns_forbidden", ())):
686
+ if not isinstance(pat, Mapping) or not pat.get("substr"):
687
+ errs.append(f"patterns_forbidden[{i}]: missing substr")
688
+ return errs