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