@orchestrator-claude/definitions 3.32.2 → 3.34.0
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.
- package/README.md +145 -0
- package/agents/adversarial-reviewer.md +2 -2
- package/agents/azure-devops-engineer.md +9 -1
- package/agents/cicd-platform-engineer.md +477 -0
- package/agents/debug-sidecar.md +6 -1
- package/agents/doc-sidecar.md +6 -1
- package/agents/frontend-engineer.md +173 -0
- package/agents/implementer.md +39 -23
- package/agents/methodology-researcher.md +2 -1
- package/agents/operational-artifact-builder.md +2 -1
- package/agents/orchestrator.md +47 -76
- package/agents/planner.md +2 -2
- package/agents/presentation-engineer.md +1 -1
- package/agents/python-data-engineer.md +214 -0
- package/agents/reviewer.md +16 -8
- package/agents/specifier.md +1 -1
- package/agents/task-generator.md +1 -1
- package/agents/test-sidecar.md +15 -4
- package/dist/cli.js +78 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/DiffCommand.js +1 -1
- package/dist/commands/DiffCommand.js.map +1 -1
- package/dist/commands/InstallCommand.d.ts +41 -0
- package/dist/commands/InstallCommand.d.ts.map +1 -0
- package/dist/commands/InstallCommand.js +253 -0
- package/dist/commands/InstallCommand.js.map +1 -0
- package/dist/http/ApiClient.d.ts +5 -0
- package/dist/http/ApiClient.d.ts.map +1 -1
- package/dist/http/ApiClient.js.map +1 -1
- package/dist/install/DevCheckoutDetector.d.ts +66 -0
- package/dist/install/DevCheckoutDetector.d.ts.map +1 -0
- package/dist/install/DevCheckoutDetector.js +150 -0
- package/dist/install/DevCheckoutDetector.js.map +1 -0
- package/dist/install/HooksWirer.d.ts +29 -0
- package/dist/install/HooksWirer.d.ts.map +1 -0
- package/dist/install/HooksWirer.js +80 -0
- package/dist/install/HooksWirer.js.map +1 -0
- package/dist/install/LocalDiffReporter.d.ts +25 -0
- package/dist/install/LocalDiffReporter.d.ts.map +1 -0
- package/dist/install/LocalDiffReporter.js +50 -0
- package/dist/install/LocalDiffReporter.js.map +1 -0
- package/dist/install/Materializer.d.ts +67 -0
- package/dist/install/Materializer.d.ts.map +1 -0
- package/dist/install/Materializer.js +196 -0
- package/dist/install/Materializer.js.map +1 -0
- package/dist/install/McpRegistrar.d.ts +63 -0
- package/dist/install/McpRegistrar.d.ts.map +1 -0
- package/dist/install/McpRegistrar.js +114 -0
- package/dist/install/McpRegistrar.js.map +1 -0
- package/dist/install/ProcessRunner.d.ts +25 -0
- package/dist/install/ProcessRunner.d.ts.map +1 -0
- package/dist/install/ProcessRunner.js +41 -0
- package/dist/install/ProcessRunner.js.map +1 -0
- package/dist/install/ProjectSettingsNeutralizer.d.ts +39 -0
- package/dist/install/ProjectSettingsNeutralizer.d.ts.map +1 -0
- package/dist/install/ProjectSettingsNeutralizer.js +100 -0
- package/dist/install/ProjectSettingsNeutralizer.js.map +1 -0
- package/dist/install/types.d.ts +52 -0
- package/dist/install/types.d.ts.map +1 -0
- package/dist/install/types.js +8 -0
- package/dist/install/types.js.map +1 -0
- package/dist/manifest/ManifestLoader.d.ts.map +1 -1
- package/dist/manifest/ManifestLoader.js +3 -2
- package/dist/manifest/ManifestLoader.js.map +1 -1
- package/dist/manifest/types.d.ts +3 -0
- package/dist/manifest/types.d.ts.map +1 -1
- package/dist/scripts/bundle-hooks.d.ts +39 -0
- package/dist/scripts/bundle-hooks.d.ts.map +1 -0
- package/dist/scripts/bundle-hooks.js +165 -0
- package/dist/scripts/bundle-hooks.js.map +1 -0
- package/dist/scripts/generate-manifest.d.ts +22 -3
- package/dist/scripts/generate-manifest.d.ts.map +1 -1
- package/dist/scripts/generate-manifest.js +78 -35
- package/dist/scripts/generate-manifest.js.map +1 -1
- package/hooks/dangling-guard.mjs +7426 -0
- package/hooks/dangling-guard.ts +223 -6
- package/hooks/gate-guardian.mjs +463 -0
- package/hooks/hooks.json +63 -0
- package/hooks/lib/additional-directories-sync.ts +295 -0
- package/hooks/lib/api-client.ts +388 -7
- package/hooks/lib/session-selection.ts +81 -0
- package/hooks/mailbox-listener.mjs +259 -0
- package/hooks/post-compact.mjs +7931 -0
- package/hooks/post-implement.mjs +238 -0
- package/hooks/session-start.mjs +8234 -0
- package/hooks/session-start.ts +264 -23
- package/hooks/sprint-registry.mjs +59 -0
- package/hooks/subagent-start.mjs +289 -0
- package/hooks/subagent-stop.mjs +7793 -0
- package/hooks/subagent-stop.ts +4 -2
- package/hooks/user-prompt.mjs +329 -0
- package/hooks/workflow-guard.mjs +7805 -0
- package/hooks/workflow-guard.ts +360 -119
- package/hooks-settings-template.json +63 -0
- package/manifest.json +124 -90
- package/mcp-servers.json +48 -0
- package/package.json +10 -4
- package/personas/auditor-compliance.md +104 -0
- package/personas/cfo-conservador.md +88 -0
- package/personas/designer-perfeccionista.md +104 -0
- package/personas/diretor-cetico-fmcg.md +80 -0
- package/personas/engenheiro-cetico-construcao.md +107 -0
- package/skills/artifact-generator/SKILL.md +1 -1
- package/skills/artifact-production/SKILL.md +28 -0
- package/skills/backlog-synthesis/SKILL.md +8 -3
- package/skills/checkpoint-protocol/SKILL.md +7 -0
- package/skills/infographic-engine/SKILL.md +116 -0
- package/skills/orchestrator/SKILL.md +93 -7
- package/skills/persona-library/SKILL.md +1 -1
- package/skills/project-conventions/SKILL.md +53 -30
- package/skills/project-management-enterprise/SKILL.md +1 -1
- package/skills/python-data-engineering/SKILL.md +792 -0
- package/skills/release/SKILL.md +29 -18
- package/skills/release/release.sh +65 -4
- package/skills/storybook-validated-loop/SKILL.md +110 -0
- package/skills/tdd-discipline/SKILL.md +16 -0
- package/skills/workflow-coordination/SKILL.md +117 -16
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: python-data-engineering
|
|
3
|
+
description: Python data engineering patterns para ETL pipelines, schema validators, fuzzy matchers, Excel generators. Use when implementing Python+pandas+rapidfuzz+openpyxl+azure-storage-blob projects. Cobre pytest discipline, mypy --strict, ruff config, Pydantic v2, pandas idioms, fuzzy matching tuning, Azure SDK Python, structured logging, government CSV parsing. Não cobre application backends Python web (FastAPI/Django) — usar /backend skills para isso.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Python Data Engineering — Patterns and Conventions
|
|
7
|
+
|
|
8
|
+
This skill is the **canonical reference** for Python data engineering work in this org. It is loaded by the `python-data-engineer` agent and is also available to any other agent dispatching Python+data tasks.
|
|
9
|
+
|
|
10
|
+
For deep pandas reference beyond what's here, see also: <https://jeffallan.github.io/claude-skills/skills/data-ml/pandas-pro/> (external skill, not maintained by this org).
|
|
11
|
+
|
|
12
|
+
## 1. Project Conventions
|
|
13
|
+
|
|
14
|
+
### Project layout (pyproject.toml-based)
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
project-name/
|
|
18
|
+
├── pyproject.toml # PEP 517/518 — single source for deps, tools config
|
|
19
|
+
├── .gitignore # blocks *.xlsx, .cache/, .env, __pycache__/, .mypy_cache/
|
|
20
|
+
├── src/
|
|
21
|
+
│ └── package_name/ # snake_case package
|
|
22
|
+
│ ├── __init__.py
|
|
23
|
+
│ └── module.py
|
|
24
|
+
├── tests/
|
|
25
|
+
│ ├── conftest.py # shared fixtures
|
|
26
|
+
│ ├── unit/
|
|
27
|
+
│ ├── integration/
|
|
28
|
+
│ └── acceptance/
|
|
29
|
+
└── docs/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Why `src/` layout** (not flat): forces install-then-test pattern, prevents accidental imports from working tree, mypy + ruff treat correctly.
|
|
33
|
+
|
|
34
|
+
### `pyproject.toml` baseline
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
[project]
|
|
38
|
+
name = "package-name"
|
|
39
|
+
version = "0.1.0"
|
|
40
|
+
requires-python = ">=3.11"
|
|
41
|
+
dependencies = [
|
|
42
|
+
"pandas>=2.1",
|
|
43
|
+
"pydantic>=2.5",
|
|
44
|
+
"pydantic-settings>=2.1",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.optional-dependencies]
|
|
48
|
+
dev = [
|
|
49
|
+
"pytest>=7.4",
|
|
50
|
+
"pytest-cov>=4.1",
|
|
51
|
+
"pytest-mock>=3.12",
|
|
52
|
+
"ruff>=0.1",
|
|
53
|
+
"mypy>=1.7",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
target-version = "py311"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF"]
|
|
62
|
+
# E/F: pycodestyle errors + pyflakes
|
|
63
|
+
# I: isort
|
|
64
|
+
# N: pep8-naming
|
|
65
|
+
# UP: pyupgrade
|
|
66
|
+
# B: bugbear (common bug patterns)
|
|
67
|
+
# SIM: simplify
|
|
68
|
+
# RUF: ruff-specific
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
strict = true
|
|
72
|
+
python_version = "3.11"
|
|
73
|
+
warn_return_any = true
|
|
74
|
+
warn_unused_ignores = true
|
|
75
|
+
disallow_untyped_defs = true
|
|
76
|
+
disallow_any_explicit = false # allow Any explicitly only when annotated
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
markers = [
|
|
80
|
+
"integration: requires external services (Azurite, etc.)",
|
|
81
|
+
"acceptance: requires real data — KPI gates",
|
|
82
|
+
]
|
|
83
|
+
addopts = "--strict-markers --cov-fail-under=80"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Rule**: never disable a ruff lint rule globally without comment. If needed, `# noqa: <code> # reason: …` per-line.
|
|
87
|
+
|
|
88
|
+
## 2. TDD with pytest
|
|
89
|
+
|
|
90
|
+
### Test layout
|
|
91
|
+
|
|
92
|
+
| Type | Path | Marker | Runs in CI? |
|
|
93
|
+
|---|---|---|---|
|
|
94
|
+
| Unit | `tests/unit/test_<module>.py` | (none) | yes, fast |
|
|
95
|
+
| Integration | `tests/integration/test_<feature>.py` | `@pytest.mark.integration` | yes (with services) |
|
|
96
|
+
| Acceptance | `tests/acceptance/test_kpi_<name>.py` | `@pytest.mark.acceptance` | no — manual only |
|
|
97
|
+
|
|
98
|
+
### Test function naming
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
def test_<unit>_<condition>_<expected_behavior>() -> None:
|
|
102
|
+
"""One-line docstring describing intent."""
|
|
103
|
+
...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
- `test_parse_inep_csv_with_valid_2023_schema_returns_dataframe`
|
|
108
|
+
- `test_parse_inep_csv_missing_required_column_raises_schema_error`
|
|
109
|
+
- `test_crossjoin_stage1_exact_match_resolves_70_percent`
|
|
110
|
+
|
|
111
|
+
### Fixture patterns
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# tests/conftest.py
|
|
115
|
+
import pytest
|
|
116
|
+
from pathlib import Path
|
|
117
|
+
|
|
118
|
+
@pytest.fixture
|
|
119
|
+
def inep_sample_2023(tmp_path: Path) -> Path:
|
|
120
|
+
"""Provides a 20-row synthetic INEP 2023 CSV."""
|
|
121
|
+
src = Path("tests/fixtures/inep_sample_2023.csv")
|
|
122
|
+
dst = tmp_path / "inep.csv"
|
|
123
|
+
dst.write_bytes(src.read_bytes())
|
|
124
|
+
return dst
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Rule**: never modify fixture files in tests. Always copy to `tmp_path` first.
|
|
128
|
+
|
|
129
|
+
### Parametrization for table-driven tests
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
@pytest.mark.parametrize(
|
|
133
|
+
"qt_presencial,qt_ead,expected",
|
|
134
|
+
[
|
|
135
|
+
(10000, 0, "PRESENCIAL"),
|
|
136
|
+
(0, 10000, "EAD"),
|
|
137
|
+
(5000, 5000, "HIBRIDO"),
|
|
138
|
+
(8000, 2000, "PRESENCIAL"), # 80% presencial
|
|
139
|
+
(2000, 8000, "EAD"), # 80% ead
|
|
140
|
+
(4000, 4000, "HIBRIDO"), # 50/50 fits 30-70 mix range
|
|
141
|
+
(0, 0, "SEM_DADOS"),
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
def test_derive_modalidade_predominante(qt_presencial: int, qt_ead: int, expected: str) -> None:
|
|
145
|
+
assert derive_modalidade(qt_presencial, qt_ead) == expected
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Mocking with pytest-mock
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
def test_fetch_latest_inep_retries_on_503(mocker: MockerFixture) -> None:
|
|
152
|
+
mock_get = mocker.patch("redbull_sfm.inep_ingest.requests.get")
|
|
153
|
+
mock_get.side_effect = [
|
|
154
|
+
mocker.Mock(status_code=503),
|
|
155
|
+
mocker.Mock(status_code=503),
|
|
156
|
+
mocker.Mock(status_code=200, content=b"..."),
|
|
157
|
+
]
|
|
158
|
+
df, year = fetch_latest_inep()
|
|
159
|
+
assert mock_get.call_count == 3
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Rule**: prefer `mocker.patch` over `unittest.mock.patch` for cleaner teardown.
|
|
163
|
+
|
|
164
|
+
### Coverage gate
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pytest tests/unit/ --cov=src --cov-fail-under=80 --cov-report=term-missing
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Required: ≥80% branch coverage on new code. CI fails below threshold.
|
|
171
|
+
|
|
172
|
+
## 3. Type Hints + mypy --strict
|
|
173
|
+
|
|
174
|
+
### Essential rules
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# Always type function signatures
|
|
178
|
+
def parse(path: Path, year: int) -> pd.DataFrame:
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
# Use Protocol for structural typing (duck typing)
|
|
182
|
+
from typing import Protocol
|
|
183
|
+
|
|
184
|
+
class CsvReader(Protocol):
|
|
185
|
+
def read(self, path: Path) -> pd.DataFrame: ...
|
|
186
|
+
|
|
187
|
+
# Use TypedDict for dict-like structured data
|
|
188
|
+
from typing import TypedDict
|
|
189
|
+
|
|
190
|
+
class RunReportDict(TypedDict):
|
|
191
|
+
year_used: int
|
|
192
|
+
kpis: dict[str, float]
|
|
193
|
+
duration_seconds: float
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### dataclass vs Pydantic BaseModel — decision tree
|
|
197
|
+
|
|
198
|
+
| Use case | Choose |
|
|
199
|
+
|---|---|
|
|
200
|
+
| Internal data, no validation needed | `@dataclass(frozen=True)` |
|
|
201
|
+
| External data parsing + validation | `pydantic.BaseModel` |
|
|
202
|
+
| Settings from env | `pydantic_settings.BaseSettings` |
|
|
203
|
+
| Performance-critical hot path | `@dataclass(slots=True, frozen=True)` |
|
|
204
|
+
| Cross-field validation | `BaseModel` with `@model_validator(mode="after")` |
|
|
205
|
+
|
|
206
|
+
### Avoid `Any`
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# BAD
|
|
210
|
+
def process(data: Any) -> Any: ...
|
|
211
|
+
|
|
212
|
+
# GOOD
|
|
213
|
+
from typing import TypeVar
|
|
214
|
+
T = TypeVar("T", bound=pd.DataFrame)
|
|
215
|
+
def process(data: T) -> T: ...
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
If `Any` is unavoidable, annotate with comment:
|
|
219
|
+
```python
|
|
220
|
+
def from_external_lib(x: Any) -> int: # third-party returns Any-typed
|
|
221
|
+
return int(x)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## 4. Pydantic v2 Patterns
|
|
225
|
+
|
|
226
|
+
### Schema validation pattern
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from pydantic import BaseModel, Field, field_validator
|
|
230
|
+
from typing import Literal
|
|
231
|
+
|
|
232
|
+
class INEPRecord(BaseModel):
|
|
233
|
+
"""Single row of MICRODADOS_CADASTRO_IES_<year>.csv."""
|
|
234
|
+
|
|
235
|
+
CO_IES: int = Field(..., gt=0, description="Código MEC da IES")
|
|
236
|
+
NO_IES: str = Field(..., min_length=3)
|
|
237
|
+
NO_MUNICIPIO_IES: str = Field(..., min_length=2)
|
|
238
|
+
SG_UF_IES: str = Field(..., pattern=r"^[A-Z]{2}$")
|
|
239
|
+
TP_REDE: Literal[1, 2, 3, 4] # 1=pub federal, 2=pub estadual, etc.
|
|
240
|
+
TP_SITUACAO: Literal[1, 2, 3] # 1=ativa, 2=extinta, 3=desativada
|
|
241
|
+
QT_MAT_PRESENCIAL: int = Field(..., ge=0)
|
|
242
|
+
QT_MAT_EAD: int = Field(..., ge=0)
|
|
243
|
+
|
|
244
|
+
@field_validator("NO_IES", "NO_MUNICIPIO_IES")
|
|
245
|
+
@classmethod
|
|
246
|
+
def strip_and_upper(cls, v: str) -> str:
|
|
247
|
+
return v.strip().upper()
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Cross-field validation
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from pydantic import model_validator
|
|
254
|
+
|
|
255
|
+
class MatchResultMetadata(BaseModel):
|
|
256
|
+
total_input_universities: int
|
|
257
|
+
matched_count: int
|
|
258
|
+
unmatched_count: int
|
|
259
|
+
|
|
260
|
+
@model_validator(mode="after")
|
|
261
|
+
def matched_plus_unmatched_equals_input(self) -> "MatchResultMetadata":
|
|
262
|
+
if self.matched_count + self.unmatched_count != self.total_input_universities:
|
|
263
|
+
raise ValueError("matched + unmatched must equal input total")
|
|
264
|
+
return self
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Settings management (replaces os.getenv boilerplate)
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
271
|
+
|
|
272
|
+
class AzureSettings(BaseSettings):
|
|
273
|
+
"""Auto-loads from env vars: AZURE_SAS_INPUT, AZURE_SAS_OUTPUT, AZURE_STORAGE_ACCOUNT."""
|
|
274
|
+
|
|
275
|
+
model_config = SettingsConfigDict(env_prefix="AZURE_", env_file=".env")
|
|
276
|
+
|
|
277
|
+
sas_input: str = Field(..., min_length=20)
|
|
278
|
+
sas_output: str = Field(..., min_length=20)
|
|
279
|
+
storage_account: str = Field(..., pattern=r"^[a-z0-9]{3,24}$")
|
|
280
|
+
input_container: str = "redbull-sfm-input"
|
|
281
|
+
output_container: str = "redbull-sfm-output"
|
|
282
|
+
|
|
283
|
+
# Usage
|
|
284
|
+
settings = AzureSettings() # raises ValidationError if env missing
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Serialization
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
report = RunReport(year_used=2023, kpis={"K-1": 2543}, duration_seconds=47.3)
|
|
291
|
+
json_str = report.model_dump_json(indent=2) # NOT json.dumps(asdict(report))
|
|
292
|
+
dict_form = report.model_dump()
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Validation error handling
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from pydantic import ValidationError
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
record = INEPRecord.model_validate(row_dict)
|
|
302
|
+
except ValidationError as e:
|
|
303
|
+
# Log structured error — each field with its own error
|
|
304
|
+
log.error("inep_row_invalid", row_idx=idx, errors=e.errors())
|
|
305
|
+
continue # skip malformed row
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**Rule**: `e.errors()` returns list of dicts — preserve via structured logging, do NOT just `str(e)`.
|
|
309
|
+
|
|
310
|
+
## 5. Pandas Idioms
|
|
311
|
+
|
|
312
|
+
For deep pandas patterns beyond this section, see jeffallan claude-skills `pandas-pro` (external reference).
|
|
313
|
+
|
|
314
|
+
### Method chaining (readability)
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
# BAD — temporary variables, harder to follow
|
|
318
|
+
df_clean = df.dropna(subset=["CO_IES"])
|
|
319
|
+
df_filtered = df_clean[df_clean["TP_SITUACAO"] == 1]
|
|
320
|
+
df_typed = df_filtered.astype({"TP_REDE": "category"})
|
|
321
|
+
|
|
322
|
+
# GOOD — chained
|
|
323
|
+
df_typed = (
|
|
324
|
+
df
|
|
325
|
+
.dropna(subset=["CO_IES"])
|
|
326
|
+
.query("TP_SITUACAO == 1")
|
|
327
|
+
.astype({"TP_REDE": "category"})
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### dtype management (memory + correctness)
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
# After read_csv, immediately enforce dtypes
|
|
335
|
+
df = pd.read_csv(
|
|
336
|
+
path,
|
|
337
|
+
encoding="latin-1",
|
|
338
|
+
dtype={
|
|
339
|
+
"CO_IES": "int64",
|
|
340
|
+
"TP_REDE": "Int8", # nullable int8, accepts NaN
|
|
341
|
+
"TP_SITUACAO": "Int8",
|
|
342
|
+
"NO_IES": "string", # not "object"
|
|
343
|
+
"SG_UF_IES": "category", # low-cardinality
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Rule**: never let pandas auto-infer dtype on production code paths. Always explicit.
|
|
349
|
+
|
|
350
|
+
### Avoiding SettingWithCopyWarning
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
# BAD
|
|
354
|
+
df_active = df[df["TP_SITUACAO"] == 1]
|
|
355
|
+
df_active["MODALIDADE"] = "..." # warning
|
|
356
|
+
|
|
357
|
+
# GOOD
|
|
358
|
+
df_active = df[df["TP_SITUACAO"] == 1].copy()
|
|
359
|
+
df_active["MODALIDADE"] = "..."
|
|
360
|
+
|
|
361
|
+
# BETTER
|
|
362
|
+
df_active = df.query("TP_SITUACAO == 1").assign(MODALIDADE="...")
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Validation after transforms
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
# Always check invariants explicitly after non-trivial transforms
|
|
369
|
+
df_agg = df.groupby("UNIVERSITY").agg(...)
|
|
370
|
+
assert df_agg.index.is_unique, "aggregation produced duplicate UNIVERSITY keys"
|
|
371
|
+
assert len(df_agg) == 430, f"expected 430 universities, got {len(df_agg)}"
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Merge validation
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
# Always specify validate when merging to catch unexpected cardinality
|
|
378
|
+
result = pd.merge(
|
|
379
|
+
df_mmm,
|
|
380
|
+
df_inep,
|
|
381
|
+
on="match_key",
|
|
382
|
+
how="left",
|
|
383
|
+
validate="one_to_one", # raises if not 1:1
|
|
384
|
+
indicator=True, # adds _merge column for debugging
|
|
385
|
+
)
|
|
386
|
+
unmatched = result.query("_merge == 'left_only'")
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## 6. Fuzzy Matching com rapidfuzz
|
|
390
|
+
|
|
391
|
+
### Algorithm selection
|
|
392
|
+
|
|
393
|
+
| Algorithm | Use case | Pitfall |
|
|
394
|
+
|---|---|---|
|
|
395
|
+
| `fuzz.ratio` | Strings same length, char-level diff | Bad for word reordering |
|
|
396
|
+
| `fuzz.token_sort_ratio` | Word reordering OK ("FED ALAGOAS" vs "ALAGOAS FED") | Slow on long strings |
|
|
397
|
+
| `fuzz.partial_ratio` | Prefixes/substrings ("UFAL" matches "UNIVERSIDADE FEDERAL DE ALAGOAS") | False positives in short strings |
|
|
398
|
+
| `fuzz.WRatio` | "Smart" weighted combo — when unsure | Less predictable, harder to tune threshold |
|
|
399
|
+
|
|
400
|
+
### Threshold tuning
|
|
401
|
+
|
|
402
|
+
Start with **85** as default threshold. Calibrate via known-truth golden set:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
# Calibration script — run once with ~20-30 known pairs
|
|
406
|
+
from rapidfuzz import fuzz
|
|
407
|
+
|
|
408
|
+
golden_pairs = [
|
|
409
|
+
("UNIVERSIDADE FEDERAL DE ALAGOAS", "UFAL"), # expected: match
|
|
410
|
+
("USP", "Universidade de Sao Paulo"), # expected: match
|
|
411
|
+
("Universidade Anhanguera", "UNESP"), # expected: no match
|
|
412
|
+
...
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
for s1, s2 in golden_pairs:
|
|
416
|
+
print(f"token_sort: {fuzz.token_sort_ratio(s1, s2):3d} partial: {fuzz.partial_ratio(s1, s2):3d} {s1[:30]} | {s2[:30]}")
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Pick threshold where positive-pairs score ≥ T and negative-pairs score < T.
|
|
420
|
+
|
|
421
|
+
### Performance — process in batches
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
from rapidfuzz import process
|
|
425
|
+
|
|
426
|
+
# Instead of N*M loop (slow):
|
|
427
|
+
for mmm_name in mmm_names:
|
|
428
|
+
for inep_name in inep_names:
|
|
429
|
+
score = fuzz.token_sort_ratio(mmm_name, inep_name)
|
|
430
|
+
|
|
431
|
+
# Use vectorized cdist (fast):
|
|
432
|
+
import numpy as np
|
|
433
|
+
matrix = process.cdist(mmm_names, inep_names, scorer=fuzz.token_sort_ratio)
|
|
434
|
+
# matrix[i, j] is score between mmm_names[i] and inep_names[j]
|
|
435
|
+
best_matches = np.argmax(matrix, axis=1)
|
|
436
|
+
best_scores = matrix[np.arange(len(mmm_names)), best_matches]
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Normalization BEFORE fuzzy
|
|
440
|
+
|
|
441
|
+
```python
|
|
442
|
+
from unidecode import unidecode
|
|
443
|
+
|
|
444
|
+
def normalize(s: str) -> str:
|
|
445
|
+
"""Apply BEFORE feeding to fuzz: removes accents, casing, punctuation."""
|
|
446
|
+
return unidecode(s.lower().strip()).replace("-", " ").replace(",", " ")
|
|
447
|
+
|
|
448
|
+
# Then fuzz on normalized strings
|
|
449
|
+
score = fuzz.token_sort_ratio(normalize(s1), normalize(s2))
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## 7. Schema Evolution Patterns
|
|
453
|
+
|
|
454
|
+
### Versioned validators
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
# schemas.py
|
|
458
|
+
INEP_2023_REQUIRED_COLS = frozenset({
|
|
459
|
+
"CO_IES", "NO_IES", "NO_MUNICIPIO_IES", "SG_UF_IES",
|
|
460
|
+
"TP_REDE", "TP_SITUACAO", "QT_MAT_PRESENCIAL", "QT_MAT_EAD",
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
INEP_2024_REQUIRED_COLS = INEP_2023_REQUIRED_COLS # presumed stable; update when published
|
|
464
|
+
|
|
465
|
+
def validate_schema(df: pd.DataFrame, year: int) -> None:
|
|
466
|
+
expected = {
|
|
467
|
+
2023: INEP_2023_REQUIRED_COLS,
|
|
468
|
+
2024: INEP_2024_REQUIRED_COLS,
|
|
469
|
+
}.get(year)
|
|
470
|
+
if expected is None:
|
|
471
|
+
raise SchemaError(f"unknown INEP year {year}")
|
|
472
|
+
missing = expected - set(df.columns)
|
|
473
|
+
if missing:
|
|
474
|
+
raise SchemaError(f"INEP {year} missing required cols: {sorted(missing)}")
|
|
475
|
+
# extras are OK — warn but don't fail
|
|
476
|
+
extras = set(df.columns) - expected
|
|
477
|
+
if extras:
|
|
478
|
+
log.warning("inep_schema_extras_detected", year=year, extras=sorted(extras))
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Backward-compat strategy
|
|
482
|
+
|
|
483
|
+
When INEP schema changes between years:
|
|
484
|
+
1. Add `INEP_<NEW_YEAR>_REQUIRED_COLS` to schemas.py (don't modify old)
|
|
485
|
+
2. If a column was renamed, add a normalization step in `parse_inep_csv` that maps old → new name
|
|
486
|
+
3. If a column was removed, add a "synthesized" column from other data
|
|
487
|
+
4. Never drop support for old schemas until you confirm no one needs to re-process old years
|
|
488
|
+
|
|
489
|
+
## 8. Azure SDK Python (azure-storage-blob)
|
|
490
|
+
|
|
491
|
+
### Async client (preferred for I/O)
|
|
492
|
+
|
|
493
|
+
```python
|
|
494
|
+
from azure.storage.blob.aio import BlobServiceClient
|
|
495
|
+
from azure.core.exceptions import ResourceNotFoundError
|
|
496
|
+
|
|
497
|
+
async def download_blob(container: str, blob: str, sas_url: str) -> bytes:
|
|
498
|
+
async with BlobServiceClient(account_url=sas_url) as service:
|
|
499
|
+
async with service.get_blob_client(container=container, blob=blob) as client:
|
|
500
|
+
try:
|
|
501
|
+
stream = await client.download_blob()
|
|
502
|
+
return await stream.readall()
|
|
503
|
+
except ResourceNotFoundError:
|
|
504
|
+
log.error("blob_not_found", container=container, blob=blob)
|
|
505
|
+
raise
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### SAS handling
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
# NEVER log full SAS URL — redact the query string
|
|
512
|
+
def redact_sas(url: str) -> str:
|
|
513
|
+
"""Remove ?sv=...&sig=... from URL for safe logging."""
|
|
514
|
+
return url.split("?")[0] + "?<redacted>"
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### Retry policy
|
|
518
|
+
|
|
519
|
+
```python
|
|
520
|
+
from azure.core.pipeline.policies import RetryPolicy
|
|
521
|
+
|
|
522
|
+
# Built-in retry with exp backoff
|
|
523
|
+
service = BlobServiceClient(
|
|
524
|
+
account_url=sas_url,
|
|
525
|
+
retry_total=3,
|
|
526
|
+
retry_backoff_factor=2,
|
|
527
|
+
retry_backoff_max=30,
|
|
528
|
+
)
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### Error types to handle explicitly
|
|
532
|
+
|
|
533
|
+
| Exception | When | Resposta |
|
|
534
|
+
|---|---|---|
|
|
535
|
+
| `ResourceNotFoundError` | Blob doesn't exist | Hard fail, log container/blob |
|
|
536
|
+
| `ResourceExistsError` | Overwrite without flag | Either set `overwrite=True` or rename |
|
|
537
|
+
| `ClientAuthenticationError` | SAS expired/invalid | Hard fail, alert (need new SAS) |
|
|
538
|
+
| `ServiceRequestError` | Network/transient | Retry (already covered by RetryPolicy) |
|
|
539
|
+
|
|
540
|
+
## 9. openpyxl Deep
|
|
541
|
+
|
|
542
|
+
### Conditional formatting
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
from openpyxl.formatting.rule import CellIsRule
|
|
546
|
+
from openpyxl.styles import PatternFill
|
|
547
|
+
|
|
548
|
+
ws.conditional_formatting.add(
|
|
549
|
+
"F2:F500", # MATCH_SCORE column
|
|
550
|
+
CellIsRule(
|
|
551
|
+
operator="lessThan",
|
|
552
|
+
formula=["85"],
|
|
553
|
+
fill=PatternFill(start_color="FFCDD2", end_color="FFCDD2", fill_type="solid"), # red
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
ws.conditional_formatting.add(
|
|
557
|
+
"F2:F500",
|
|
558
|
+
CellIsRule(
|
|
559
|
+
operator="between",
|
|
560
|
+
formula=["85", "89"],
|
|
561
|
+
fill=PatternFill(start_color="FFF9C4", end_color="FFF9C4", fill_type="solid"), # yellow
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
### AutoFilter
|
|
567
|
+
|
|
568
|
+
```python
|
|
569
|
+
ws.auto_filter.ref = ws.dimensions # filter buttons on all columns
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### NamedStyle for consistent headers
|
|
573
|
+
|
|
574
|
+
```python
|
|
575
|
+
from openpyxl.styles import NamedStyle, Font, PatternFill, Alignment
|
|
576
|
+
|
|
577
|
+
header_style = NamedStyle(name="header")
|
|
578
|
+
header_style.font = Font(bold=True, color="FFFFFF")
|
|
579
|
+
header_style.fill = PatternFill(start_color="001E60", end_color="001E60", fill_type="solid") # Red Bull blue
|
|
580
|
+
header_style.alignment = Alignment(horizontal="center", vertical="center")
|
|
581
|
+
|
|
582
|
+
# Register once on workbook
|
|
583
|
+
wb.add_named_style(header_style)
|
|
584
|
+
|
|
585
|
+
# Apply
|
|
586
|
+
for cell in ws[1]: # row 1 = header
|
|
587
|
+
cell.style = "header"
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Freeze panes
|
|
591
|
+
|
|
592
|
+
```python
|
|
593
|
+
ws.freeze_panes = "A2" # freeze row 1
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### Performance — write_only mode for large outputs
|
|
597
|
+
|
|
598
|
+
```python
|
|
599
|
+
from openpyxl import Workbook
|
|
600
|
+
wb = Workbook(write_only=True) # ~10x faster, but read_only=False after save
|
|
601
|
+
ws = wb.create_sheet("IES_FULL")
|
|
602
|
+
ws.append(headers)
|
|
603
|
+
for row in rows_iter:
|
|
604
|
+
ws.append(row)
|
|
605
|
+
wb.save(path)
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
## 10. Structured Logging (structlog)
|
|
609
|
+
|
|
610
|
+
### Config (call once at startup)
|
|
611
|
+
|
|
612
|
+
```python
|
|
613
|
+
import structlog
|
|
614
|
+
import logging
|
|
615
|
+
import sys
|
|
616
|
+
|
|
617
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
618
|
+
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=level)
|
|
619
|
+
structlog.configure(
|
|
620
|
+
processors=[
|
|
621
|
+
structlog.contextvars.merge_contextvars,
|
|
622
|
+
structlog.processors.add_log_level,
|
|
623
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
624
|
+
structlog.processors.StackInfoRenderer(),
|
|
625
|
+
structlog.processors.format_exc_info,
|
|
626
|
+
structlog.processors.JSONRenderer(),
|
|
627
|
+
],
|
|
628
|
+
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
|
629
|
+
cache_logger_on_first_use=True,
|
|
630
|
+
)
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Usage patterns
|
|
634
|
+
|
|
635
|
+
```python
|
|
636
|
+
log = structlog.get_logger()
|
|
637
|
+
|
|
638
|
+
# Bind context at function entry
|
|
639
|
+
log = log.bind(workflow="annual-update", year=2023)
|
|
640
|
+
|
|
641
|
+
# Log with structured fields (NOT f-strings)
|
|
642
|
+
log.info("inep_download_started", url=url, container=container)
|
|
643
|
+
# BAD: log.info(f"Downloading from {url}")
|
|
644
|
+
|
|
645
|
+
# Use exception() to capture stack
|
|
646
|
+
try:
|
|
647
|
+
...
|
|
648
|
+
except SchemaError:
|
|
649
|
+
log.exception("inep_schema_validation_failed", year=year)
|
|
650
|
+
raise
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### Required fields in all logs
|
|
654
|
+
|
|
655
|
+
| Field | Always | Why |
|
|
656
|
+
|---|---|---|
|
|
657
|
+
| `event` | yes (first arg) | snake_case action name |
|
|
658
|
+
| `workflow` | yes | enables filtering across runs |
|
|
659
|
+
| `year` | yes | enables filtering across years |
|
|
660
|
+
| Module-specific kwargs | as relevant | structured grep |
|
|
661
|
+
|
|
662
|
+
**Rule**: never log secrets, SAS URLs, or PII. Use `redact_sas()` helper.
|
|
663
|
+
|
|
664
|
+
## 11. INEP / Government CSV Patterns
|
|
665
|
+
|
|
666
|
+
### Encoding fallback
|
|
667
|
+
|
|
668
|
+
```python
|
|
669
|
+
def read_csv_resilient(path: Path) -> pd.DataFrame:
|
|
670
|
+
"""Try latin-1 → cp1252 → utf-8. INEP files are usually latin-1."""
|
|
671
|
+
for encoding in ("latin-1", "cp1252", "utf-8"):
|
|
672
|
+
try:
|
|
673
|
+
return pd.read_csv(path, encoding=encoding, sep=";")
|
|
674
|
+
except UnicodeDecodeError:
|
|
675
|
+
continue
|
|
676
|
+
raise ValueError(f"could not decode {path} with any known encoding")
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### Malformed rows
|
|
680
|
+
|
|
681
|
+
```python
|
|
682
|
+
# Pandas: skip bad rows but log them
|
|
683
|
+
df = pd.read_csv(
|
|
684
|
+
path,
|
|
685
|
+
encoding="latin-1",
|
|
686
|
+
sep=";",
|
|
687
|
+
on_bad_lines=lambda bad_line: (
|
|
688
|
+
log.warning("inep_bad_row_skipped", line=bad_line),
|
|
689
|
+
None, # skip
|
|
690
|
+
)[1],
|
|
691
|
+
engine="python", # required for on_bad_lines callable
|
|
692
|
+
)
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### Year discovery from MEC portal
|
|
696
|
+
|
|
697
|
+
```python
|
|
698
|
+
import re
|
|
699
|
+
import requests
|
|
700
|
+
|
|
701
|
+
def discover_latest_inep_year() -> int:
|
|
702
|
+
"""Scrape MEC portal for the most recent published Censo year.
|
|
703
|
+
|
|
704
|
+
Falls back to (current_calendar_year - 2) if scraping fails — INEP publishes
|
|
705
|
+
with ~1y lag, so 2026 Q1 usually has 2024 latest, but conservatively assume 2023.
|
|
706
|
+
"""
|
|
707
|
+
try:
|
|
708
|
+
resp = requests.get(
|
|
709
|
+
"https://www.gov.br/inep/pt-br/acesso-a-informacao/dados-abertos/microdados/censo-da-educacao-superior",
|
|
710
|
+
timeout=30,
|
|
711
|
+
)
|
|
712
|
+
years = sorted(set(int(y) for y in re.findall(r"\b(20\d{2})\b", resp.text)), reverse=True)
|
|
713
|
+
return years[0] if years else 2023
|
|
714
|
+
except requests.RequestException:
|
|
715
|
+
log.warning("inep_year_discovery_failed", fallback=2023)
|
|
716
|
+
return 2023
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Data quality assertions per row
|
|
720
|
+
|
|
721
|
+
```python
|
|
722
|
+
def validate_inep_row(row: dict[str, Any]) -> bool:
|
|
723
|
+
"""Quick sanity checks per row. Reject if any fail."""
|
|
724
|
+
if row.get("CO_IES", 0) <= 0:
|
|
725
|
+
return False
|
|
726
|
+
if not re.match(r"^[A-Z]{2}$", str(row.get("SG_UF_IES", ""))):
|
|
727
|
+
return False
|
|
728
|
+
if row.get("QT_MAT_PRESENCIAL", -1) < 0 or row.get("QT_MAT_EAD", -1) < 0:
|
|
729
|
+
return False
|
|
730
|
+
return True
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
## 12. Quality Gates
|
|
734
|
+
|
|
735
|
+
### Pre-commit checklist (run in order)
|
|
736
|
+
|
|
737
|
+
```bash
|
|
738
|
+
ruff format --check src tests # NO uncommitted formatting drift
|
|
739
|
+
ruff check src tests # 0 lint errors
|
|
740
|
+
mypy --strict src # 0 type errors
|
|
741
|
+
pytest tests/unit --cov=src --cov-fail-under=80
|
|
742
|
+
pytest tests/integration # requires Azurite
|
|
743
|
+
# acceptance tests run manually only — never in CI
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### CI pipeline structure (GHA)
|
|
747
|
+
|
|
748
|
+
```yaml
|
|
749
|
+
jobs:
|
|
750
|
+
lint-and-types:
|
|
751
|
+
steps:
|
|
752
|
+
- ruff format --check src tests
|
|
753
|
+
- ruff check src tests
|
|
754
|
+
- mypy --strict src
|
|
755
|
+
unit:
|
|
756
|
+
needs: lint-and-types
|
|
757
|
+
steps:
|
|
758
|
+
- pytest tests/unit --cov=src --cov-fail-under=80
|
|
759
|
+
integration:
|
|
760
|
+
needs: unit
|
|
761
|
+
services:
|
|
762
|
+
azurite:
|
|
763
|
+
image: mcr.microsoft.com/azure-storage/azurite
|
|
764
|
+
steps:
|
|
765
|
+
- pytest tests/integration
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
### Definition of Done per module
|
|
769
|
+
|
|
770
|
+
A module is **DONE** when:
|
|
771
|
+
1. All unit tests pass (TDD red → green)
|
|
772
|
+
2. Type-check passes (`mypy --strict <module>`)
|
|
773
|
+
3. Lint passes (`ruff check <module>`)
|
|
774
|
+
4. Coverage ≥80% branch
|
|
775
|
+
5. At least one integration test covers the module if it has I/O
|
|
776
|
+
6. Module exports documented in `__init__.py`
|
|
777
|
+
7. Acceptance tests exist for KPI-bearing functionality (if applicable)
|
|
778
|
+
|
|
779
|
+
## Cross-references
|
|
780
|
+
|
|
781
|
+
- TDD discipline (TS-flavored): `/tdd-discipline` — different stack, **do NOT use for Python projects**
|
|
782
|
+
- Project conventions (stack-agnostic): `/project-conventions`
|
|
783
|
+
- Checkpoint protocol: `/checkpoint-protocol`
|
|
784
|
+
- KB lookup: `/kb-lookup` — for org-specific patterns
|
|
785
|
+
- External pandas deep dive: <https://jeffallan.github.io/claude-skills/skills/data-ml/pandas-pro/>
|
|
786
|
+
|
|
787
|
+
## When NOT to use this skill
|
|
788
|
+
|
|
789
|
+
- Python web backends (FastAPI/Django) — use backend-specific skills if available
|
|
790
|
+
- Pure scripting (no schemas, no data transformations) — overkill
|
|
791
|
+
- TS/React projects — wrong language entirely
|
|
792
|
+
- Azure infra (Bicep, GHA OIDC) — use `/azure-prepare`, `/azure-deploy` instead
|