code-atlas-forge 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|