Skip to content

Commit e1d5c92

Browse files
build: introduce python type checking (#81)
* build: introduce python type checking * docs: document make typecheck in the maintainers guide The other make targets are listed under "Running the tests" but typecheck was missing, even though CI runs it on every PR. --------- Co-authored-by: Michael Brooks <mbrooks@slack-corp.com>
1 parent 89139bf commit e1d5c92

12 files changed

Lines changed: 67 additions & 26 deletions

.github/maintainers_guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ make test-eval # LLM-judged skill evaluations (local only)
8383
make test # both
8484
make lint # Ruff linter (line-length 120)
8585
make format # Ruff auto-format + fix
86+
make typecheck # Mypy static type checks
8687
```
8788

8889
### Testing in Claude Code

.github/workflows/ci-build.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ jobs:
3232
- name: Run lint
3333
run: make lint
3434

35+
typecheck:
36+
name: Typecheck
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 5
39+
permissions:
40+
contents: read
41+
steps:
42+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
43+
with:
44+
persist-credentials: false
45+
- name: Set up Python ${{ env.SUPPORTED_PY }}
46+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
47+
with:
48+
python-version: ${{ env.SUPPORTED_PY }}
49+
# mypy resolves imports, so it needs the runtime/test deps (deepeval, mcp,
50+
# pydantic — all typed) as well as the tools, not just ruff/mypy.
51+
- name: Install dependencies
52+
run: make install
53+
- name: Run typecheck
54+
run: make typecheck
55+
3556
test:
3657
name: Unit tests
3758
runs-on: ubuntu-latest
@@ -78,6 +99,7 @@ jobs:
7899
runs-on: ubuntu-latest
79100
needs:
80101
- lint
102+
- typecheck
81103
- test
82104
- eval
83105
if: ${{ !success() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' }}

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Requires Python 3.14+. Run `make install` before first use to set up the virtual
2121
| `make install` | Full setup: venv + deps |
2222
| `make lint` | Ruff linter (line-length=120) |
2323
| `make format` | Ruff auto-format + fix |
24+
| `make typecheck` | Mypy static type checks |
2425
| `make test-unit` | Fast validation tests (pytest) |
2526
| `make test-eval` | LLM-judged tests (runs DeepEval against Gemini) |
2627
| `make test` | Both unit + eval tests |
@@ -55,6 +56,7 @@ To add an eval scenario, append a `Scenario` (prompt + expected tool) to `SCENAR
5556
GitHub Actions (`.github/workflows/ci-build.yml`) gates every PR with:
5657

5758
- **Lint**`make lint` (Ruff)
59+
- **Typecheck**`make typecheck` (mypy)
5860
- **Test**`make test-unit` (pytest)
5961
- **Eval**`make test-eval` (DeepEval + Gemini)
6062

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ VENV := .venv
22
PYTHON := $(VENV)/bin/python
33
PIP := $(VENV)/bin/pip
44
RUFF := $(VENV)/bin/ruff
5+
MYPY := $(VENV)/bin/mypy
56
DEEPEVAL := $(VENV)/bin/deepeval
67

7-
TARGETS := help install install-test install-tools clean lint format test test-unit test-eval cursor-install cursor-uninstall
8+
TARGETS := help install install-test install-tools clean lint format typecheck test test-unit test-eval cursor-install cursor-uninstall
89

910
.PHONY: $(TARGETS)
1011

@@ -42,6 +43,9 @@ format: ## Auto-format code with ruff
4243
$(RUFF) format .
4344
$(RUFF) check --fix .
4445

46+
typecheck: ## Run mypy static type checks
47+
$(MYPY)
48+
4549
test: ## Run all tests (set testdir=<path> to route to the matching runner)
4650
ifdef testdir
4751
@if echo "$(testdir)" | grep -q "tests/eval"; then \

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ test = [
1616
"pyyaml>=6.0,<7.0",
1717
"mcp>=1.13,<2.0",
1818
]
19-
tools = ["ruff>=0.11,<1.0"]
19+
tools = ["ruff>=0.11,<1.0", "mypy>=1.11,<2.0", "types-PyYAML>=6.0,<7.0"]
2020

2121
# This project is installed only to run the test suite; the importable package
2222
# is `tests`. Scope discovery to it so setuptools' flat-layout autodiscovery
@@ -39,3 +39,12 @@ known-first-party = ["tests"]
3939
[tool.ruff.format]
4040
quote-style = "double"
4141
indent-style = "space"
42+
43+
[tool.mypy]
44+
python_version = "3.14"
45+
files = ["tests", "scripts"]
46+
plugins = ["pydantic.mypy"]
47+
disallow_untyped_defs = true
48+
warn_unused_ignores = true
49+
warn_redundant_casts = true
50+
warn_return_any = true

scripts/cursor.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@
3131
MARKETPLACE_NAME = "local"
3232

3333

34-
def get_plugin_key(plugin_name: str):
34+
def get_plugin_key(plugin_name: str) -> str:
3535
return f"{plugin_name}@{MARKETPLACE_NAME}"
3636

3737

38-
def get_target_path(plugin_key: str):
38+
def get_target_path(plugin_key: str) -> Path:
3939
return CURSOR_PLUGINS_PATH / plugin_key
4040

4141

@@ -44,7 +44,8 @@ def plugin_name() -> str:
4444
cursor_plugin = json.loads(
4545
(REPO_ROOT / ".cursor-plugin" / "plugin.json").read_text()
4646
)
47-
return cursor_plugin["name"]
47+
name: str = cursor_plugin["name"]
48+
return name
4849

4950

5051
def plugin_files() -> set[Path]:
@@ -61,7 +62,8 @@ def plugin_files() -> set[Path]:
6162
def load_json(path: Path) -> dict:
6263
if not path.exists() or not path.read_text().strip():
6364
return {}
64-
return json.loads(path.read_text())
65+
data: dict = json.loads(path.read_text())
66+
return data
6567

6668

6769
def save_json(path: Path, data: dict) -> None:

scripts/sync_versions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515

1616
def read_version(package_path: Path) -> str:
1717
"""Return the ``version`` field from ``package.json``."""
18-
return json.loads(package_path.read_text())["version"]
18+
version: str = json.loads(package_path.read_text())["version"]
19+
return version
1920

2021

2122
def write_version(plugin_path: Path, version: str) -> None:

tests/eval/test_tool_selection.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ class TestToolSelection:
188188
available_tools: list[ToolCall]
189189

190190
@classmethod
191-
def setup_class(cls):
191+
def setup_class(cls) -> None:
192192
if not GEMINI_API_KEY:
193193
pytest.fail("GEMINI_API_KEY not set")
194194
if not SLACK_MCP_TOKEN:
@@ -198,7 +198,7 @@ def setup_class(cls):
198198
cls.model = make_judge_model()
199199
cls.available_tools = get_slack_mcp_tools() + get_all_skill_tools()
200200

201-
def teardown_method(self):
201+
def teardown_method(self) -> None:
202202
# Gemini's free tier allows only 15 requests/minute. Each scenario makes one
203203
# model.generate() call, so sleep between scenarios to stay well under the
204204
# limit (~12 req/min) and avoid HTTP 429 / RESOURCE_EXHAUSTED.
@@ -209,7 +209,7 @@ def teardown_method(self):
209209
SCENARIOS,
210210
ids=[s["id"] for s in SCENARIOS],
211211
)
212-
def test_tool_selection(self, scenario: Scenario):
212+
def test_tool_selection(self, scenario: Scenario) -> None:
213213
accepted_tools = scenario["accepted_tools"]
214214
available_names = {t.name for t in self.available_tools}
215215
for accepted_tool in accepted_tools:

tests/unit/test_cross_skill_references.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55

66

77
class TestCrossSkillReferences:
8-
def setup_method(self):
8+
def setup_method(self) -> None:
99
self.skills = discover_skills()
1010
self.skill_names = {skill.frontmatter.name for skill in self.skills}
1111

12-
def test_plugin_skill_references_target_real_skills(self):
12+
def test_plugin_skill_references_target_real_skills(self) -> None:
1313
pattern = re.compile(rf"`{re.escape(PLUGIN_NAME)}:([a-z0-9-]+)`")
1414
for skill in self.skills:
1515
for target in pattern.findall(skill.body):
1616
assert target in self.skill_names, f"{skill.path} references unknown skill `{PLUGIN_NAME}:{target}`"
1717

18-
def test_no_markdown_anchor_links(self):
18+
def test_no_markdown_anchor_links(self) -> None:
1919
for skill in self.skills:
2020
# .find() returns -1 if the substring is not found
2121
anchor_index = skill.body.find("](#")
@@ -27,7 +27,7 @@ def test_no_markdown_anchor_links(self):
2727
"cross-skill references must not use `[text](#anchor)` links"
2828
)
2929

30-
def test_no_bare_skill_file_paths(self):
30+
def test_no_bare_skill_file_paths(self) -> None:
3131
for skill in self.skills:
3232
# .find() returns -1 if the substring is not found
3333
path_index = skill.body.find("SKILL.md")

tests/unit/test_discovery.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33

44

55
class TestSkillDiscovery:
6-
def setup_method(self):
6+
def setup_method(self) -> None:
77
self.skills = discover_skills()
88

9-
def test_skills_discovered(self):
9+
def test_skills_discovered(self) -> None:
1010
assert len(self.skills) > 0, "No skills found"
1111

12-
def test_expected_skills_exist(self):
12+
def test_expected_skills_exist(self) -> None:
1313
found = [s.frontmatter.name for s in self.skills]
1414
for expected in EXPECTED_SKILLS:
1515
assert expected in found

0 commit comments

Comments
 (0)