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,57 @@
1
+ """Evidência de fatos de domínio (lf-0110, Cycle 7).
2
+
3
+ Nenhum tipo novo: evidência de domínio **é** ``EvidenceRecord`` — a mesma
4
+ tabela ``evidence``, o mesmo envelope (extractor + versão + content_hash +
5
+ span). O que muda é a convenção de identificação do produtor:
6
+
7
+ - ``extractor`` = ``domain:<domain_id>`` (ex.: ``domain:sql``);
8
+ - ``extractor_version`` = ``adapter_version`` do adapter;
9
+ - ``resolver_id``/``resolver_version``/``evidence_tier`` preenchidos só
10
+ quando a evidência veio de um resolvedor (mesma semântica de lf-0035);
11
+ - ``content_hash`` = sha256 do artefato observado — casa 1:1 com o
12
+ arquivo do inventário (evidence-first: nenhum fato sem trecho real).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
18
+
19
+ # Alias documentado — evidência de domínio reutiliza o envelope canônico.
20
+ DomainEvidence = EvidenceRecord
21
+
22
+ DOMAIN_EXTRACTOR_PREFIX = "domain:"
23
+
24
+
25
+ def domain_extractor_id(domain_id: str) -> str:
26
+ """``extractor`` canônico de um adapter de domínio."""
27
+ return f"{DOMAIN_EXTRACTOR_PREFIX}{domain_id}"
28
+
29
+
30
+ def evidence_record(
31
+ *,
32
+ adapter_domain: str,
33
+ adapter_version: str,
34
+ path: str,
35
+ content_hash: str,
36
+ span: SourceSpan | None = None,
37
+ notes: str = "",
38
+ resolver_id: str = "",
39
+ resolver_version: str = "",
40
+ evidence_tier: int = 0,
41
+ index_hash: str = "",
42
+ validation_status: str = "",
43
+ ) -> EvidenceRecord:
44
+ """Fábrica tipada de ``EvidenceRecord`` com convenção de domínio."""
45
+ return EvidenceRecord(
46
+ path=path,
47
+ span=span,
48
+ content_hash=content_hash,
49
+ extractor=domain_extractor_id(adapter_domain),
50
+ extractor_version=adapter_version,
51
+ notes=notes,
52
+ resolver_id=resolver_id,
53
+ resolver_version=resolver_version,
54
+ evidence_tier=evidence_tier,
55
+ index_hash=index_hash,
56
+ validation_status=validation_status,
57
+ )
@@ -0,0 +1,149 @@
1
+ """Identidade canônica de entidades de domínio (lf-0110, Cycle 7).
2
+
3
+ Regra inegociável: duas entidades com o mesmo nome em escopos distintos
4
+ NÃO podem colidir — duas tabelas ``orders`` em catálogos diferentes, dois
5
+ ``aws_s3_bucket.logs`` em contas/regiões distintas têm identidades
6
+ diferentes. A chave canônica é posicional e **escapada por segmento**
7
+ (percent-encoding), portanto separadores dentro de um componente
8
+ (``:``, ``/``, ``.``) nunca se confundem com o separador estrutural.
9
+
10
+ Componentes, em ordem fixa:
11
+
12
+ 1. ``domain`` — id do domínio do adapter (``sql``, ``terraform``, …);
13
+ 2. ``repository`` — ``identity_key`` lógica do repositório (lf-0021);
14
+ 3. ``root`` — sub-raiz dentro do repo/monorepo (``""`` = raiz);
15
+ 4. ``catalog`` — catálogo/conta/região/projeto que qualifica o
16
+ namespace (ex.: ``glue:123456789012:us-east-1``, ``aws:…:eu-west-1``);
17
+ 5. ``namespace`` — prefixo intermediário (schema, database, module);
18
+ 6. ``environment`` — qualificador opcional (``dev``/``prod``); ``""``
19
+ quando o adapter não distingue ambientes;
20
+ 7. ``qualified_name`` — nome qualificado definido pelo adapter;
21
+ 8. ``snapshot`` — escopo opcional de revisão (commit/digest); ``""``
22
+ para identidade **lógica** (vale por repositório, como ``symbols``).
23
+
24
+ ``identity_key`` persistida usa sempre a forma lógica (``snapshot=""``);
25
+ a ocorrência por snapshot é quem carrega o vínculo temporal — mesmo
26
+ desenho de ``symbols``/``symbol_occurrences``.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+
33
+ _SCHEME = "dom"
34
+ _SEP = "/"
35
+
36
+ # Percent-encoding próprio: ``urllib.parse`` é proibido fora de ``api/``
37
+ # (auditoria de rede lf-0086). Safe-set = RFC 3986 unreserved.
38
+ _SAFE = frozenset(
39
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-~"
40
+ )
41
+
42
+
43
+ def _enc(part: str) -> str:
44
+ """Escapa um segmento — todo byte fora do safe-set vira ``%XX``.
45
+
46
+ Cobre ``:``, ``/``, ``.``, espaço e UTF-8 multibyte: separadores
47
+ estruturais nunca se confundem com conteúdo do componente.
48
+ """
49
+ out: list[str] = []
50
+ for b in part.encode("utf-8"):
51
+ ch = chr(b)
52
+ out.append(ch if ch in _SAFE else f"%{b:02X}")
53
+ return "".join(out)
54
+
55
+
56
+ def _dec(part: str) -> str:
57
+ """Inverso de ``_enc`` — ``%XX`` malformado é literal, nunca crash."""
58
+ out = bytearray()
59
+ i, n = 0, len(part)
60
+ while i < n:
61
+ ch = part[i]
62
+ if ch == "%" and i + 3 <= n:
63
+ try:
64
+ out.append(int(part[i + 1:i + 3], 16))
65
+ i += 3
66
+ continue
67
+ except ValueError:
68
+ pass
69
+ out.extend(ch.encode("utf-8"))
70
+ i += 1
71
+ return out.decode("utf-8")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class DomainIdentity:
76
+ """Identidade canônica completa de uma entidade de domínio.
77
+
78
+ Campos são ``strip()``ados em ``__post_init__``; capitalização é
79
+ **preservada** (política de case é do adapter — identificadores SQL
80
+ quoted são case-sensitive e adapters não devem fundir nomes reais
81
+ distintos).
82
+ """
83
+
84
+ domain: str # id do domínio produtor (adapter.domain_id)
85
+ repository: str # identity_key lógica do repositório (lf-0021)
86
+ qualified_name: str # nome qualificado definido pelo adapter
87
+ root: str = "" # sub-raiz no monorepo; "" = raiz do repo
88
+ catalog: str = "" # catálogo/conta/região (qualificador superior)
89
+ namespace: str = "" # schema/database/módulo (qualificador inferior)
90
+ environment: str = "" # qualificador opcional de ambiente
91
+ snapshot: str = "" # escopo de revisão; "" = identidade lógica
92
+
93
+ def __post_init__(self) -> None:
94
+ for f in ("domain", "repository", "qualified_name", "root",
95
+ "catalog", "namespace", "environment", "snapshot"):
96
+ object.__setattr__(self, f, getattr(self, f).strip())
97
+ for f in ("domain", "repository", "qualified_name"):
98
+ if not getattr(self, f):
99
+ raise ValueError(f"domain identity requires {f}")
100
+
101
+ def _segments(self, *, include_snapshot: bool) -> tuple[str, ...]:
102
+ seg = (
103
+ self.domain, self.repository, self.root, self.catalog,
104
+ self.namespace, self.environment, self.qualified_name,
105
+ )
106
+ if include_snapshot:
107
+ return (*seg, self.snapshot)
108
+ return seg
109
+
110
+ def canonical_key(self) -> str:
111
+ """Chave canônica completa (inclui ``snapshot`` quando presente).
112
+
113
+ Formato posicional ``dom:<seg>/<seg>/…`` com cada segmento
114
+ percent-encodado — colisão só se **todos** os componentes forem
115
+ idênticos.
116
+ """
117
+ segs = "/".join(_enc(s) for s in self._segments(include_snapshot=True))
118
+ return f"{_SCHEME}:{segs}"
119
+
120
+ def logical_key(self) -> str:
121
+ """Chave lógica (sem ``snapshot``) — identidade por repositório."""
122
+ segs = "/".join(_enc(s) for s in self._segments(include_snapshot=False))
123
+ return f"{_SCHEME}:{segs}"
124
+
125
+ def logical(self) -> DomainIdentity:
126
+ """Identidade lógica equivalente (``snapshot=""``)."""
127
+ if not self.snapshot:
128
+ return self
129
+ return DomainIdentity(
130
+ domain=self.domain, repository=self.repository,
131
+ qualified_name=self.qualified_name, root=self.root,
132
+ catalog=self.catalog, namespace=self.namespace,
133
+ environment=self.environment,
134
+ )
135
+
136
+ def display(self) -> str:
137
+ """Forma legível para humanos (não usada como chave)."""
138
+ head = ".".join(p for p in (self.catalog, self.namespace) if p)
139
+ qname = f"{head}.{self.qualified_name}" if head else self.qualified_name
140
+ env = f"@{self.environment}" if self.environment else ""
141
+ return f"{self.domain}:{qname}{env}"
142
+
143
+ @staticmethod
144
+ def parse_key(key: str) -> tuple[str, ...] | None:
145
+ """Decodifica os segmentos de uma chave ``dom:…``; None se inválida."""
146
+ if not key.startswith(f"{_SCHEME}:"):
147
+ return None
148
+ body = key[len(_SCHEME) + 1:]
149
+ return tuple(_dec(seg) for seg in body.split(_SEP))
@@ -0,0 +1,202 @@
1
+ """Modelo unificado de entidades/relações de domínio (lf-0110, Cycle 7).
2
+
3
+ Vocabulário fechado do prompt Cycle 7 (Phases 1+2): adapters extraem
4
+ **somente** estes kinds — qualquer extensão exige spec + ADR. ``TESTS`` e
5
+ ``REFERENCES`` são compartilhados com ``EdgeKind`` (mesmo valor semântico).
6
+
7
+ ``REPOSITORY``/``PROJECT`` aqui são entidades de **domínio** (ex.: um repo
8
+ federado referenciado por um workflow CI/CD), distintos de
9
+ ``NodeKind.REPOSITORY`` — nó do grafo de código já coberto pelo modelo de
10
+ símbolos. Camadas diferentes, tabelas diferentes.
11
+
12
+ Persistência (ADR-0010): entidades de domínio NÃO reutilizam a tabela
13
+ ``symbols`` — a identidade delas inclui ``catalog``/``namespace``/
14
+ ``environment``, que o UNIQUE de ``symbols`` (repo, language, qname, kind)
15
+ não consegue expressar (duas tabelas ``orders`` de catálogos distintos
16
+ colidiriam). Schema dedicado na migração v9.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass, field
23
+ from enum import StrEnum
24
+
25
+ from code_atlas.contracts.enums import ConfidenceClass, EdgeKind, ResolutionStatus
26
+ from code_atlas.contracts.models import _coerce
27
+ from code_atlas.domains.identity import DomainIdentity
28
+
29
+
30
+ class DomainEntityKind(StrEnum):
31
+ """Tipos de entidade de domínio (prompt Cycle 7 — vocabulário fechado)."""
32
+
33
+ DATA_TABLE = "data_table"
34
+ DATA_COLUMN = "data_column"
35
+ DATA_VIEW = "data_view"
36
+ DATA_TRANSFORMATION = "data_transformation"
37
+ DATA_PIPELINE = "data_pipeline"
38
+ SPARK_JOB = "spark_job"
39
+ GLUE_JOB = "glue_job"
40
+ DATA_CATALOG = "data_catalog"
41
+ ICEBERG_TABLE = "iceberg_table"
42
+ API_ENDPOINT = "api_endpoint"
43
+ API_SERVICE = "api_service"
44
+ API_CONTRACT = "api_contract"
45
+ TERRAFORM_RESOURCE = "terraform_resource"
46
+ TERRAFORM_MODULE = "terraform_module"
47
+ IAM_ROLE = "iam_role"
48
+ CICD_WORKFLOW = "cicd_workflow"
49
+ CICD_JOB = "cicd_job"
50
+ REPOSITORY = "repository" # entidade de federação (lf-0118), ≠ NodeKind
51
+ PROJECT = "project"
52
+
53
+
54
+ class DomainRelationKind(StrEnum):
55
+ """Relações de domínio (prompt Cycle 7 — vocabulário fechado)."""
56
+
57
+ READS_FROM = "READS_FROM"
58
+ WRITES_TO = "WRITES_TO"
59
+ TRANSFORMS = "TRANSFORMS"
60
+ DERIVES_FROM = "DERIVES_FROM"
61
+ DEPENDS_ON = "DEPENDS_ON"
62
+ CALLS_ENDPOINT = "CALLS_ENDPOINT"
63
+ IMPLEMENTS_CONTRACT = "IMPLEMENTS_CONTRACT"
64
+ PROVISIONS = "PROVISIONS"
65
+ USES_RESOURCE = "USES_RESOURCE"
66
+ ASSUMES_ROLE = "ASSUMES_ROLE"
67
+ TRIGGERS = "TRIGGERS"
68
+ DEPLOYS = "DEPLOYS"
69
+ TESTS = "TESTS" # valor compartilhado com EdgeKind.TESTS
70
+ REFERENCES = "REFERENCES" # valor compartilhado com EdgeKind.REFERENCES
71
+
72
+
73
+ # Vocabulário aceito em domain_relations.type: as relações de domínio mais
74
+ # o vocabulário estrutural (CONTAINS módulo→recurso, IMPORTS de tf module…).
75
+ DOMAIN_RELATION_TYPES: frozenset[str] = frozenset(
76
+ {str(k) for k in DomainRelationKind} | {str(k) for k in EdgeKind}
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DomainEntity:
82
+ """Entidade lógica de domínio — estável por repositório.
83
+
84
+ ``id`` é a row id em ``domain_entities`` (``None`` = a persistir);
85
+ ``attributes`` carrega observáveis específicos do domínio (ddl
86
+ resumido, tipo de recurso, provider) como strings — nada de blobs
87
+ arbitrários nem segredos.
88
+ """
89
+
90
+ identity: DomainIdentity
91
+ kind: DomainEntityKind
92
+ name: str
93
+ attributes: Mapping[str, str] = field(default_factory=dict)
94
+ id: int | None = None
95
+
96
+ def __post_init__(self) -> None:
97
+ object.__setattr__(
98
+ self, "kind", _coerce(DomainEntityKind, self.kind, "kind")
99
+ )
100
+ if not self.name.strip():
101
+ raise ValueError("domain entity requires name")
102
+ object.__setattr__(
103
+ self,
104
+ "attributes",
105
+ {str(k): str(v) for k, v in self.attributes.items()},
106
+ )
107
+
108
+ @property
109
+ def domain(self) -> str:
110
+ return self.identity.domain
111
+
112
+ @property
113
+ def qualified_name(self) -> str:
114
+ return self.identity.qualified_name
115
+
116
+ def identity_key(self) -> str:
117
+ """Chave lógica persistida em ``domain_entities.identity_key``."""
118
+ return self.identity.logical_key()
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class DomainEntityOccurrence:
123
+ """Ocorrência de uma entidade num snapshot (span/hash da ocorrência).
124
+
125
+ ``source_file_id=None`` é permitido apenas para entidades sintetizadas
126
+ por derivação declarada (ex.: catálogo agregado) — o caso normal é
127
+ evidence-first: entidade observada num arquivo do inventário.
128
+ """
129
+
130
+ snapshot_id: int
131
+ entity_id: int
132
+ source_file_id: int | None
133
+ id: int | None = None
134
+ span_start: int | None = None
135
+ span_end: int | None = None
136
+ line: int | None = None
137
+ col: int | None = None
138
+ content_hash: str | None = None
139
+ attributes: Mapping[str, str] = field(default_factory=dict)
140
+ extraction_status: str = "extracted" # extracted | partial | failed
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class DomainRelation:
145
+ """Aresta de domínio com proveniência obrigatória (espelha ``Relation``).
146
+
147
+ Endpoints podem ser ocorrências de entidade de domínio OU de símbolo
148
+ de código — a ponte código↔domínio (lf-0117/0120) precisa das duas
149
+ direções sem nova migração. Exige-se ≥1 endpoint de origem e ≥1 alvo
150
+ (ou ``to_unresolved`` honesto).
151
+
152
+ Invariante inegociável (spec): evidência ``inferred`` NUNCA é
153
+ promovida a ``resolved``.
154
+ """
155
+
156
+ snapshot_id: int
157
+ relation_type: str
158
+ resolver: str
159
+ confidence_class: ConfidenceClass
160
+ resolution_status: ResolutionStatus
161
+ id: int | None = None
162
+ from_entity_occ_id: int | None = None
163
+ from_symbol_occ_id: int | None = None
164
+ to_entity_occ_id: int | None = None
165
+ to_symbol_occ_id: int | None = None
166
+ to_unresolved: str | None = None
167
+ evidence_ids: tuple[int, ...] = ()
168
+
169
+ def __post_init__(self) -> None:
170
+ t = str(self.relation_type)
171
+ if t not in DOMAIN_RELATION_TYPES:
172
+ raise ValueError(f"invalid relation_type: {self.relation_type!r}")
173
+ object.__setattr__(self, "relation_type", t)
174
+ object.__setattr__(
175
+ self,
176
+ "confidence_class",
177
+ _coerce(ConfidenceClass, self.confidence_class, "confidence_class"),
178
+ )
179
+ object.__setattr__(
180
+ self,
181
+ "resolution_status",
182
+ _coerce(ResolutionStatus, self.resolution_status, "resolution_status"),
183
+ )
184
+ if self.from_entity_occ_id is None and self.from_symbol_occ_id is None:
185
+ raise ValueError("domain relation requires a from endpoint")
186
+ if (
187
+ self.to_entity_occ_id is None
188
+ and self.to_symbol_occ_id is None
189
+ and not self.to_unresolved
190
+ ):
191
+ raise ValueError(
192
+ "domain relation requires to endpoint or to_unresolved"
193
+ )
194
+ if not self.resolver:
195
+ raise ValueError("domain relation requires resolver provenance")
196
+ if (
197
+ self.confidence_class is ConfidenceClass.INFERRED
198
+ and self.resolution_status is ResolutionStatus.RESOLVED
199
+ ):
200
+ raise ValueError(
201
+ "inferred evidence can never be reported resolved"
202
+ )
@@ -0,0 +1,250 @@
1
+ """Registro de adaptadores de domínio (lf-0110, Cycle 7).
2
+
3
+ **Registro explícito** (mesmo desenho de ``ResolverRegistry``, ADR-0010):
4
+ nenhum adapter é ativado por padrão, nenhum código de terceiros é
5
+ carregado por varredura automática. ``discover_entrypoints`` existe como
6
+ mecanismo **opt-in** — só roda quando o caller invoca explicitamente.
7
+
8
+ Semântica honesta de ausência: domínio não registrado NÃO é erro de
9
+ extração — adapters são opcionais (spec). ``get`` devolve ``None``;
10
+ ``require`` levanta exceção tipada para quem precisa do domínio.
11
+ Adapters desabilitados produzem **nada**: ``enabled()`` os exclui e
12
+ ``require`` recusa.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.metadata
18
+ import re
19
+ from collections.abc import Callable
20
+
21
+ from code_atlas.domains.contracts import DomainAdapter
22
+
23
+ ENTRYPOINT_GROUP = "code_atlas.domain_adapters"
24
+
25
+
26
+ class AdapterRegistryError(Exception):
27
+ """Falha de registro/lookup de adapter de domínio."""
28
+
29
+
30
+ class UnknownDomainError(AdapterRegistryError):
31
+ """Domínio requerido não está registrado."""
32
+
33
+
34
+ class AdapterDisabledError(AdapterRegistryError):
35
+ """Domínio registrado mas desabilitado — não produz extração."""
36
+
37
+
38
+ class DuplicateAdapterError(AdapterRegistryError):
39
+ """Par (domain_id, adapter_version) já registrado."""
40
+
41
+
42
+ def _version_key(version: str) -> tuple[tuple[int, ...], str]:
43
+ """Chave de ordenação semver-ish: segmentos numéricos + tiebreak lexical."""
44
+ nums = tuple(int(p) for p in re.findall(r"\d+", version))
45
+ return (nums, version)
46
+
47
+
48
+ def _check_adapter(adapter: DomainAdapter) -> None:
49
+ """Validação mínima do contrato no momento do registro."""
50
+ if not isinstance(adapter, DomainAdapter):
51
+ raise AdapterRegistryError(
52
+ f"object does not satisfy DomainAdapter: {adapter!r}"
53
+ )
54
+ if not adapter.domain_id.strip():
55
+ raise AdapterRegistryError("adapter requires non-empty domain_id")
56
+ if not adapter.adapter_version.strip():
57
+ raise AdapterRegistryError(
58
+ f"adapter {adapter.domain_id!r} requires non-empty version"
59
+ )
60
+ adapter.entity_kinds() # materializa — valida early
61
+ adapter.relation_kinds()
62
+ adapter.confidence_model()
63
+
64
+
65
+ class DomainRegistry:
66
+ """Registro explícito de adapters de domínio com versionamento.
67
+
68
+ ``register`` nunca substitui silenciosamente: mesmo ``domain_id`` com
69
+ versões distintas convivem (rastreabilidade de upgrade); mesma versão
70
+ registrada duas vezes é ``DuplicateAdapterError``.
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ self._adapters: dict[str, dict[str, DomainAdapter]] = {}
75
+ self._disabled: set[str] = set()
76
+ self._entrypoint_errors: list[str] = []
77
+
78
+ # --- registro ----------------------------------------------------
79
+
80
+ def register(self, adapter: DomainAdapter, *, enabled: bool = True) -> None:
81
+ """Registra ``adapter``; ``enabled=False`` registra desabilitado."""
82
+ _check_adapter(adapter)
83
+ versions = self._adapters.setdefault(adapter.domain_id, {})
84
+ if adapter.adapter_version in versions:
85
+ raise DuplicateAdapterError(
86
+ f"adapter already registered: "
87
+ f"{adapter.domain_id}@{adapter.adapter_version}"
88
+ )
89
+ versions[adapter.adapter_version] = adapter
90
+ if not enabled:
91
+ self._disabled.add(adapter.domain_id)
92
+
93
+ def unregister(self, domain_id: str, version: str | None = None) -> bool:
94
+ """Remove ``version`` (ou todas quando ``None``). True se removeu."""
95
+ versions = self._adapters.get(domain_id)
96
+ if not versions:
97
+ return False
98
+ if version is None:
99
+ del self._adapters[domain_id]
100
+ self._disabled.discard(domain_id)
101
+ return True
102
+ if version not in versions:
103
+ return False
104
+ del versions[version]
105
+ if not versions:
106
+ del self._adapters[domain_id]
107
+ self._disabled.discard(domain_id)
108
+ return True
109
+
110
+ def discover_entrypoints(
111
+ self, group: str = ENTRYPOINT_GROUP
112
+ ) -> int:
113
+ """Carrega adapters declarados via ``entry_points`` instalados.
114
+
115
+ **Opt-in explícito** — nunca chamado automaticamente (ADR-0010):
116
+ importar código de terceiros é decisão do caller, não do
117
+ registry. Cada entry point deve resolver para um
118
+ ``DomainAdapter`` ou uma fábrica ``() -> DomainAdapter``.
119
+ Falhas são registradas em ``entrypoint_errors`` e não abortam
120
+ os demais. Retorna quantos adapters foram registrados.
121
+ """
122
+ loaded = 0
123
+ for ep in importlib.metadata.entry_points(group=group):
124
+ try:
125
+ target = ep.load()
126
+ adapter: DomainAdapter = (
127
+ target() if callable(target) else target
128
+ )
129
+ self.register(adapter)
130
+ except Exception as exc: # diagnóstico honesto, não crash
131
+ self._entrypoint_errors.append(f"{ep.name}: {exc}")
132
+ continue
133
+ loaded += 1
134
+ return loaded
135
+
136
+ @property
137
+ def entrypoint_errors(self) -> tuple[str, ...]:
138
+ return tuple(self._entrypoint_errors)
139
+
140
+ # --- enable / disable --------------------------------------------
141
+
142
+ def enable(self, domain_id: str) -> None:
143
+ if domain_id not in self._adapters:
144
+ raise UnknownDomainError(f"domain not registered: {domain_id!r}")
145
+ self._disabled.discard(domain_id)
146
+
147
+ def disable(self, domain_id: str) -> None:
148
+ if domain_id not in self._adapters:
149
+ raise UnknownDomainError(f"domain not registered: {domain_id!r}")
150
+ self._disabled.add(domain_id)
151
+
152
+ def is_enabled(self, domain_id: str) -> bool:
153
+ return (
154
+ domain_id in self._adapters and domain_id not in self._disabled
155
+ )
156
+
157
+ # --- lookup --------------------------------------------------------
158
+
159
+ def _pick(self, domain_id: str, version: str | None) -> DomainAdapter | None:
160
+ versions = self._adapters.get(domain_id)
161
+ if not versions:
162
+ return None
163
+ if version is not None:
164
+ return versions.get(version)
165
+ if len(versions) == 1:
166
+ return next(iter(versions.values()))
167
+ # versão mais alta por ordenação semver-ish — determinístico
168
+ return versions[max(versions, key=_version_key)]
169
+
170
+ def get(
171
+ self, domain_id: str, version: str | None = None
172
+ ) -> DomainAdapter | None:
173
+ """Adapter registrado (habilitado ou não); ``None`` se ausente.
174
+
175
+ ``version=None`` retorna a maior versão registrada.
176
+ """
177
+ return self._pick(domain_id, version)
178
+
179
+ def require(
180
+ self, domain_id: str, version: str | None = None
181
+ ) -> DomainAdapter:
182
+ """Adapter **habilitado** ou exceção tipada e honesta.
183
+
184
+ - não registrado → ``UnknownDomainError``;
185
+ - registrado mas desabilitado → ``AdapterDisabledError``;
186
+ - versão inexistente → ``UnknownDomainError`` com detalhe.
187
+ """
188
+ versions = self._adapters.get(domain_id)
189
+ if not versions:
190
+ raise UnknownDomainError(f"domain not registered: {domain_id!r}")
191
+ if domain_id in self._disabled:
192
+ raise AdapterDisabledError(f"domain disabled: {domain_id!r}")
193
+ adapter = self._pick(domain_id, version)
194
+ if adapter is None:
195
+ raise UnknownDomainError(
196
+ f"version not registered: {domain_id!r}@{version!r}"
197
+ )
198
+ return adapter
199
+
200
+ # --- inventário ------------------------------------------------------
201
+
202
+ def versions(self, domain_id: str) -> tuple[str, ...]:
203
+ """Versões registradas do domínio, ordenadas."""
204
+ versions = self._adapters.get(domain_id, {})
205
+ return tuple(sorted(versions, key=_version_key))
206
+
207
+ def domains(self) -> tuple[str, ...]:
208
+ return tuple(sorted(self._adapters))
209
+
210
+ def adapters(self) -> tuple[DomainAdapter, ...]:
211
+ """Todos os adapters registrados (habilitados e desabilitados)."""
212
+ return tuple(
213
+ a for versions in self._adapters.values() for a in versions.values()
214
+ )
215
+
216
+ def enabled(self) -> tuple[DomainAdapter, ...]:
217
+ """Adapters habilitados — maior versão por domínio.
218
+
219
+ É o conjunto de extração: domínio desabilitado ou não registrado
220
+ produz exatamente zero entidades/relações.
221
+ """
222
+ out: list[DomainAdapter] = []
223
+ for domain_id in sorted(self._adapters):
224
+ if domain_id in self._disabled:
225
+ continue
226
+ picked = self._pick(domain_id, None)
227
+ if picked is not None:
228
+ out.append(picked)
229
+ return tuple(out)
230
+
231
+ def discovery_candidates(self, paths: list[str]) -> dict[str, list[str]]:
232
+ """Mapa ``domain_id → paths`` suportados pelos adapters habilitados.
233
+
234
+ Filtro puro por ``ArtifactSupport`` — não executa adapters.
235
+ """
236
+ out: dict[str, list[str]] = {}
237
+ for adapter in self.enabled():
238
+ support = adapter.supported_artifacts()
239
+ out[adapter.domain_id] = [p for p in paths if support.matches(p)]
240
+ return out
241
+
242
+
243
+ def default_registry(
244
+ factories: tuple[Callable[[], DomainAdapter], ...] = (),
245
+ ) -> DomainRegistry:
246
+ """Constrói um registry populado por fábricas explícitas do caller."""
247
+ reg = DomainRegistry()
248
+ for f in factories:
249
+ reg.register(f())
250
+ return reg