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