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,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()
|