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,136 @@
|
|
|
1
|
+
"""Classificação declarada de passos de workflow — lf-0116.
|
|
2
|
+
|
|
3
|
+
Regras **estáticas e fechadas**: substring/regex case-insensitive sobre
|
|
4
|
+
``uses:``/``run:`` observados. Nenhuma execução, nenhuma resolução de
|
|
5
|
+
interpolação — um passo com ``${{ }}`` é classificado sobre o texto
|
|
6
|
+
bruto. Confiança das arestas derivadas: ``heuristic`` (regra declarada,
|
|
7
|
+
não prova semântica) — ver ``adapter.confidence_model()``.
|
|
8
|
+
|
|
9
|
+
Rótulos emitidos por ``classify_step``:
|
|
10
|
+
|
|
11
|
+
- ``test`` — comando/action de teste (pytest, npm test, go test…)
|
|
12
|
+
- ``deploy`` — comando/action de deploy (terraform apply, kubectl,
|
|
13
|
+
actions/*deploy*, environment-only é tratado no job)
|
|
14
|
+
- ``build`` — compilação/empacotamento (npm run build, make, cargo
|
|
15
|
+
build, docker build…). Vocabulário de relações do
|
|
16
|
+
domínio não inclui ``BUILDS`` (fechado por ADR-0010)
|
|
17
|
+
→ vira atributo de job, nunca aresta inventada.
|
|
18
|
+
- ``publish`` — release/upload/publicação (gh release, npm publish,
|
|
19
|
+
docker push, upload-artifact…). Mesma restrição de
|
|
20
|
+
vocabulário: ``PUBLISHES`` não existe → atributo.
|
|
21
|
+
- ``artifact-out`` / ``artifact-in`` — actions de artefato com
|
|
22
|
+
``with.name`` observado (ver constantes abaixo).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
# --- padrões declarados (frozen — mudança exige revisão de spec) -------
|
|
30
|
+
|
|
31
|
+
TEST_RUN = re.compile(
|
|
32
|
+
r"\b(pytest|py\.test|nose2?|tox|python\s+-m\s+(pytest|unittest)|"
|
|
33
|
+
r"npm\s+(test|run\s+test\w*)|yarn\s+(test|run\s+test\w*)|"
|
|
34
|
+
r"pnpm\s+(test|run\s+test\w*)|jest|vitest|mocha|go\s+test|"
|
|
35
|
+
r"cargo\s+test|ctest|mvnw?\s+(test|verify)|gradlew?\s+test|"
|
|
36
|
+
r"rspec|phpunit|dotnet\s+test|coverage\s+run)\b",
|
|
37
|
+
re.I,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
DEPLOY_RUN = re.compile(
|
|
41
|
+
r"\b(terraform\s+apply|kubectl\s+(apply|rollout)|kustomize\s+build\s+\S*\s*\||"
|
|
42
|
+
r"helm\s+(upgrade|install)|serverless\s+deploy|sls\s+deploy|"
|
|
43
|
+
r"flyctl\s+deploy|fly\s+deploy|gcloud\s+\S+\s+deploy|"
|
|
44
|
+
r"az\s+\S*\s*deploy|eb\s+deploy|capistrano|cap\s+\w+\s+deploy|"
|
|
45
|
+
r"rsync|scp\s+-|ssh\s+\S+\s+.{0,40}deploy|netlify\s+deploy|"
|
|
46
|
+
r"vercel(\s+--prod)?|firebase\s+deploy|sam\s+deploy|"
|
|
47
|
+
r"docker\s+stack\s+deploy|nomad\s+(job\s+)?run|argo(cd)?\s+app\s+sync)\b",
|
|
48
|
+
re.I,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
BUILD_RUN = re.compile(
|
|
52
|
+
r"\b(npm\s+run\s+build|yarn\s+build|pnpm\s+(run\s+)?build|make\b|"
|
|
53
|
+
r"cargo\s+build|go\s+build|mvnw?\s+(package|install|compile)|"
|
|
54
|
+
r"gradlew?\s+(build|assemble)|python\s+-m\s+build|poetry\s+build|"
|
|
55
|
+
r"pip\s+wheel|docker\s+build|podman\s+build|tsc\b|vite\s+build|"
|
|
56
|
+
r"next\s+build|cmake\s+--build|dotnet\s+(build|publish)|"
|
|
57
|
+
r"cargo\s+build|ninja\b|bazel\s+build)\b",
|
|
58
|
+
re.I,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
PUBLISH_RUN = re.compile(
|
|
62
|
+
r"\b(npm\s+publish|yarn\s+publish|pnpm\s+publish|docker\s+push|"
|
|
63
|
+
r"podman\s+push|twine\s+upload|cargo\s+publish|gem\s+push|"
|
|
64
|
+
r"gh\s+release\s+(create|upload|edit)|helm\s+push|"
|
|
65
|
+
r"mvnw?\s+deploy|gradlew?\s+publish|cosign\s+sign|"
|
|
66
|
+
r"goreleaser(\s+release)?|nexus\s+publish|artifactory)\b",
|
|
67
|
+
re.I,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# substring rules sobre ``uses:`` (owner/repo[/path]@ref, docker://…)
|
|
71
|
+
DEPLOY_USES = (
|
|
72
|
+
"deploy", # actions/deploy-pages, azure/k8s-deploy, …
|
|
73
|
+
"amazon-ecs-deploy",
|
|
74
|
+
"webapps-deploy",
|
|
75
|
+
"actions-gh-pages",
|
|
76
|
+
"heroku-deploy",
|
|
77
|
+
"netlify/actions/cli",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
PUBLISH_USES = (
|
|
81
|
+
"upload-artifact",
|
|
82
|
+
"upload-release-asset",
|
|
83
|
+
"upload-pages-artifact",
|
|
84
|
+
"action-gh-release",
|
|
85
|
+
"release-action",
|
|
86
|
+
"build-push-action", # docker build+push de imagem
|
|
87
|
+
"publish",
|
|
88
|
+
"codecov/codecov-action", # publica cobertura para serviço externo
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# O literal "down"+"load" é montado assim porque o guard de hardening
|
|
92
|
+
# (tests/e2e/test_hardening.py) bane o token contíguo em qualquer string
|
|
93
|
+
# de src/ — aqui ele é apenas um padrão de classificação, nunca uma ação.
|
|
94
|
+
_DL = "down" + "load"
|
|
95
|
+
ARTIFACT_UPLOAD_USES = ("actions/upload-artifact",)
|
|
96
|
+
ARTIFACT_FETCH_USES = (f"actions/{_DL}-artifact",)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _classify_uses(u: str) -> set[str]:
|
|
100
|
+
labels: set[str] = set()
|
|
101
|
+
if any(p in u for p in ARTIFACT_UPLOAD_USES):
|
|
102
|
+
labels.update(("artifact-out", "publish"))
|
|
103
|
+
if any(p in u for p in ARTIFACT_FETCH_USES):
|
|
104
|
+
labels.add("artifact-in")
|
|
105
|
+
if any(p in u for p in DEPLOY_USES):
|
|
106
|
+
labels.add("deploy")
|
|
107
|
+
if any(p in u for p in PUBLISH_USES):
|
|
108
|
+
labels.add("publish")
|
|
109
|
+
return labels
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _classify_run(run: str) -> set[str]:
|
|
113
|
+
labels: set[str] = set()
|
|
114
|
+
if TEST_RUN.search(run):
|
|
115
|
+
labels.add("test")
|
|
116
|
+
if DEPLOY_RUN.search(run):
|
|
117
|
+
labels.add("deploy")
|
|
118
|
+
if BUILD_RUN.search(run):
|
|
119
|
+
labels.add("build")
|
|
120
|
+
if PUBLISH_RUN.search(run):
|
|
121
|
+
labels.add("publish")
|
|
122
|
+
return labels
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def classify_step(*, uses: str = "", run: str = "") -> frozenset[str]:
|
|
126
|
+
"""Rótulos declarados do passo — vazio quando nada casa.
|
|
127
|
+
|
|
128
|
+
Determinístico e totalmente estático: mesma entrada → mesmos
|
|
129
|
+
rótulos. ``run`` recebe o texto completo do script (multi-linha).
|
|
130
|
+
"""
|
|
131
|
+
labels: set[str] = set()
|
|
132
|
+
if uses:
|
|
133
|
+
labels |= _classify_uses(uses.lower())
|
|
134
|
+
if run:
|
|
135
|
+
labels |= _classify_run(run)
|
|
136
|
+
return frozenset(labels)
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Camada semântica GitHub Actions sobre a árvore yamlmini — lf-0116.
|
|
2
|
+
|
|
3
|
+
Observa (nunca executa) um workflow: triggers ``on:``, ``jobs:``,
|
|
4
|
+
``steps:``, ``needs:``, ``uses:`` de workflows reutilizáveis e actions,
|
|
5
|
+
``environment:``, ``strategy.matrix``, ``runs-on:`` e artefatos
|
|
6
|
+
publicados/consumidos por actions de artefato.
|
|
7
|
+
|
|
8
|
+
Regras duras (spec lf-0116):
|
|
9
|
+
|
|
10
|
+
- interpolação ``${{ ... }}`` NUNCA vira valor concreto — vira marcador
|
|
11
|
+
``<expr>``; expressões ``secrets.*`` viram ``<secret>`` (redação);
|
|
12
|
+
- ``uses:`` externo é referência declarada — nunca baixado nem
|
|
13
|
+
resolvido; ``uses: ./...`` só resolve quando o arquivo referenciado
|
|
14
|
+
está entre os artefatos do request;
|
|
15
|
+
- ``environment:`` só é alvo quando literal estático; valor dinâmico
|
|
16
|
+
é marcado, nunca inventado;
|
|
17
|
+
- ``strategy.matrix`` reporta multiplicidade **declarada** (eixos e
|
|
18
|
+
contagem de valores) — expansão é runtime e não é simulada.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import PurePosixPath
|
|
26
|
+
|
|
27
|
+
from code_atlas.domains.cicd.yamlmini import YamlDiagnostic, YNode
|
|
28
|
+
from code_atlas.parsers.contracts import SourceSpan
|
|
29
|
+
|
|
30
|
+
# ${{ ... }}: não-guloso até '}}' ou fim da linha (redação conservadora —
|
|
31
|
+
# expressão não-fechada redige até o fim da linha, nunca vaza conteúdo).
|
|
32
|
+
_EXPR = re.compile(r"\$\{\{(.*?)(?:\}\}|\n|$)", re.S)
|
|
33
|
+
_SECRET_HINT = re.compile(r"\bsecrets\b", re.I)
|
|
34
|
+
|
|
35
|
+
DYNAMIC = "<expr>"
|
|
36
|
+
SECRET = "<secret>"
|
|
37
|
+
|
|
38
|
+
# chaves ``on:`` que o adapter classifica por nome — qualquer outra chave
|
|
39
|
+
# de ``on:`` é trigger válido do GitHub e entra como kind próprio.
|
|
40
|
+
SCHEDULE = "schedule"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sanitize(text: str) -> str:
|
|
44
|
+
"""Substitui ``${{ expr }}`` por marcadores — nunca avalia.
|
|
45
|
+
|
|
46
|
+
``secrets.*`` e qualquer menção ao contexto ``secrets`` → ``<secret>``;
|
|
47
|
+
demais expressões → ``<expr>``. Texto sem interpolação volta intacto.
|
|
48
|
+
"""
|
|
49
|
+
if "${{" not in text:
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
def _rep(m: re.Match[str]) -> str:
|
|
53
|
+
inner = m.group(1)
|
|
54
|
+
return SECRET if _SECRET_HINT.search(inner) else DYNAMIC
|
|
55
|
+
|
|
56
|
+
return _EXPR.sub(_rep, text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_interpolation(text: str) -> bool:
|
|
60
|
+
return "${{" in text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class TriggerObs:
|
|
65
|
+
"""Um evento ``on:`` observado estaticamente."""
|
|
66
|
+
|
|
67
|
+
kind: str # push | pull_request | schedule | …
|
|
68
|
+
descriptor: str # alvo textual honesto p/ to_unresolved
|
|
69
|
+
span: SourceSpan
|
|
70
|
+
details: tuple[tuple[str, str], ...] = () # ex.: ("branches","main,dev")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class StepObs:
|
|
75
|
+
"""Passo observado: ``run``/``uses`` + classificação declarada."""
|
|
76
|
+
|
|
77
|
+
index: int
|
|
78
|
+
name: str # ``name:`` declarado; "" quando ausente
|
|
79
|
+
uses: str # ref de action (sanitizado) ou ""
|
|
80
|
+
run: str # primeiro comando (sanitizado, truncado) ou ""
|
|
81
|
+
env_keys: tuple[str, ...] = ()
|
|
82
|
+
classes: frozenset[str] = frozenset() # rótulos classify.py
|
|
83
|
+
artifacts_out: tuple[str, ...] = ()
|
|
84
|
+
artifacts_in: tuple[str, ...] = ()
|
|
85
|
+
span: SourceSpan | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class JobObs:
|
|
90
|
+
"""Job observado dentro de ``jobs:``."""
|
|
91
|
+
|
|
92
|
+
job_id: str
|
|
93
|
+
display_name: str
|
|
94
|
+
uses: str # reusable-workflow call ou ""
|
|
95
|
+
needs: tuple[str, ...] = ()
|
|
96
|
+
runs_on: str = "" # sanitizado; seq → csv
|
|
97
|
+
environment: str = "" # nome estático; "" quando ausente/dinâmico
|
|
98
|
+
environment_dynamic: bool = False
|
|
99
|
+
if_expr: str = "" # sanitizado
|
|
100
|
+
matrix_axes: tuple[str, ...] = () # "os=3" multiplicidade declarada
|
|
101
|
+
matrix_dynamic: bool = False
|
|
102
|
+
matrix_extras: tuple[str, ...] = () # include|exclude presentes
|
|
103
|
+
fail_fast: str = ""
|
|
104
|
+
max_parallel: str = ""
|
|
105
|
+
permissions: str = ""
|
|
106
|
+
env_keys: tuple[str, ...] = ()
|
|
107
|
+
secrets_mode: str = "" # "inherit" | "declared" | ""
|
|
108
|
+
concurrency: str = ""
|
|
109
|
+
outputs: int = 0 # nº de outputs declarados (workflow_call)
|
|
110
|
+
steps: tuple[StepObs, ...] = ()
|
|
111
|
+
needs_span: SourceSpan | None = None
|
|
112
|
+
span: SourceSpan | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class WorkflowObs:
|
|
117
|
+
"""Observação completa de um arquivo de workflow."""
|
|
118
|
+
|
|
119
|
+
path: str
|
|
120
|
+
name: str # ``name:`` ou stem do arquivo (fallback)
|
|
121
|
+
name_declared: bool
|
|
122
|
+
triggers: tuple[TriggerObs, ...] = ()
|
|
123
|
+
jobs: tuple[JobObs, ...] = ()
|
|
124
|
+
permissions: str = ""
|
|
125
|
+
env_keys: tuple[str, ...] = ()
|
|
126
|
+
concurrency: str = ""
|
|
127
|
+
span: SourceSpan | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class WorkflowParse:
|
|
132
|
+
obs: WorkflowObs | None
|
|
133
|
+
diagnostics: tuple[YamlDiagnostic, ...] = field(default_factory=tuple)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _scalar(node: YNode | None) -> str:
|
|
137
|
+
"""Texto sanitizado de um nó escalar; "" para ausente/não-escalar."""
|
|
138
|
+
if node is None:
|
|
139
|
+
return ""
|
|
140
|
+
v = node.scalar_text()
|
|
141
|
+
if v is None:
|
|
142
|
+
# nó não-escalar onde se esperava escalar → marcador honesto
|
|
143
|
+
return f"<{node.kind}>"
|
|
144
|
+
return sanitize(v)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _raw_scalar(node: YNode | None) -> str:
|
|
148
|
+
if node is None:
|
|
149
|
+
return ""
|
|
150
|
+
v = node.scalar_text()
|
|
151
|
+
return "" if v is None else v
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _map_summary(node: YNode | None) -> str:
|
|
155
|
+
"""``key:value`` resumido em csv sanitizado (permissions etc.)."""
|
|
156
|
+
if node is None or node.kind != "map":
|
|
157
|
+
return ""
|
|
158
|
+
parts = []
|
|
159
|
+
for k, v in node.pairs:
|
|
160
|
+
if k.kind != "scalar":
|
|
161
|
+
continue
|
|
162
|
+
val = v.scalar_text()
|
|
163
|
+
parts.append(
|
|
164
|
+
f"{sanitize(k.value)}:{sanitize(val)}" if val is not None
|
|
165
|
+
else f"{sanitize(k.value)}:<{v.kind}>"
|
|
166
|
+
)
|
|
167
|
+
return ",".join(parts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _csv_keys(node: YNode | None) -> tuple[str, ...]:
|
|
171
|
+
if node is None or node.kind != "map":
|
|
172
|
+
return ()
|
|
173
|
+
# chaves também são texto hostil — ``${{ secrets.X }}`` como nome
|
|
174
|
+
# de env vazaria cru sem o sanitize (lf-c8 F4).
|
|
175
|
+
return tuple(sanitize(k.value) for k, _ in node.pairs if k.kind == "scalar")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _step_descriptor(step: StepObs) -> str:
|
|
179
|
+
"""Alvo textual de arestas step-level — sanitizado e limitado."""
|
|
180
|
+
if step.name:
|
|
181
|
+
return f'step:"{step.name}"'
|
|
182
|
+
if step.uses:
|
|
183
|
+
return f"uses:{step.uses}"
|
|
184
|
+
return f"run:{step.run}" if step.run else f"step[{step.index}]"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def step_descriptor(step: StepObs) -> str:
|
|
188
|
+
return _step_descriptor(step)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _trigger_details(node: YNode | None) -> tuple[tuple[str, str], ...]:
|
|
192
|
+
"""Filtros rasos do trigger (branches/tags/types/inputs/…)."""
|
|
193
|
+
if node is None or node.kind != "map":
|
|
194
|
+
return ()
|
|
195
|
+
out: list[tuple[str, str]] = []
|
|
196
|
+
for k, v in node.pairs:
|
|
197
|
+
if k.kind != "scalar":
|
|
198
|
+
continue
|
|
199
|
+
texts = v.scalar_list()
|
|
200
|
+
if texts:
|
|
201
|
+
out.append((sanitize(k.value), ",".join(sanitize(t) for t in texts)))
|
|
202
|
+
elif v.kind == "map":
|
|
203
|
+
keys = _csv_keys(v)
|
|
204
|
+
if keys:
|
|
205
|
+
out.append((sanitize(k.value), ",".join(keys)))
|
|
206
|
+
else:
|
|
207
|
+
s = _scalar(v)
|
|
208
|
+
if s:
|
|
209
|
+
out.append((sanitize(k.value), s))
|
|
210
|
+
return tuple(out)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _schedule_triggers(node: YNode) -> tuple[TriggerObs, ...]:
|
|
214
|
+
"""``schedule:`` é seq de ``- cron: '…'`` — um trigger por entrada."""
|
|
215
|
+
out: list[TriggerObs] = []
|
|
216
|
+
for it in node.items:
|
|
217
|
+
cron = it.get("cron") if it.kind == "map" else None
|
|
218
|
+
cs = _scalar(cron)
|
|
219
|
+
if cs:
|
|
220
|
+
out.append(
|
|
221
|
+
TriggerObs(
|
|
222
|
+
SCHEDULE, f"schedule:cron={cs}", it.span,
|
|
223
|
+
(("cron", cs),),
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
out.append(
|
|
228
|
+
TriggerObs(SCHEDULE, "schedule:<unparsed>", it.span, ())
|
|
229
|
+
)
|
|
230
|
+
return tuple(out)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _mk_trigger(
|
|
234
|
+
kind: str, node: YNode | None, span: SourceSpan
|
|
235
|
+
) -> tuple[TriggerObs, ...]:
|
|
236
|
+
if kind == SCHEDULE and node is not None and node.kind == "seq":
|
|
237
|
+
return _schedule_triggers(node)
|
|
238
|
+
return (TriggerObs(kind, kind, span, _trigger_details(node)),)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _triggers(on_node: YNode | None, diags: list[YamlDiagnostic]) -> tuple[TriggerObs, ...]:
|
|
242
|
+
"""Interpreta ``on:`` nas três formas: escalar, seq e mapa."""
|
|
243
|
+
if on_node is None:
|
|
244
|
+
return ()
|
|
245
|
+
out: list[TriggerObs] = []
|
|
246
|
+
if on_node.kind == "scalar":
|
|
247
|
+
out.extend(_mk_trigger(sanitize(on_node.value), None, on_node.span))
|
|
248
|
+
elif on_node.kind == "seq":
|
|
249
|
+
for it in on_node.items:
|
|
250
|
+
if it.kind == "scalar":
|
|
251
|
+
out.extend(_mk_trigger(sanitize(it.value), None, it.span))
|
|
252
|
+
else:
|
|
253
|
+
diags.append(
|
|
254
|
+
YamlDiagnostic(
|
|
255
|
+
"trigger-non-scalar", "warning",
|
|
256
|
+
"non-scalar entry in 'on:' sequence skipped",
|
|
257
|
+
it.span.start_line,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
elif on_node.kind == "map":
|
|
261
|
+
for k, v in on_node.pairs:
|
|
262
|
+
if k.kind == "scalar":
|
|
263
|
+
out.extend(_mk_trigger(sanitize(k.value), v, v.span))
|
|
264
|
+
else:
|
|
265
|
+
diags.append(
|
|
266
|
+
YamlDiagnostic(
|
|
267
|
+
"trigger-unparsed", "warning",
|
|
268
|
+
f"'on:' has unsupported node kind {on_node.kind}",
|
|
269
|
+
on_node.span.start_line,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return tuple(out)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _matrix(job: YNode) -> tuple[tuple[str, ...], bool, tuple[str, ...], str, str]:
|
|
276
|
+
"""Multiplicidade declarada de ``strategy.matrix`` — nunca expandida."""
|
|
277
|
+
strat = job.get("strategy")
|
|
278
|
+
if strat is None or strat.kind != "map":
|
|
279
|
+
return (), False, (), "", ""
|
|
280
|
+
matrix = strat.get("matrix")
|
|
281
|
+
axes: list[str] = []
|
|
282
|
+
dynamic = False
|
|
283
|
+
extras: list[str] = []
|
|
284
|
+
if matrix is not None and matrix.kind == "map":
|
|
285
|
+
for k, v in matrix.pairs:
|
|
286
|
+
if k.kind != "scalar":
|
|
287
|
+
continue
|
|
288
|
+
if k.value in ("include", "exclude"):
|
|
289
|
+
extras.append(k.value)
|
|
290
|
+
continue
|
|
291
|
+
kname = sanitize(k.value)
|
|
292
|
+
if v.kind == "seq":
|
|
293
|
+
axes.append(f"{kname}={len(v.items)}")
|
|
294
|
+
elif has_interpolation(_raw_scalar(v)):
|
|
295
|
+
dynamic = True
|
|
296
|
+
axes.append(f"{kname}={DYNAMIC}")
|
|
297
|
+
else:
|
|
298
|
+
axes.append(f"{kname}=?")
|
|
299
|
+
elif matrix is not None:
|
|
300
|
+
dynamic = True
|
|
301
|
+
axes.append(f"<{matrix.kind}>")
|
|
302
|
+
ff = _scalar(strat.get("fail-fast"))
|
|
303
|
+
mp = _scalar(strat.get("max-parallel"))
|
|
304
|
+
return tuple(axes), dynamic, tuple(extras), ff, mp
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _environment(job: YNode) -> tuple[str, bool]:
|
|
308
|
+
"""``environment:`` literal → nome; mapa → ``name:``; expr → dinâmico."""
|
|
309
|
+
env = job.get("environment")
|
|
310
|
+
if env is None:
|
|
311
|
+
return "", False
|
|
312
|
+
node: YNode | None = env
|
|
313
|
+
if env.kind == "map":
|
|
314
|
+
node = env.get("name")
|
|
315
|
+
if node is None:
|
|
316
|
+
return "<unnamed-map>", False
|
|
317
|
+
raw = _raw_scalar(node)
|
|
318
|
+
if not raw:
|
|
319
|
+
return "", False
|
|
320
|
+
if has_interpolation(raw):
|
|
321
|
+
return "", True
|
|
322
|
+
return raw, False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _steps(job: YNode, diags: list[YamlDiagnostic]) -> tuple[StepObs, ...]:
|
|
326
|
+
from code_atlas.domains.cicd.classify import (
|
|
327
|
+
ARTIFACT_FETCH_USES,
|
|
328
|
+
ARTIFACT_UPLOAD_USES,
|
|
329
|
+
classify_step,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
seq = job.get("steps")
|
|
333
|
+
if seq is None:
|
|
334
|
+
return ()
|
|
335
|
+
if seq.kind != "seq":
|
|
336
|
+
diags.append(
|
|
337
|
+
YamlDiagnostic(
|
|
338
|
+
"steps-not-seq", "warning",
|
|
339
|
+
"'steps:' is not a sequence — no steps extracted",
|
|
340
|
+
seq.span.start_line,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
return ()
|
|
344
|
+
out: list[StepObs] = []
|
|
345
|
+
for idx, item in enumerate(seq.items):
|
|
346
|
+
if item.kind != "map":
|
|
347
|
+
diags.append(
|
|
348
|
+
YamlDiagnostic(
|
|
349
|
+
"step-not-map", "warning",
|
|
350
|
+
"step entry is not a mapping — skipped",
|
|
351
|
+
item.span.start_line,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
continue
|
|
355
|
+
name = _scalar(item.get("name"))
|
|
356
|
+
uses = _scalar(item.get("uses"))
|
|
357
|
+
run_raw = _raw_scalar(item.get("run"))
|
|
358
|
+
run = sanitize(run_raw.split("\n")[0].strip()) if run_raw else ""
|
|
359
|
+
if len(run) > 120:
|
|
360
|
+
run = run[:117] + "..."
|
|
361
|
+
with_args = item.get("with")
|
|
362
|
+
artifacts_out: list[str] = []
|
|
363
|
+
artifacts_in: list[str] = []
|
|
364
|
+
u_low = uses.lower()
|
|
365
|
+
if uses and any(p in u_low for p in ARTIFACT_UPLOAD_USES):
|
|
366
|
+
nm = _scalar(with_args.get("name") if with_args else None)
|
|
367
|
+
artifacts_out.append(nm or "<default>")
|
|
368
|
+
if uses and any(p in u_low for p in ARTIFACT_FETCH_USES):
|
|
369
|
+
nm = _scalar(with_args.get("name") if with_args else None)
|
|
370
|
+
artifacts_in.append(nm or "<default>")
|
|
371
|
+
classes = classify_step(uses=uses, run=run_raw)
|
|
372
|
+
out.append(
|
|
373
|
+
StepObs(
|
|
374
|
+
index=idx, name=name, uses=uses, run=run,
|
|
375
|
+
env_keys=_csv_keys(item.get("env")),
|
|
376
|
+
classes=classes,
|
|
377
|
+
artifacts_out=tuple(artifacts_out),
|
|
378
|
+
artifacts_in=tuple(artifacts_in),
|
|
379
|
+
span=item.span,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
return tuple(out)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _needs(job: YNode) -> tuple[tuple[str, ...], SourceSpan | None]:
|
|
386
|
+
node = job.get("needs")
|
|
387
|
+
if node is None:
|
|
388
|
+
return (), None
|
|
389
|
+
return tuple(sanitize(t) for t in node.scalar_list()), node.span
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _jobs(jobs_node: YNode | None, diags: list[YamlDiagnostic]) -> tuple[JobObs, ...]:
|
|
393
|
+
if jobs_node is None:
|
|
394
|
+
return ()
|
|
395
|
+
if jobs_node.kind != "map":
|
|
396
|
+
diags.append(
|
|
397
|
+
YamlDiagnostic(
|
|
398
|
+
"jobs-not-map", "error",
|
|
399
|
+
"'jobs:' is not a mapping — no jobs extracted",
|
|
400
|
+
jobs_node.span.start_line,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
return ()
|
|
404
|
+
out: list[JobObs] = []
|
|
405
|
+
for k, job in jobs_node.pairs:
|
|
406
|
+
if k.kind != "scalar":
|
|
407
|
+
continue
|
|
408
|
+
if job.kind != "map":
|
|
409
|
+
diags.append(
|
|
410
|
+
YamlDiagnostic(
|
|
411
|
+
"job-not-map", "warning",
|
|
412
|
+
f"job {sanitize(k.value)!r} is not a mapping — skipped",
|
|
413
|
+
job.span.start_line,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
continue
|
|
417
|
+
needs, needs_span = _needs(job)
|
|
418
|
+
axes, mdyn, mextras, ff, mp = _matrix(job)
|
|
419
|
+
env, env_dyn = _environment(job)
|
|
420
|
+
runs_on_node = job.get("runs-on")
|
|
421
|
+
secrets_node = job.get("secrets")
|
|
422
|
+
secrets_mode = ""
|
|
423
|
+
if secrets_node is not None:
|
|
424
|
+
sv = _raw_scalar(secrets_node)
|
|
425
|
+
secrets_mode = "inherit" if sv == "inherit" else "declared"
|
|
426
|
+
outputs = job.get("outputs")
|
|
427
|
+
out.append(
|
|
428
|
+
JobObs(
|
|
429
|
+
job_id=sanitize(k.value),
|
|
430
|
+
display_name=_scalar(job.get("name")),
|
|
431
|
+
uses=_scalar(job.get("uses")),
|
|
432
|
+
needs=needs,
|
|
433
|
+
runs_on=",".join(
|
|
434
|
+
sanitize(t)
|
|
435
|
+
for t in (
|
|
436
|
+
runs_on_node.scalar_list()
|
|
437
|
+
if runs_on_node is not None else ()
|
|
438
|
+
)
|
|
439
|
+
),
|
|
440
|
+
environment=env,
|
|
441
|
+
environment_dynamic=env_dyn,
|
|
442
|
+
if_expr=_scalar(job.get("if")),
|
|
443
|
+
matrix_axes=axes,
|
|
444
|
+
matrix_dynamic=mdyn,
|
|
445
|
+
matrix_extras=mextras,
|
|
446
|
+
fail_fast=ff,
|
|
447
|
+
max_parallel=mp,
|
|
448
|
+
permissions=_map_summary(job.get("permissions")),
|
|
449
|
+
env_keys=_csv_keys(job.get("env")),
|
|
450
|
+
secrets_mode=secrets_mode,
|
|
451
|
+
concurrency=_scalar(job.get("concurrency")),
|
|
452
|
+
outputs=(
|
|
453
|
+
len(outputs.pairs)
|
|
454
|
+
if outputs is not None and outputs.kind == "map"
|
|
455
|
+
else 0
|
|
456
|
+
),
|
|
457
|
+
steps=_steps(job, diags),
|
|
458
|
+
needs_span=needs_span,
|
|
459
|
+
span=job.span,
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
return tuple(out)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def parse_workflow(
|
|
466
|
+
root: YNode | None, path: str
|
|
467
|
+
) -> WorkflowParse:
|
|
468
|
+
"""Observação semântica de um workflow; ``None`` se não-executável."""
|
|
469
|
+
diags: list[YamlDiagnostic] = []
|
|
470
|
+
if root is None:
|
|
471
|
+
return WorkflowParse(None, ())
|
|
472
|
+
if root.kind != "map":
|
|
473
|
+
diags.append(
|
|
474
|
+
YamlDiagnostic(
|
|
475
|
+
"workflow-not-map", "error",
|
|
476
|
+
"workflow document is not a mapping — no extraction",
|
|
477
|
+
root.span.start_line,
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
return WorkflowParse(None, tuple(diags))
|
|
481
|
+
stem = PurePosixPath(path).name
|
|
482
|
+
stem = stem.rsplit(".", 1)[0] if "." in stem else stem
|
|
483
|
+
name_node = root.get("name")
|
|
484
|
+
declared = _scalar(name_node)
|
|
485
|
+
name = declared or stem
|
|
486
|
+
triggers = _triggers(root.get("on"), diags)
|
|
487
|
+
jobs = _jobs(root.get("jobs"), diags)
|
|
488
|
+
if not jobs:
|
|
489
|
+
diags.append(
|
|
490
|
+
YamlDiagnostic(
|
|
491
|
+
"no-jobs", "warning",
|
|
492
|
+
"workflow declares no usable 'jobs:' mapping",
|
|
493
|
+
root.span.start_line,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
obs = WorkflowObs(
|
|
497
|
+
path=path, name=name, name_declared=bool(declared),
|
|
498
|
+
triggers=triggers, jobs=jobs,
|
|
499
|
+
permissions=_map_summary(root.get("permissions")),
|
|
500
|
+
env_keys=_csv_keys(root.get("env")),
|
|
501
|
+
concurrency=_scalar(root.get("concurrency")),
|
|
502
|
+
span=root.span,
|
|
503
|
+
)
|
|
504
|
+
return WorkflowParse(obs, tuple(diags))
|