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,65 @@
1
+ """Estado de resolução de alvos de relações de domínio (lf-0110).
2
+
3
+ Reutiliza o vocabulário ``ResolutionStatus`` (resolved / ambiguous /
4
+ unresolved / external) e ``ConfidenceClass`` — nenhum valor novo.
5
+
6
+ Invariantes (spec Cycle 7 — "estados honestos de resolução"):
7
+
8
+ - ``resolved`` exige ``target`` (identidade canônica do alvo);
9
+ - ``ambiguous`` exige ≥2 candidatos — nunca "resolved por sorte";
10
+ - ``external`` pode carregar ``target`` (identidade conhecida, fora do
11
+ snapshot — ex.: ARN de recurso real, tabela de outro repo federado);
12
+ - ``inferred`` **nunca** é promovida a ``resolved`` — evidência inferida
13
+ no máximo sustenta ``ambiguous``/``external``/``unresolved``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
21
+ from code_atlas.contracts.models import _coerce
22
+ from code_atlas.domains.identity import DomainIdentity
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DomainResolution:
27
+ """Desfecho tipado da resolução de um alvo de relação de domínio."""
28
+
29
+ status: ResolutionStatus
30
+ confidence_class: ConfidenceClass
31
+ resolver: str # id do adapter/resolver produtor
32
+ target: DomainIdentity | None = None
33
+ candidates: tuple[DomainIdentity, ...] = field(default_factory=tuple)
34
+ unresolved_text: str = "" # alvo textual preservado
35
+
36
+ def __post_init__(self) -> None:
37
+ object.__setattr__(
38
+ self, "status", _coerce(ResolutionStatus, self.status, "status")
39
+ )
40
+ object.__setattr__(
41
+ self,
42
+ "confidence_class",
43
+ _coerce(ConfidenceClass, self.confidence_class, "confidence_class"),
44
+ )
45
+ if not self.resolver:
46
+ raise ValueError("domain resolution requires resolver provenance")
47
+ if self.status is ResolutionStatus.RESOLVED and self.target is None:
48
+ raise ValueError("resolved status requires target identity")
49
+ if (
50
+ self.status is ResolutionStatus.AMBIGUOUS
51
+ and len(self.candidates) < 2
52
+ ):
53
+ raise ValueError("ambiguous status requires >=2 candidates")
54
+ if (
55
+ self.status in (ResolutionStatus.UNRESOLVED,)
56
+ and self.target is not None
57
+ ):
58
+ raise ValueError("unresolved status cannot carry a target")
59
+ if (
60
+ self.confidence_class is ConfidenceClass.INFERRED
61
+ and self.status is ResolutionStatus.RESOLVED
62
+ ):
63
+ raise ValueError(
64
+ "inferred evidence can never be reported resolved"
65
+ )
@@ -0,0 +1,31 @@
1
+ """Adapter de domínio ``pyspark`` — linhagem estática de DataFrames.
2
+
3
+ Implementação de ``factory/specs/inbox/lf-0112-cycle7-pyspark-adapter.md``
4
+ (Cycle 7, Phase 4). Análise ``ast`` stdlib pura — sem regex-parser,
5
+ sem execução de código, sem dependência de ``pyspark`` instalado.
6
+
7
+ Costuras públicas:
8
+
9
+ - ``PySparkAdapter`` — facade ``DomainAdapter``;
10
+ - ``extract(request, version, base)`` — driver por request;
11
+ - ``delegate_sql_literal`` — costura ``spark.sql(literal)`` → adapter
12
+ ``sql`` (lf-0111).
13
+ """
14
+
15
+ from code_atlas.domains.spark.adapter import (
16
+ ADAPTER_VERSION,
17
+ DOMAIN_ID,
18
+ PySparkAdapter,
19
+ satisfies_protocol,
20
+ )
21
+ from code_atlas.domains.spark.extract import extract
22
+ from code_atlas.domains.spark.sqllit import delegate_sql_literal
23
+
24
+ __all__ = [
25
+ "ADAPTER_VERSION",
26
+ "DOMAIN_ID",
27
+ "PySparkAdapter",
28
+ "delegate_sql_literal",
29
+ "extract",
30
+ "satisfies_protocol",
31
+ ]
@@ -0,0 +1,146 @@
1
+ """``PySparkAdapter`` — adapter de domínio PySpark (lf-0112, Cycle 7 Ph.4).
2
+
3
+ Linhagem estática de DataFrames sobre artefatos ``.py`` — análise
4
+ ``ast`` pura, zero execução, zero imports de ``pyspark``:
5
+
6
+ - sessões: ``spark``/``spark_session``/``sparkSession`` implícitas
7
+ (notebook), ``SparkSession.builder…getOrCreate()``,
8
+ ``getActiveSession()`` e ``self.spark``;
9
+ - leituras: ``spark.read.table|parquet|csv|json|jdbc|format(…).load``,
10
+ ``spark.table`` — tabelas de catálogo e paths nunca se confundem;
11
+ - SQL: ``spark.sql("literal")`` delega ao parser/extrator do adapter
12
+ ``sql`` (lf-0111) — CTEs colapsam, ``SELECT *`` é diagnóstico, e o
13
+ resultado é re-ancorado na transformação do call-site;
14
+ - transforms: ``select/filter/join/groupBy/agg/union/withColumn/
15
+ dropDuplicates/withColumnRenamed`` + família completa de ops
16
+ ``DataFrame → DataFrame``;
17
+ - writes: ``df.write.parquet|saveAsTable|insertInto|mode(…).<fmt>``,
18
+ ``df.writeTo(t).<terminal>`` (Iceberg-style, ``iceberg_table``);
19
+ - honestidade: alvos dinâmicos (``f-string``, variáveis não-literal),
20
+ receivers desconhecidos e SQL dinâmico viram ``to_unresolved`` +
21
+ diagnóstico — nenhuma relação fabricada, nenhum ``resolved`` local.
22
+
23
+ Leitura de artefatos (mesmo desenho do ``SqlDomainAdapter``):
24
+ ``base_path`` resolve os paths relativos de ``DiscoveredArtifact``;
25
+ ``None`` trata os paths como estão. O adapter nunca lê fora do
26
+ request, nunca executa jobs e nunca abre rede.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+
33
+ from code_atlas.contracts.enums import ConfidenceClass, EdgeKind
34
+ from code_atlas.domains.contracts import (
35
+ ArtifactSupport,
36
+ ConfidenceModel,
37
+ DiscoveryRule,
38
+ DomainDiagnostic,
39
+ DomainExtractionRequest,
40
+ DomainExtractionResult,
41
+ )
42
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
43
+ from code_atlas.domains.spark.extract import extract as _extract
44
+
45
+ DOMAIN_ID = "pyspark"
46
+ ADAPTER_VERSION = "0.1.0"
47
+
48
+
49
+ class PySparkAdapter:
50
+ """Adapter ``pyspark`` — linhagem estática de DataFrames."""
51
+
52
+ def __init__(self, base_path: str | Path | None = None) -> None:
53
+ self._base = (
54
+ Path(base_path) if base_path is not None else Path.cwd()
55
+ )
56
+
57
+ # -- contrato ------------------------------------------------------
58
+
59
+ @property
60
+ def domain_id(self) -> str:
61
+ return DOMAIN_ID
62
+
63
+ @property
64
+ def adapter_version(self) -> str:
65
+ return ADAPTER_VERSION
66
+
67
+ def supported_artifacts(self) -> ArtifactSupport:
68
+ return ArtifactSupport(extensions=frozenset({".py"}))
69
+
70
+ def entity_kinds(self) -> frozenset[DomainEntityKind]:
71
+ return frozenset(
72
+ {
73
+ DomainEntityKind.DATA_TABLE,
74
+ DomainEntityKind.DATA_VIEW,
75
+ DomainEntityKind.DATA_COLUMN,
76
+ DomainEntityKind.ICEBERG_TABLE,
77
+ DomainEntityKind.DATA_TRANSFORMATION,
78
+ DomainEntityKind.SPARK_JOB,
79
+ }
80
+ )
81
+
82
+ def relation_kinds(self) -> frozenset[str]:
83
+ return frozenset(
84
+ {
85
+ str(DomainRelationKind.READS_FROM),
86
+ str(DomainRelationKind.WRITES_TO),
87
+ str(DomainRelationKind.TRANSFORMS),
88
+ str(DomainRelationKind.DERIVES_FROM),
89
+ # job CONTAINS transformation — vocabulário estrutural
90
+ # compartilhado (EdgeKind) permitido pelo schema v9
91
+ str(EdgeKind.CONTAINS),
92
+ }
93
+ )
94
+
95
+ def confidence_model(self) -> ConfidenceModel:
96
+ # Extração estritamente evidence-first: o que o AST não
97
+ # estabelece sai como diagnóstico/unresolved — nunca promovido.
98
+ return ConfidenceModel(
99
+ ceiling=ConfidenceClass.SYNTACTIC,
100
+ default=ConfidenceClass.SYNTACTIC,
101
+ allows_inferred=False,
102
+ notes="static PySpark DataFrame lineage; dynamic sql/paths/"
103
+ "unresolvable receivers are diagnosed, never resolved",
104
+ )
105
+
106
+ def discovery_rules(self) -> tuple[DiscoveryRule, ...]:
107
+ return (
108
+ DiscoveryRule(
109
+ glob="*.py",
110
+ artifact_kind="pyspark-job",
111
+ notes=(
112
+ "Python files are scanned for SparkSession usage "
113
+ "(spark.read/spark.sql/df.write) — non-Spark .py "
114
+ "files are ignored silently"
115
+ ),
116
+ ),
117
+ )
118
+
119
+ # -- extract ---------------------------------------------------------
120
+
121
+ def extract(
122
+ self, request: DomainExtractionRequest
123
+ ) -> DomainExtractionResult:
124
+ try:
125
+ return _extract(
126
+ request, self.adapter_version, self._base
127
+ )
128
+ except Exception as exc: # última linha de defesa — nunca levanta
129
+ return DomainExtractionResult(
130
+ domain_id=self.domain_id,
131
+ adapter_version=self.adapter_version,
132
+ diagnostics=(
133
+ DomainDiagnostic(
134
+ code="adapter-failure",
135
+ severity="error",
136
+ message=f"pyspark adapter failure: {exc!r}",
137
+ adapter_id=self.domain_id,
138
+ ),
139
+ ),
140
+ )
141
+
142
+
143
+ def satisfies_protocol() -> PySparkAdapter:
144
+ """Smoke check para tests: a instância satisfaz ``DomainAdapter``."""
145
+ adapter: PySparkAdapter = PySparkAdapter()
146
+ return adapter
@@ -0,0 +1,344 @@
1
+ """Sink de emissão do adapter ``domains.spark`` (lf-0112, Phase 4).
2
+
3
+ Acumulador **por request** (não por arquivo): entidades dedupam por
4
+ ``logical_key`` com evidência unida e o atributo ``roles`` fundido
5
+ (``read``/``write``); relações dedupam por ``(tipo, origem, alvo)``
6
+ acumulando evidência — mesma disciplina dos sinks ``sql``/``api``.
7
+
8
+ Também mantém os índices de linhagem do domínio:
9
+
10
+ - ``sources``: para cada transformação materializada, o conjunto de
11
+ fontes terminais (tabelas/paths) que a alimentam — base do colapso
12
+ ``sink DERIVES_FROM fonte`` nos writes;
13
+ - ``view_sources``: fontes terminais de temp views registradas —
14
+ ``FROM v`` dentro de literais ``spark.sql`` colapsa além do alias.
15
+
16
+ Resolução honesta: alvo ``to_identity`` sai sem ``resolution`` (o
17
+ coordinator resolve pela ocorrência no snapshot); ``to_unresolved``
18
+ carrega ``DomainResolution(UNRESOLVED, syntactic)`` — nunca ``resolved``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass, field
25
+
26
+ from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
27
+ from code_atlas.domains.contracts import (
28
+ DomainDiagnostic,
29
+ DomainExtractionRequest,
30
+ DomainExtractionResult,
31
+ ExtractedDomainEntity,
32
+ ExtractedDomainRelation,
33
+ )
34
+ from code_atlas.domains.evidence import domain_extractor_id, evidence_record
35
+ from code_atlas.domains.identity import DomainIdentity
36
+ from code_atlas.domains.model import DomainEntityKind
37
+ from code_atlas.domains.resolution import DomainResolution
38
+ from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
39
+
40
+ DOMAIN_ID = "pyspark"
41
+
42
+ _DIAG_CAP = 60 # diagnósticos por request — depois vira resumo
43
+
44
+ _SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*)://")
45
+ _BACKTICK_RE = re.compile(r"^`(.*)`$")
46
+
47
+
48
+ def split_table_name(literal: str) -> tuple[str, ...] | None:
49
+ """``"cat.db.t"`` → partes; backticks por segmento são removidos.
50
+
51
+ ``None`` quando o literal é vazio ou carrega marcador dinâmico
52
+ (``${}``/``{{}}``) — o caller emite ``to_unresolved``, nunca fabrica.
53
+ """
54
+ lit = literal.strip()
55
+ if not lit or "${" in lit or "{{" in lit:
56
+ return None
57
+ parts: list[str] = []
58
+ for raw in lit.split("."):
59
+ p = raw.strip()
60
+ m = _BACKTICK_RE.match(p)
61
+ if m:
62
+ p = m.group(1)
63
+ if not p:
64
+ return None
65
+ parts.append(p)
66
+ return tuple(parts)
67
+
68
+
69
+ def uri_scheme(literal: str) -> str:
70
+ """Esquema de URI do path (``s3a``, ``hdfs``, ``file``) ou ``""``."""
71
+ m = _SCHEME_RE.match(literal.strip())
72
+ return m.group(1).lower() if m else ""
73
+
74
+
75
+ @dataclass
76
+ class _EntAcc:
77
+ entity: ExtractedDomainEntity
78
+ ev: set[int] = field(default_factory=set)
79
+ roles: set[str] = field(default_factory=set)
80
+ # mesmo objeto dict passado ao ExtractedDomainEntity — mutável
81
+ # (a entidade expõe Mapping; aqui vive a visão de escrita)
82
+ attrs: dict[str, str] = field(default_factory=dict)
83
+
84
+
85
+ @dataclass
86
+ class _RelAcc:
87
+ rel: ExtractedDomainRelation
88
+ ev: set[int] = field(default_factory=set)
89
+
90
+
91
+ class Sink:
92
+ """Acumulador determinístico da saída de ``extract``."""
93
+
94
+ def __init__(
95
+ self, request: DomainExtractionRequest, adapter_version: str
96
+ ) -> None:
97
+ self.request = request
98
+ self.version = adapter_version
99
+ self.entities: dict[str, _EntAcc] = {}
100
+ self.relations: dict[tuple[str, str, str], _RelAcc] = {}
101
+ self.evidence: list[EvidenceRecord] = []
102
+ self._evidx: dict[tuple[str, int], int] = {}
103
+ self.diagnostics: list[DomainDiagnostic] = []
104
+ self._truncated = 0
105
+ self.stats: dict[str, int] = {}
106
+ self.sources: dict[str, set[str]] = {}
107
+ self.view_sources: dict[str, set[str]] = {}
108
+ self._diag_seen: set[tuple[str, str]] = set()
109
+
110
+ # -- evidência / diagnóstico ----------------------------------------
111
+
112
+ def evi(
113
+ self,
114
+ path: str,
115
+ sha: str,
116
+ span: SourceSpan | None,
117
+ notes: str = "",
118
+ ) -> int:
119
+ """Evidência dedup por ``(path, start_byte)`` → índice no envelope."""
120
+ key = (path, span.start_byte if span else -1)
121
+ idx = self._evidx.get(key)
122
+ if idx is not None:
123
+ return idx
124
+ self.evidence.append(
125
+ evidence_record(
126
+ adapter_domain=DOMAIN_ID,
127
+ adapter_version=self.version,
128
+ path=path,
129
+ content_hash=sha,
130
+ span=span,
131
+ notes=notes,
132
+ )
133
+ )
134
+ idx = len(self.evidence) - 1
135
+ self._evidx[key] = idx
136
+ return idx
137
+
138
+ def diag(
139
+ self, code: str, severity: str, message: str, path: str = ""
140
+ ) -> None:
141
+ if (code, message) in self._diag_seen:
142
+ return
143
+ self._diag_seen.add((code, message))
144
+ if len(self.diagnostics) >= _DIAG_CAP:
145
+ self._truncated += 1
146
+ return
147
+ self.diagnostics.append(
148
+ DomainDiagnostic(
149
+ code=code, severity=severity, message=message,
150
+ path=path, adapter_id=DOMAIN_ID,
151
+ )
152
+ )
153
+
154
+ def bump(self, stat: str, n: int = 1) -> None:
155
+ self.stats[stat] = self.stats.get(stat, 0) + n
156
+
157
+ def set_entity_attr(self, key: str, name: str, value: str) -> None:
158
+ """Escreve atributo numa entidade já registrada (``logical_key``)."""
159
+ acc = self.entities.get(key)
160
+ if acc is not None:
161
+ acc.attrs[name] = value
162
+
163
+ # -- identidades ------------------------------------------------------
164
+
165
+ def table_ident(self, parts: tuple[str, ...]) -> DomainIdentity:
166
+ """catalog/namespace/qualified_name a partir das partes do nome."""
167
+ req = self.request
168
+ if len(parts) >= 3:
169
+ catalog, namespace, name = (
170
+ parts[0], ".".join(parts[1:-1]), parts[-1]
171
+ )
172
+ elif len(parts) == 2:
173
+ catalog, namespace, name = "", parts[0], parts[1]
174
+ else:
175
+ catalog, namespace, name = "", "", parts[0]
176
+ return DomainIdentity(
177
+ domain=DOMAIN_ID, repository=req.repository, root=req.root,
178
+ catalog=catalog, namespace=namespace,
179
+ environment=req.environment, qualified_name=name,
180
+ )
181
+
182
+ def path_ident(self, literal: str) -> DomainIdentity:
183
+ """Entidade de path (s3/hdfs/file) — NUNCA confundida com tabela.
184
+
185
+ ``qualified_name`` é o literal verbatim (sem namespace/catalog
186
+ de tabela); ``uri_scheme`` vai para os atributos da entidade.
187
+ """
188
+ req = self.request
189
+ return DomainIdentity(
190
+ domain=DOMAIN_ID, repository=req.repository, root=req.root,
191
+ environment=req.environment, qualified_name=literal.strip(),
192
+ )
193
+
194
+ def transform_ident(self, path: str, anchor: str) -> DomainIdentity:
195
+ """Entidade ``data_transformation`` — âncora ``<var>@<line>``."""
196
+ req = self.request
197
+ return DomainIdentity(
198
+ domain=DOMAIN_ID, repository=req.repository, root=req.root,
199
+ environment=req.environment,
200
+ qualified_name=f"{path}#{anchor}",
201
+ )
202
+
203
+ def retag(self, ident: DomainIdentity) -> DomainIdentity:
204
+ """Mesma posição de identidade, domínio ``pyspark``.
205
+
206
+ Usado no remap da delegação ``spark.sql``: o adapter ``sql``
207
+ emite ``domain="sql"`` — o contrato exige que tudo que este
208
+ adapter emite carregue ``domain="pyspark"``.
209
+ """
210
+ return DomainIdentity(
211
+ domain=DOMAIN_ID, repository=ident.repository,
212
+ root=ident.root, catalog=ident.catalog,
213
+ namespace=ident.namespace, environment=ident.environment,
214
+ qualified_name=ident.qualified_name,
215
+ )
216
+
217
+ # -- entidades / relações --------------------------------------------
218
+
219
+ def add_entity(
220
+ self,
221
+ ident: DomainIdentity,
222
+ kind: DomainEntityKind,
223
+ name: str,
224
+ *,
225
+ path: str = "",
226
+ span: SourceSpan | None = None,
227
+ content_hash: str = "",
228
+ attributes: dict[str, str] | None = None,
229
+ roles: tuple[str, ...] = (),
230
+ evidence: tuple[int, ...] = (),
231
+ ) -> DomainIdentity:
232
+ """Registra entidade; dedup por ``logical_key`` funde evidência."""
233
+ key = ident.logical_key()
234
+ acc = self.entities.get(key)
235
+ if acc is not None:
236
+ acc.ev.update(evidence)
237
+ acc.roles.update(roles)
238
+ # primeira observação vence por chave (``via`` registra a
239
+ # API que primeiro revelou a entidade); chaves novas entram
240
+ for k, v in (attributes or {}).items():
241
+ acc.attrs.setdefault(k, v)
242
+ return ident
243
+ attrs = dict(attributes or {})
244
+ acc = _EntAcc(
245
+ entity=ExtractedDomainEntity(
246
+ identity=ident, kind=kind, name=name, path=path,
247
+ span=span, content_hash=content_hash,
248
+ attributes=attrs, evidence=(),
249
+ ),
250
+ ev=set(evidence),
251
+ roles=set(roles),
252
+ attrs=attrs,
253
+ )
254
+ self.entities[key] = acc
255
+ return ident
256
+
257
+ def add_rel(
258
+ self,
259
+ rtype: str,
260
+ from_i: DomainIdentity,
261
+ *,
262
+ path: str,
263
+ to_i: DomainIdentity | None = None,
264
+ to_text: str = "",
265
+ span: SourceSpan | None = None,
266
+ evidence: tuple[int, ...] = (),
267
+ resolution: DomainResolution | None = None,
268
+ ) -> None:
269
+ """Relação dedup por (tipo, origem, alvo); evidência acumula."""
270
+ to_key = to_i.logical_key() if to_i is not None else f"text:{to_text}"
271
+ key = (rtype, from_i.logical_key(), to_key)
272
+ acc = self.relations.get(key)
273
+ if acc is not None:
274
+ acc.ev.update(evidence)
275
+ return
276
+ if resolution is None and to_i is None:
277
+ resolution = DomainResolution(
278
+ status=ResolutionStatus.UNRESOLVED,
279
+ confidence_class=ConfidenceClass.SYNTACTIC,
280
+ resolver=domain_extractor_id(DOMAIN_ID),
281
+ unresolved_text=to_text,
282
+ )
283
+ self.relations[key] = _RelAcc(
284
+ rel=ExtractedDomainRelation(
285
+ relation_type=rtype, path=path,
286
+ from_identity=from_i, to_identity=to_i,
287
+ to_unresolved=to_text, resolution=resolution,
288
+ span=span, evidence=(),
289
+ ),
290
+ ev=set(evidence),
291
+ )
292
+
293
+ # -- fechamento ---------------------------------------------------------
294
+
295
+ def finish(self) -> None:
296
+ if self._truncated:
297
+ self.diagnostics.append(
298
+ DomainDiagnostic(
299
+ code="diagnostics-truncated", severity="info",
300
+ message=(
301
+ f"{self._truncated} further diagnostics "
302
+ f"suppressed (cap {_DIAG_CAP})"
303
+ ),
304
+ path="", adapter_id=DOMAIN_ID,
305
+ )
306
+ )
307
+
308
+ def result(self) -> DomainExtractionResult:
309
+ entities = []
310
+ for acc in self.entities.values():
311
+ attrs = dict(acc.attrs)
312
+ if acc.roles:
313
+ attrs["roles"] = ",".join(sorted(acc.roles))
314
+ entities.append(
315
+ ExtractedDomainEntity(
316
+ identity=acc.entity.identity, kind=acc.entity.kind,
317
+ name=acc.entity.name, path=acc.entity.path,
318
+ span=acc.entity.span,
319
+ content_hash=acc.entity.content_hash,
320
+ attributes=attrs, evidence=tuple(sorted(acc.ev)),
321
+ )
322
+ )
323
+ relations = [
324
+ ExtractedDomainRelation(
325
+ relation_type=acc.rel.relation_type, path=acc.rel.path,
326
+ from_identity=acc.rel.from_identity,
327
+ from_symbol=acc.rel.from_symbol,
328
+ to_identity=acc.rel.to_identity,
329
+ to_symbol=acc.rel.to_symbol,
330
+ to_unresolved=acc.rel.to_unresolved,
331
+ resolution=acc.rel.resolution,
332
+ span=acc.rel.span, evidence=tuple(sorted(acc.ev)),
333
+ )
334
+ for acc in self.relations.values()
335
+ ]
336
+ return DomainExtractionResult(
337
+ domain_id=DOMAIN_ID,
338
+ adapter_version=self.version,
339
+ entities=tuple(entities),
340
+ relations=tuple(relations),
341
+ evidence=tuple(self.evidence),
342
+ diagnostics=tuple(self.diagnostics),
343
+ stats=dict(self.stats),
344
+ )
@@ -0,0 +1,108 @@
1
+ """Driver de extração por request do domínio ``pyspark`` (lf-0112).
2
+
3
+ Ordem determinística: artefatos ordenados por ``path``; cada ``.py`` é
4
+ lido uma vez (utf-8, bytes decodificados) e percorrido pelo walker
5
+ AST. Falhas de leitura/decodificação viram diagnóstico; exceção
6
+ inesperada do walker vira ``adapter-crash`` (error) — o adapter nunca
7
+ propaga exceção para o coordinator.
8
+
9
+ Isolamento: só lê os paths declarados em ``request.artifacts`` sob
10
+ ``base_path``; não escreve no disco, não executa código, não abre
11
+ conexões.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ from pathlib import Path
18
+
19
+ from code_atlas.discovery.scanner import (
20
+ artifact_path_confined,
21
+ read_file_verified,
22
+ )
23
+ from code_atlas.domains.contracts import (
24
+ DomainExtractionRequest,
25
+ DomainExtractionResult,
26
+ )
27
+ from code_atlas.domains.spark.emit import Sink
28
+ from code_atlas.domains.spark.walk import _Walker
29
+
30
+ _MAX_BYTES = 2 * 1024 * 1024 # 2 MiB — acima disso: diagnóstico, skip
31
+
32
+
33
+ def _read_artifact(
34
+ base_path: Path, rel_path: str, sink: Sink
35
+ ) -> tuple[str, str] | None:
36
+ """Lê o artefato → ``(texto, sha256-bytes)``; ``None`` + diag em falha."""
37
+ base = base_path.resolve()
38
+ # containment por pathlib — ``startswith`` em string aceitaria irmãos
39
+ # com prefixo comum (``/repo-evil`` sob ``/repo``); absoluto e ``..``
40
+ # no path do request são igualmente hostis (lf-c8 F7).
41
+ p = artifact_path_confined(rel_path, base)
42
+ if p is None:
43
+ sink.diag(
44
+ "artifact-outside-root", "warning",
45
+ f"{rel_path}: artifact path escapes repository root",
46
+ rel_path,
47
+ )
48
+ return None
49
+ raw = read_file_verified(p, base)
50
+ if raw is None:
51
+ sink.diag(
52
+ "read-error", "error",
53
+ "cannot read artifact: unsafe or vanished", rel_path,
54
+ )
55
+ return None
56
+ if len(raw) > _MAX_BYTES:
57
+ sink.diag(
58
+ "artifact-too-large", "info",
59
+ f"skipping {rel_path}: {len(raw)} bytes > {_MAX_BYTES}",
60
+ rel_path,
61
+ )
62
+ return None
63
+ sha = hashlib.sha256(raw).hexdigest()
64
+ try:
65
+ text = raw.decode("utf-8")
66
+ except UnicodeDecodeError as exc:
67
+ sink.diag(
68
+ "decode-error", "error",
69
+ f"artifact is not valid utf-8: {exc}", rel_path,
70
+ )
71
+ return None
72
+ return text, sha
73
+
74
+
75
+ def extract(
76
+ request: DomainExtractionRequest,
77
+ adapter_version: str,
78
+ base_path: Path,
79
+ ) -> DomainExtractionResult:
80
+ """Percorre ``request.artifacts`` (somente ``.py``) e extrai linhagem."""
81
+ sink = Sink(request, adapter_version)
82
+ for art in sorted(request.artifacts, key=lambda a: a.path):
83
+ if not art.path.lower().endswith(".py"):
84
+ sink.diag(
85
+ "unsupported-artifact", "info",
86
+ f"{art.path}: adapter extracts only Python (.py) "
87
+ f"artifacts — {art.artifact_kind or 'unknown'} skipped",
88
+ art.path,
89
+ )
90
+ sink.bump("files_skipped")
91
+ continue
92
+ got = _read_artifact(base_path, art.path, sink)
93
+ if got is None:
94
+ sink.bump("files_unreadable")
95
+ continue
96
+ text, sha = got
97
+ sink.bump("files_seen")
98
+ try:
99
+ _Walker(sink, art.path, sha, text).run()
100
+ except Exception as exc: # honestidade: crash vira diagnóstico
101
+ sink.diag(
102
+ "adapter-crash", "error",
103
+ f"{type(exc).__name__} while walking {art.path}: {exc}",
104
+ art.path,
105
+ )
106
+ sink.bump("parse_errors")
107
+ sink.finish()
108
+ return sink.result()