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,762 @@
|
|
|
1
|
+
"""Adapter de domínio CI/CD — GitHub Actions (lf-0116, Cycle 7 Phase 8).
|
|
2
|
+
|
|
3
|
+
Plataforma primária: **GitHub Actions** (``.github/workflows/*.yml``).
|
|
4
|
+
Outras plataformas são **declared-optional** — GitLab CI, Azure
|
|
5
|
+
Pipelines, Jenkinsfile e CircleCI são reconhecíveis por convenção de
|
|
6
|
+
path mas NÃO são parseados neste slice: seus arquivos simplesmente não
|
|
7
|
+
casam com ``supported_artifacts`` e produzem zero fatos (ausência
|
|
8
|
+
honesta, nunca fabricação). Ver ``OPTIONAL_PLATFORMS``.
|
|
9
|
+
|
|
10
|
+
Regras duras herdadas da spec/contrato:
|
|
11
|
+
|
|
12
|
+
- sem execução de workflow/job/step; sem resolução de secrets;
|
|
13
|
+
- ``${{ ... }}`` nunca vira valor concreto (``<expr>``/``<secret>``);
|
|
14
|
+
- actions e workflows reutilizáveis externos são ``REFERENCES``
|
|
15
|
+
``external`` — nunca baixados; ``uses: ./...`` só resolve quando o
|
|
16
|
+
arquivo está entre os artefatos descobertos do request;
|
|
17
|
+
- ``BUILDS``/``PUBLISHES`` não existem no vocabulário fechado de
|
|
18
|
+
relações (``DOMAIN_RELATION_TYPES``, ADR-0010 — extensão exige
|
|
19
|
+
spec+ADR): passos classificados como build/publish viram **atributos
|
|
20
|
+
de job** com evidência, nunca arestas inventadas.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
27
|
+
from pathlib import Path, PurePosixPath
|
|
28
|
+
|
|
29
|
+
from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
|
|
30
|
+
from code_atlas.domains.cicd import yamlmini
|
|
31
|
+
from code_atlas.domains.cicd.workflow import (
|
|
32
|
+
JobObs,
|
|
33
|
+
StepObs,
|
|
34
|
+
WorkflowObs,
|
|
35
|
+
parse_workflow,
|
|
36
|
+
step_descriptor,
|
|
37
|
+
)
|
|
38
|
+
from code_atlas.domains.contracts import (
|
|
39
|
+
ArtifactSupport,
|
|
40
|
+
ConfidenceModel,
|
|
41
|
+
DiscoveredArtifact,
|
|
42
|
+
DiscoveryRule,
|
|
43
|
+
DomainDiagnostic,
|
|
44
|
+
DomainExtractionRequest,
|
|
45
|
+
DomainExtractionResult,
|
|
46
|
+
ExtractedDomainEntity,
|
|
47
|
+
ExtractedDomainRelation,
|
|
48
|
+
)
|
|
49
|
+
from code_atlas.domains.evidence import domain_extractor_id, evidence_record
|
|
50
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
51
|
+
from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
|
|
52
|
+
from code_atlas.domains.resolution import DomainResolution
|
|
53
|
+
from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
|
|
54
|
+
|
|
55
|
+
DOMAIN_ID = "cicd"
|
|
56
|
+
ADAPTER_VERSION = "0.1.0"
|
|
57
|
+
PLATFORM = "github-actions"
|
|
58
|
+
|
|
59
|
+
# Plataformas reconhecidas por convenção de path e declaradas opcionais:
|
|
60
|
+
# nenhuma é parseada neste slice — documentação honesta de fronteira.
|
|
61
|
+
OPTIONAL_PLATFORMS: tuple[str, ...] = (
|
|
62
|
+
"gitlab-ci:.gitlab-ci.yml",
|
|
63
|
+
"azure-pipelines:azure-pipelines.yml",
|
|
64
|
+
"jenkins:Jenkinsfile",
|
|
65
|
+
"circleci:.circleci/config.yml",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_WORKFLOW_GLOBS = (
|
|
69
|
+
".github/workflows/*.yml",
|
|
70
|
+
".github/workflows/*.yaml",
|
|
71
|
+
"*/.github/workflows/*.yml",
|
|
72
|
+
"*/.github/workflows/*.yaml",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
ContentReader = Callable[[str], bytes | None]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _workspace_reader(root: Path) -> ContentReader:
|
|
79
|
+
"""Reader restrito ao workspace: só lê paths relativos declarados."""
|
|
80
|
+
base = root.resolve()
|
|
81
|
+
|
|
82
|
+
def _read(rel: str) -> bytes | None:
|
|
83
|
+
# recusa traversal: path deve permanecer sob ``base``
|
|
84
|
+
if rel.startswith("/") or ".." in PurePosixPath(rel).parts:
|
|
85
|
+
return None
|
|
86
|
+
target = (base / rel).resolve()
|
|
87
|
+
# pathlib puro — separador portátil (Windows usa "\\"); o check
|
|
88
|
+
# exige containment real, não prefixo de string.
|
|
89
|
+
if not target.is_relative_to(base):
|
|
90
|
+
return None
|
|
91
|
+
if not target.is_file():
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return target.read_bytes()
|
|
95
|
+
except OSError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return _read
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GitHubActionsAdapter:
|
|
102
|
+
"""``DomainAdapter`` para workflows GitHub Actions."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, read_content: ContentReader | None = None) -> None:
|
|
105
|
+
"""``read_content`` injeta acesso a bytes por path relativo.
|
|
106
|
+
|
|
107
|
+
Sem reader → cada artefato gera diagnóstico ``missing-content``
|
|
108
|
+
e zero entidades (a extração nunca inventa conteúdo).
|
|
109
|
+
"""
|
|
110
|
+
self._read = read_content
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def for_workspace(cls, root: Path) -> GitHubActionsAdapter:
|
|
114
|
+
"""Adapter que lê artefatos do request sob ``root`` no disco.
|
|
115
|
+
|
|
116
|
+
Restrito a paths POSIX relativos declarados em
|
|
117
|
+
``request.artifacts`` — traversal e paths absolutos recusados.
|
|
118
|
+
"""
|
|
119
|
+
return cls(_workspace_reader(root))
|
|
120
|
+
|
|
121
|
+
# --- contrato ---------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def domain_id(self) -> str:
|
|
125
|
+
return DOMAIN_ID
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def adapter_version(self) -> str:
|
|
129
|
+
return ADAPTER_VERSION
|
|
130
|
+
|
|
131
|
+
def supported_artifacts(self) -> ArtifactSupport:
|
|
132
|
+
return ArtifactSupport(globs=_WORKFLOW_GLOBS)
|
|
133
|
+
|
|
134
|
+
def entity_kinds(self) -> frozenset[DomainEntityKind]:
|
|
135
|
+
return frozenset(
|
|
136
|
+
{DomainEntityKind.CICD_WORKFLOW, DomainEntityKind.CICD_JOB}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def relation_kinds(self) -> frozenset[str]:
|
|
140
|
+
return frozenset(
|
|
141
|
+
{
|
|
142
|
+
"CONTAINS",
|
|
143
|
+
str(DomainRelationKind.TRIGGERS),
|
|
144
|
+
str(DomainRelationKind.DEPENDS_ON),
|
|
145
|
+
str(DomainRelationKind.TESTS),
|
|
146
|
+
str(DomainRelationKind.DEPLOYS),
|
|
147
|
+
str(DomainRelationKind.REFERENCES),
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def confidence_model(self) -> ConfidenceModel:
|
|
152
|
+
return ConfidenceModel(
|
|
153
|
+
ceiling=ConfidenceClass.SYNTACTIC,
|
|
154
|
+
default=ConfidenceClass.SYNTACTIC,
|
|
155
|
+
allows_inferred=True,
|
|
156
|
+
notes=(
|
|
157
|
+
"structural edges (CONTAINS/TRIGGERS/DEPENDS_ON/"
|
|
158
|
+
"REFERENCES) are syntactic; step classification edges "
|
|
159
|
+
"(TESTS/DEPLOYS by declared patterns) are heuristic; "
|
|
160
|
+
"inferred is permitted but never reports resolved"
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def discovery_rules(self) -> tuple[DiscoveryRule, ...]:
|
|
165
|
+
return (
|
|
166
|
+
DiscoveryRule(
|
|
167
|
+
glob=".github/workflows/*.yml",
|
|
168
|
+
artifact_kind="gha-workflow",
|
|
169
|
+
notes="GitHub Actions workflow (primary platform)",
|
|
170
|
+
),
|
|
171
|
+
DiscoveryRule(
|
|
172
|
+
glob=".github/workflows/*.yaml",
|
|
173
|
+
artifact_kind="gha-workflow",
|
|
174
|
+
notes="GitHub Actions workflow, .yaml spelling",
|
|
175
|
+
),
|
|
176
|
+
DiscoveryRule(
|
|
177
|
+
glob="*/.github/workflows/*.yml",
|
|
178
|
+
artifact_kind="gha-workflow",
|
|
179
|
+
notes="nested workflow in monorepo sub-root",
|
|
180
|
+
),
|
|
181
|
+
DiscoveryRule(
|
|
182
|
+
glob="*/.github/workflows/*.yaml",
|
|
183
|
+
artifact_kind="gha-workflow",
|
|
184
|
+
notes="nested workflow in monorepo sub-root",
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# --- extração ---------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def extract(
|
|
191
|
+
self, request: DomainExtractionRequest
|
|
192
|
+
) -> DomainExtractionResult:
|
|
193
|
+
artifacts = sorted(request.artifacts, key=lambda a: a.path)
|
|
194
|
+
entities: list[ExtractedDomainEntity] = []
|
|
195
|
+
relations: list[ExtractedDomainRelation] = []
|
|
196
|
+
evidence: list[EvidenceRecord] = []
|
|
197
|
+
diagnostics: list[DomainDiagnostic] = []
|
|
198
|
+
stats: dict[str, int] = {
|
|
199
|
+
"files_seen": len(artifacts), "files_parsed": 0,
|
|
200
|
+
"workflows": 0, "jobs": 0, "steps": 0, "triggers": 0,
|
|
201
|
+
"secrets_redacted": 0, "expressions_masked": 0,
|
|
202
|
+
"diagnostics": 0,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Fase 1 — parse de todos os artefatos (paths → obs) antes de
|
|
206
|
+
# emitir relações: ``uses: ./...`` só resolve com o mapa cheio.
|
|
207
|
+
parsed: dict[str, tuple[WorkflowObs, str, int]] = {}
|
|
208
|
+
# path -> (obs, sha256, evidence_index)
|
|
209
|
+
for art in artifacts:
|
|
210
|
+
out = self._extract_file(
|
|
211
|
+
art, request, entities, evidence, diagnostics, stats,
|
|
212
|
+
)
|
|
213
|
+
if out is not None:
|
|
214
|
+
parsed[art.path] = out
|
|
215
|
+
|
|
216
|
+
# Fase 2 — relações por workflow/jobs já com identidades certas.
|
|
217
|
+
wf_identity: dict[str, DomainIdentity] = {
|
|
218
|
+
path: self._wf_identity(request, path, obs)
|
|
219
|
+
for path, (obs, _sha, _ev) in parsed.items()
|
|
220
|
+
}
|
|
221
|
+
for path, (obs, _sha, evi) in parsed.items():
|
|
222
|
+
self._emit_workflow_relations(
|
|
223
|
+
request, obs, path, evi, wf_identity,
|
|
224
|
+
relations, diagnostics, stats,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
diagnostics = self._dedup_diags(diagnostics)
|
|
228
|
+
stats["diagnostics"] = len(diagnostics)
|
|
229
|
+
return DomainExtractionResult(
|
|
230
|
+
domain_id=DOMAIN_ID,
|
|
231
|
+
adapter_version=ADAPTER_VERSION,
|
|
232
|
+
entities=tuple(entities),
|
|
233
|
+
relations=tuple(relations),
|
|
234
|
+
evidence=tuple(evidence),
|
|
235
|
+
diagnostics=tuple(diagnostics),
|
|
236
|
+
stats=stats,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# --- fase 1: arquivo → entidades + evidência ---------------------------
|
|
240
|
+
|
|
241
|
+
def _load_document(
|
|
242
|
+
self,
|
|
243
|
+
art: DiscoveredArtifact,
|
|
244
|
+
_d: Callable[[str, str, str], None],
|
|
245
|
+
) -> tuple[yamlmini.YNode, str] | None:
|
|
246
|
+
"""Lê bytes do artefato, valida hash/UTF-8 e parseia YAML.
|
|
247
|
+
|
|
248
|
+
Retorna ``(root, sha256)``; ``None`` + diagnóstico em qualquer
|
|
249
|
+
falha — nunca exceção.
|
|
250
|
+
"""
|
|
251
|
+
if self._read is None:
|
|
252
|
+
_d("missing-content-reader", "warning",
|
|
253
|
+
"no content reader configured; artifact not parsed")
|
|
254
|
+
return None
|
|
255
|
+
content = self._read(art.path)
|
|
256
|
+
if content is None:
|
|
257
|
+
_d("missing-content", "warning",
|
|
258
|
+
"artifact declared but content unavailable")
|
|
259
|
+
return None
|
|
260
|
+
sha = hashlib.sha256(content).hexdigest()
|
|
261
|
+
if art.sha256 and art.sha256 != sha:
|
|
262
|
+
_d(
|
|
263
|
+
"artifact-hash-mismatch", "error",
|
|
264
|
+
"discovered sha256 does not match content; artifact "
|
|
265
|
+
"skipped to keep evidence honest",
|
|
266
|
+
)
|
|
267
|
+
return None
|
|
268
|
+
try:
|
|
269
|
+
text = content.decode("utf-8")
|
|
270
|
+
except UnicodeDecodeError as exc:
|
|
271
|
+
_d("decode-error", "error", f"not valid UTF-8: {exc}")
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
result = yamlmini.parse(text)
|
|
275
|
+
if result.root is None:
|
|
276
|
+
if not result.diagnostics:
|
|
277
|
+
_d("empty-document", "info", "no YAML document content")
|
|
278
|
+
else:
|
|
279
|
+
for yd in result.diagnostics:
|
|
280
|
+
_d(
|
|
281
|
+
f"yaml-{yd.code}", yd.severity,
|
|
282
|
+
f"line {yd.line}: {yd.message}"
|
|
283
|
+
if yd.line else yd.message,
|
|
284
|
+
)
|
|
285
|
+
return None
|
|
286
|
+
for yd in result.diagnostics:
|
|
287
|
+
_d(
|
|
288
|
+
f"yaml-{yd.code}", yd.severity,
|
|
289
|
+
f"line {yd.line}: {yd.message}" if yd.line else yd.message,
|
|
290
|
+
)
|
|
291
|
+
return result.root, sha
|
|
292
|
+
|
|
293
|
+
def _extract_file(
|
|
294
|
+
self,
|
|
295
|
+
art: DiscoveredArtifact,
|
|
296
|
+
request: DomainExtractionRequest,
|
|
297
|
+
entities: list[ExtractedDomainEntity],
|
|
298
|
+
evidence: list[EvidenceRecord],
|
|
299
|
+
diagnostics: list[DomainDiagnostic],
|
|
300
|
+
stats: dict[str, int],
|
|
301
|
+
) -> tuple[WorkflowObs, str, int] | None:
|
|
302
|
+
def _d(code: str, sev: str, msg: str) -> None:
|
|
303
|
+
diagnostics.append(
|
|
304
|
+
DomainDiagnostic(code, sev, msg, art.path, DOMAIN_ID)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
loaded = self._load_document(art, _d)
|
|
308
|
+
if loaded is None:
|
|
309
|
+
return None
|
|
310
|
+
root_node, sha = loaded
|
|
311
|
+
|
|
312
|
+
parsed = parse_workflow(root_node, art.path)
|
|
313
|
+
for yd in parsed.diagnostics:
|
|
314
|
+
diagnostics.append(
|
|
315
|
+
DomainDiagnostic(
|
|
316
|
+
f"gha-{yd.code}", yd.severity,
|
|
317
|
+
f"line {yd.line}: {yd.message}" if yd.line else yd.message,
|
|
318
|
+
art.path, DOMAIN_ID,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
obs = parsed.obs
|
|
322
|
+
if obs is None:
|
|
323
|
+
return None
|
|
324
|
+
if not obs.triggers and not obs.jobs:
|
|
325
|
+
_d(
|
|
326
|
+
"not-a-gha-workflow", "info",
|
|
327
|
+
"YAML document has neither 'on:' triggers nor 'jobs:' — "
|
|
328
|
+
"not a GitHub Actions workflow; no facts emitted",
|
|
329
|
+
)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
stats["files_parsed"] += 1
|
|
333
|
+
evi = len(evidence)
|
|
334
|
+
evidence.append(
|
|
335
|
+
evidence_record(
|
|
336
|
+
adapter_domain=DOMAIN_ID,
|
|
337
|
+
adapter_version=ADAPTER_VERSION,
|
|
338
|
+
path=art.path, content_hash=sha, span=root_node.span,
|
|
339
|
+
notes=f"platform={PLATFORM}",
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
wf_id = self._wf_identity(request, art.path, obs)
|
|
344
|
+
wf_attrs = self._wf_attrs(obs, stats)
|
|
345
|
+
entities.append(
|
|
346
|
+
ExtractedDomainEntity(
|
|
347
|
+
identity=wf_id, kind=DomainEntityKind.CICD_WORKFLOW,
|
|
348
|
+
name=obs.name, path=art.path, span=obs.span,
|
|
349
|
+
content_hash=sha, attributes=wf_attrs, evidence=(evi,),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
stats["workflows"] += 1
|
|
353
|
+
stats["triggers"] += len(obs.triggers)
|
|
354
|
+
|
|
355
|
+
for job in obs.jobs:
|
|
356
|
+
entities.append(
|
|
357
|
+
self._job_entity(
|
|
358
|
+
request, art.path, obs, job, sha, evi, stats
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return obs, sha, evi
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _wf_attrs(obs: WorkflowObs, stats: dict[str, int]) -> dict[str, str]:
|
|
365
|
+
"""Atributos observados do workflow (triggers, permissions…)."""
|
|
366
|
+
attrs: dict[str, str] = {
|
|
367
|
+
"platform": PLATFORM,
|
|
368
|
+
"name": obs.name,
|
|
369
|
+
"name_declared": "true" if obs.name_declared else "false",
|
|
370
|
+
"triggers": ",".join(t.kind for t in obs.triggers),
|
|
371
|
+
}
|
|
372
|
+
for trig in obs.triggers:
|
|
373
|
+
for dk, dv in trig.details:
|
|
374
|
+
stats["expressions_masked"] += dv.count("<expr>")
|
|
375
|
+
stats["secrets_redacted"] += dv.count("<secret>")
|
|
376
|
+
attrs[f"trigger.{trig.kind}.{dk}"] = dv
|
|
377
|
+
if obs.permissions:
|
|
378
|
+
attrs["permissions"] = obs.permissions
|
|
379
|
+
if obs.env_keys:
|
|
380
|
+
attrs["env_keys"] = ",".join(obs.env_keys)
|
|
381
|
+
if obs.concurrency:
|
|
382
|
+
attrs["concurrency"] = obs.concurrency
|
|
383
|
+
return attrs
|
|
384
|
+
|
|
385
|
+
# --- identidades --------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _namespace(path: str) -> str:
|
|
389
|
+
parent = str(PurePosixPath(path).parent)
|
|
390
|
+
return "" if parent == "." else parent
|
|
391
|
+
|
|
392
|
+
def _wf_identity(
|
|
393
|
+
self,
|
|
394
|
+
request: DomainExtractionRequest,
|
|
395
|
+
path: str,
|
|
396
|
+
obs: WorkflowObs,
|
|
397
|
+
) -> DomainIdentity:
|
|
398
|
+
"""repo + root + path(ns+basename) + nome declarado do workflow."""
|
|
399
|
+
return DomainIdentity(
|
|
400
|
+
domain=DOMAIN_ID,
|
|
401
|
+
repository=request.repository,
|
|
402
|
+
root=request.root,
|
|
403
|
+
namespace=self._namespace(path),
|
|
404
|
+
environment=request.environment,
|
|
405
|
+
qualified_name=f"{PurePosixPath(path).name}#{obs.name}",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def _job_identity(
|
|
409
|
+
self, wf_id: DomainIdentity, job: JobObs
|
|
410
|
+
) -> DomainIdentity:
|
|
411
|
+
return DomainIdentity(
|
|
412
|
+
domain=wf_id.domain, repository=wf_id.repository,
|
|
413
|
+
root=wf_id.root, catalog=wf_id.catalog,
|
|
414
|
+
namespace=wf_id.namespace, environment=wf_id.environment,
|
|
415
|
+
qualified_name=f"{wf_id.qualified_name}/jobs/{job.job_id}",
|
|
416
|
+
snapshot=wf_id.snapshot,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# --- entidades de job --------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def _job_entity(
|
|
422
|
+
self,
|
|
423
|
+
request: DomainExtractionRequest,
|
|
424
|
+
path: str,
|
|
425
|
+
obs: WorkflowObs,
|
|
426
|
+
job: JobObs,
|
|
427
|
+
sha: str,
|
|
428
|
+
evi: int,
|
|
429
|
+
stats: dict[str, int],
|
|
430
|
+
) -> ExtractedDomainEntity:
|
|
431
|
+
stats["jobs"] += 1
|
|
432
|
+
stats["steps"] += len(job.steps)
|
|
433
|
+
attrs = self._job_static_attrs(job)
|
|
434
|
+
attrs.update(self._job_class_attrs(job))
|
|
435
|
+
stats["expressions_masked"] += sum(
|
|
436
|
+
v.count("<expr>") for v in attrs.values()
|
|
437
|
+
)
|
|
438
|
+
stats["secrets_redacted"] += sum(
|
|
439
|
+
v.count("<secret>") for v in attrs.values()
|
|
440
|
+
)
|
|
441
|
+
return ExtractedDomainEntity(
|
|
442
|
+
identity=self._job_identity(
|
|
443
|
+
self._wf_identity(request, path, obs), job
|
|
444
|
+
),
|
|
445
|
+
kind=DomainEntityKind.CICD_JOB,
|
|
446
|
+
name=job.display_name or job.job_id,
|
|
447
|
+
path=path, span=job.span, content_hash=sha,
|
|
448
|
+
attributes=attrs, evidence=(evi,),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _job_static_attrs(job: JobObs) -> dict[str, str]:
|
|
453
|
+
"""Atributos estruturais observados diretamente no job."""
|
|
454
|
+
attrs: dict[str, str] = {"job_id": job.job_id}
|
|
455
|
+
for key, val in (
|
|
456
|
+
("display_name", job.display_name),
|
|
457
|
+
("calls_reusable", job.uses),
|
|
458
|
+
("runs_on", job.runs_on),
|
|
459
|
+
("if", job.if_expr),
|
|
460
|
+
("fail_fast", job.fail_fast),
|
|
461
|
+
("max_parallel", job.max_parallel),
|
|
462
|
+
("permissions", job.permissions),
|
|
463
|
+
("secrets", job.secrets_mode),
|
|
464
|
+
("concurrency", job.concurrency),
|
|
465
|
+
):
|
|
466
|
+
if val:
|
|
467
|
+
attrs[key] = val
|
|
468
|
+
if job.environment:
|
|
469
|
+
attrs["environment"] = job.environment
|
|
470
|
+
elif job.environment_dynamic:
|
|
471
|
+
attrs["environment"] = "<dynamic>"
|
|
472
|
+
for key, vals in (
|
|
473
|
+
("matrix_axes", job.matrix_axes),
|
|
474
|
+
("matrix_extras", job.matrix_extras),
|
|
475
|
+
("env_keys", job.env_keys),
|
|
476
|
+
("needs", job.needs),
|
|
477
|
+
):
|
|
478
|
+
if vals:
|
|
479
|
+
attrs[key] = ",".join(vals)
|
|
480
|
+
if job.matrix_dynamic:
|
|
481
|
+
attrs["matrix_dynamic"] = "true"
|
|
482
|
+
if job.outputs:
|
|
483
|
+
attrs["outputs_declared"] = str(job.outputs)
|
|
484
|
+
return attrs
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _job_class_attrs(job: JobObs) -> dict[str, str]:
|
|
488
|
+
"""Classificação declarada → atributos evidence-backed.
|
|
489
|
+
|
|
490
|
+
``BUILDS``/``PUBLISHES`` não existem no vocabulário fechado de
|
|
491
|
+
relações (ADR-0010) — viram atributos, nunca arestas inventadas.
|
|
492
|
+
"""
|
|
493
|
+
attrs: dict[str, str] = {"step_count": str(len(job.steps))}
|
|
494
|
+
for label, key in (
|
|
495
|
+
("test", "test_steps"), ("deploy", "deploy_steps"),
|
|
496
|
+
("build", "build_steps"), ("publish", "publish_steps"),
|
|
497
|
+
):
|
|
498
|
+
n = sum(1 for s in job.steps if label in s.classes)
|
|
499
|
+
if n:
|
|
500
|
+
attrs[key] = str(n)
|
|
501
|
+
outs = [a for s in job.steps for a in s.artifacts_out]
|
|
502
|
+
ins = [a for s in job.steps for a in s.artifacts_in]
|
|
503
|
+
if outs:
|
|
504
|
+
attrs["artifacts_out"] = ",".join(outs)
|
|
505
|
+
if ins:
|
|
506
|
+
attrs["artifacts_in"] = ",".join(ins)
|
|
507
|
+
return attrs
|
|
508
|
+
|
|
509
|
+
# --- fase 2: relações ---------------------------------------------------
|
|
510
|
+
|
|
511
|
+
def _emit_workflow_relations(
|
|
512
|
+
self,
|
|
513
|
+
request: DomainExtractionRequest,
|
|
514
|
+
obs: WorkflowObs,
|
|
515
|
+
path: str,
|
|
516
|
+
evi: int,
|
|
517
|
+
wf_identity: Mapping[str, DomainIdentity],
|
|
518
|
+
relations: list[ExtractedDomainRelation],
|
|
519
|
+
diagnostics: list[DomainDiagnostic],
|
|
520
|
+
stats: dict[str, int],
|
|
521
|
+
) -> None:
|
|
522
|
+
wf_id = wf_identity[path]
|
|
523
|
+
resolver = domain_extractor_id(DOMAIN_ID)
|
|
524
|
+
|
|
525
|
+
def _res(
|
|
526
|
+
status: ResolutionStatus,
|
|
527
|
+
conf: ConfidenceClass,
|
|
528
|
+
target: DomainIdentity | None = None,
|
|
529
|
+
text: str = "",
|
|
530
|
+
) -> DomainResolution:
|
|
531
|
+
return DomainResolution(
|
|
532
|
+
status=status, confidence_class=conf, resolver=resolver,
|
|
533
|
+
target=target, unresolved_text=text,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# TRIGGERS: workflow → evento externo (GitHub event bus fora do
|
|
537
|
+
# snapshot) — alvo textual honesto, status external.
|
|
538
|
+
for trig in obs.triggers:
|
|
539
|
+
relations.append(
|
|
540
|
+
ExtractedDomainRelation(
|
|
541
|
+
relation_type=str(DomainRelationKind.TRIGGERS),
|
|
542
|
+
path=path, from_identity=wf_id,
|
|
543
|
+
to_unresolved=trig.descriptor,
|
|
544
|
+
resolution=_res(
|
|
545
|
+
ResolutionStatus.EXTERNAL,
|
|
546
|
+
ConfidenceClass.SYNTACTIC,
|
|
547
|
+
text=trig.descriptor,
|
|
548
|
+
),
|
|
549
|
+
span=trig.span, evidence=(evi,),
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
for job in obs.jobs:
|
|
554
|
+
job_id = self._job_identity(wf_id, job)
|
|
555
|
+
|
|
556
|
+
# CONTAINS: workflow contém job — resolvido no mesmo arquivo.
|
|
557
|
+
relations.append(
|
|
558
|
+
ExtractedDomainRelation(
|
|
559
|
+
relation_type="CONTAINS",
|
|
560
|
+
path=path, from_identity=wf_id, to_identity=job_id,
|
|
561
|
+
resolution=_res(
|
|
562
|
+
ResolutionStatus.RESOLVED,
|
|
563
|
+
ConfidenceClass.SYNTACTIC, target=job_id,
|
|
564
|
+
),
|
|
565
|
+
span=job.span, evidence=(evi,),
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# DEPENDS_ON: needs: → job(s) do mesmo workflow.
|
|
570
|
+
for need in job.needs:
|
|
571
|
+
target_obs = next(
|
|
572
|
+
(j for j in obs.jobs if j.job_id == need), None
|
|
573
|
+
)
|
|
574
|
+
if target_obs is not None:
|
|
575
|
+
tgt = self._job_identity(wf_id, target_obs)
|
|
576
|
+
relations.append(
|
|
577
|
+
ExtractedDomainRelation(
|
|
578
|
+
relation_type=str(DomainRelationKind.DEPENDS_ON),
|
|
579
|
+
path=path, from_identity=job_id,
|
|
580
|
+
to_identity=tgt,
|
|
581
|
+
resolution=_res(
|
|
582
|
+
ResolutionStatus.RESOLVED,
|
|
583
|
+
ConfidenceClass.SYNTACTIC, target=tgt,
|
|
584
|
+
),
|
|
585
|
+
span=job.needs_span or job.span,
|
|
586
|
+
evidence=(evi,),
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
diagnostics.append(
|
|
591
|
+
DomainDiagnostic(
|
|
592
|
+
"unresolved-needs", "warning",
|
|
593
|
+
f"job {job.job_id!r} needs {need!r} which is "
|
|
594
|
+
"not a declared job in this workflow",
|
|
595
|
+
path, DOMAIN_ID,
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
relations.append(
|
|
599
|
+
ExtractedDomainRelation(
|
|
600
|
+
relation_type=str(DomainRelationKind.DEPENDS_ON),
|
|
601
|
+
path=path, from_identity=job_id,
|
|
602
|
+
to_unresolved=need,
|
|
603
|
+
resolution=_res(
|
|
604
|
+
ResolutionStatus.UNRESOLVED,
|
|
605
|
+
ConfidenceClass.SYNTACTIC, text=need,
|
|
606
|
+
),
|
|
607
|
+
span=job.needs_span or job.span,
|
|
608
|
+
evidence=(evi,),
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
# REFERENCES: job-level ``uses:`` = reusable workflow.
|
|
613
|
+
if job.uses:
|
|
614
|
+
self._emit_uses(
|
|
615
|
+
job.uses, job_id, path, evi, wf_identity,
|
|
616
|
+
relations, diagnostics, _res, job.span,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# DEPLOYS: environment estático → alvo externo nomeado.
|
|
620
|
+
if job.environment:
|
|
621
|
+
relations.append(
|
|
622
|
+
ExtractedDomainRelation(
|
|
623
|
+
relation_type=str(DomainRelationKind.DEPLOYS),
|
|
624
|
+
path=path, from_identity=job_id,
|
|
625
|
+
to_unresolved=f"environment:{job.environment}",
|
|
626
|
+
resolution=_res(
|
|
627
|
+
ResolutionStatus.EXTERNAL,
|
|
628
|
+
ConfidenceClass.SYNTACTIC,
|
|
629
|
+
text=f"environment:{job.environment}",
|
|
630
|
+
),
|
|
631
|
+
span=job.span, evidence=(evi,),
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# arestas por passo: TESTS/DEPLOYS/REFERENCES
|
|
636
|
+
for step in job.steps:
|
|
637
|
+
self._emit_step_relations(
|
|
638
|
+
step, job_id, path, evi, wf_identity,
|
|
639
|
+
relations, diagnostics, _res,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def _emit_uses(
|
|
643
|
+
self,
|
|
644
|
+
uses: str,
|
|
645
|
+
from_id: DomainIdentity,
|
|
646
|
+
path: str,
|
|
647
|
+
evi: int,
|
|
648
|
+
wf_identity: Mapping[str, DomainIdentity],
|
|
649
|
+
relations: list[ExtractedDomainRelation],
|
|
650
|
+
diagnostics: list[DomainDiagnostic],
|
|
651
|
+
_res: Callable[..., DomainResolution],
|
|
652
|
+
span: SourceSpan | None,
|
|
653
|
+
) -> None:
|
|
654
|
+
"""``uses:`` → REFERENCES; ``./`` resolve só se artefato existe."""
|
|
655
|
+
if uses.startswith("./") or uses.startswith(".\\"):
|
|
656
|
+
norm = uses[2:].replace("\\", "/")
|
|
657
|
+
if norm in wf_identity:
|
|
658
|
+
tgt = wf_identity[norm]
|
|
659
|
+
relations.append(
|
|
660
|
+
ExtractedDomainRelation(
|
|
661
|
+
relation_type=str(DomainRelationKind.REFERENCES),
|
|
662
|
+
path=path, from_identity=from_id, to_identity=tgt,
|
|
663
|
+
resolution=_res(
|
|
664
|
+
ResolutionStatus.RESOLVED,
|
|
665
|
+
ConfidenceClass.SYNTACTIC, target=tgt,
|
|
666
|
+
),
|
|
667
|
+
span=span, evidence=(evi,),
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
else:
|
|
671
|
+
diagnostics.append(
|
|
672
|
+
DomainDiagnostic(
|
|
673
|
+
"local-ref-not-in-artifacts", "info",
|
|
674
|
+
f"local 'uses:' {uses!r} not among discovered "
|
|
675
|
+
"artifacts — reported unresolved",
|
|
676
|
+
path, DOMAIN_ID,
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
relations.append(
|
|
680
|
+
ExtractedDomainRelation(
|
|
681
|
+
relation_type=str(DomainRelationKind.REFERENCES),
|
|
682
|
+
path=path, from_identity=from_id,
|
|
683
|
+
to_unresolved=f"local:{uses}",
|
|
684
|
+
resolution=_res(
|
|
685
|
+
ResolutionStatus.UNRESOLVED,
|
|
686
|
+
ConfidenceClass.SYNTACTIC, text=uses,
|
|
687
|
+
),
|
|
688
|
+
span=span, evidence=(evi,),
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
return
|
|
692
|
+
# externo: owner/repo[/path]@ref ou docker:// — nunca resolvido
|
|
693
|
+
relations.append(
|
|
694
|
+
ExtractedDomainRelation(
|
|
695
|
+
relation_type=str(DomainRelationKind.REFERENCES),
|
|
696
|
+
path=path, from_identity=from_id,
|
|
697
|
+
to_unresolved=uses,
|
|
698
|
+
resolution=_res(
|
|
699
|
+
ResolutionStatus.EXTERNAL,
|
|
700
|
+
ConfidenceClass.SYNTACTIC, text=uses,
|
|
701
|
+
),
|
|
702
|
+
span=span, evidence=(evi,),
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _emit_step_relations(
|
|
707
|
+
self,
|
|
708
|
+
step: StepObs,
|
|
709
|
+
job_id: DomainIdentity,
|
|
710
|
+
path: str,
|
|
711
|
+
evi: int,
|
|
712
|
+
wf_identity: Mapping[str, DomainIdentity],
|
|
713
|
+
relations: list[ExtractedDomainRelation],
|
|
714
|
+
diagnostics: list[DomainDiagnostic],
|
|
715
|
+
_res: Callable[..., DomainResolution],
|
|
716
|
+
) -> None:
|
|
717
|
+
if step.uses:
|
|
718
|
+
self._emit_uses(
|
|
719
|
+
step.uses, job_id, path, evi, wf_identity,
|
|
720
|
+
relations, diagnostics, _res, step.span,
|
|
721
|
+
)
|
|
722
|
+
desc = step_descriptor(step)
|
|
723
|
+
if "test" in step.classes:
|
|
724
|
+
relations.append(
|
|
725
|
+
ExtractedDomainRelation(
|
|
726
|
+
relation_type=str(DomainRelationKind.TESTS),
|
|
727
|
+
path=path, from_identity=job_id,
|
|
728
|
+
to_unresolved=desc,
|
|
729
|
+
resolution=_res(
|
|
730
|
+
ResolutionStatus.UNRESOLVED,
|
|
731
|
+
ConfidenceClass.HEURISTIC, text=desc,
|
|
732
|
+
),
|
|
733
|
+
span=step.span, evidence=(evi,),
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
if "deploy" in step.classes:
|
|
737
|
+
relations.append(
|
|
738
|
+
ExtractedDomainRelation(
|
|
739
|
+
relation_type=str(DomainRelationKind.DEPLOYS),
|
|
740
|
+
path=path, from_identity=job_id,
|
|
741
|
+
to_unresolved=desc,
|
|
742
|
+
resolution=_res(
|
|
743
|
+
ResolutionStatus.UNRESOLVED,
|
|
744
|
+
ConfidenceClass.HEURISTIC, text=desc,
|
|
745
|
+
),
|
|
746
|
+
span=step.span, evidence=(evi,),
|
|
747
|
+
)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _dedup_diags(
|
|
752
|
+
diags: list[DomainDiagnostic],
|
|
753
|
+
) -> list[DomainDiagnostic]:
|
|
754
|
+
seen: set[tuple[str, str, str]] = set()
|
|
755
|
+
out: list[DomainDiagnostic] = []
|
|
756
|
+
for d in diags:
|
|
757
|
+
key = (d.code, d.path, d.message)
|
|
758
|
+
if key in seen:
|
|
759
|
+
continue
|
|
760
|
+
seen.add(key)
|
|
761
|
+
out.append(d)
|
|
762
|
+
return out
|