Skip to content

Commit 261ccb5

Browse files
committed
lint
1 parent 40a2d87 commit 261ccb5

37 files changed

+1594
-216
lines changed

.github/workflows/ci.yml

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,42 @@ on:
77
branches: [ main, master ]
88

99
jobs:
10+
backend-lint:
11+
name: Backend Lint & Type Check
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Set up Python 3.12
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: '3.12'
22+
cache: 'pip'
23+
24+
- name: Install linting tools
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install ruff mypy bandit
28+
29+
- name: Run ruff (linting)
30+
run: ruff check app/
31+
32+
- name: Run ruff (formatting check)
33+
run: ruff format --check app/
34+
35+
- name: Run mypy (type checking)
36+
run: mypy app/ --ignore-missing-imports
37+
38+
- name: Run bandit (security scan)
39+
run: bandit -r app/ -x app/tests --skip B101
40+
1041
backend-test:
11-
name: Backend (Python 3.12)
42+
name: Backend Tests
1243
runs-on: ubuntu-latest
13-
44+
needs: backend-lint
45+
1446
steps:
1547
- name: Checkout repository
1648
uses: actions/checkout@v4
@@ -19,21 +51,52 @@ jobs:
1951
uses: actions/setup-python@v5
2052
with:
2153
python-version: '3.12'
22-
cache: 'pip' # Caches dependencies based on pyproject.toml
54+
cache: 'pip'
2355

2456
- name: Install dependencies
25-
# pygambit requires Rust/Cargo which is pre-installed on ubuntu-latest
2657
run: |
2758
python -m pip install --upgrade pip
2859
pip install -e ".[dev]"
2960
30-
- name: Run tests
61+
- name: Run tests with coverage
3162
run: |
32-
pytest -v
63+
pytest -v --cov=app --cov-report=xml --cov-report=term-missing
64+
65+
- name: Upload coverage report
66+
uses: codecov/codecov-action@v4
67+
with:
68+
files: ./coverage.xml
69+
fail_ci_if_error: false
70+
continue-on-error: true
71+
72+
frontend-lint:
73+
name: Frontend Lint & Type Check
74+
runs-on: ubuntu-latest
75+
defaults:
76+
run:
77+
working-directory: ./frontend
78+
79+
steps:
80+
- name: Checkout repository
81+
uses: actions/checkout@v4
3382

34-
frontend-build:
35-
name: Frontend (Node 18)
83+
- name: Set up Node.js 20
84+
uses: actions/setup-node@v4
85+
with:
86+
node-version: '20'
87+
cache: 'npm'
88+
cache-dependency-path: frontend/package-lock.json
89+
90+
- name: Install dependencies
91+
run: npm ci
92+
93+
- name: Type check
94+
run: npx tsc --noEmit
95+
96+
frontend-test:
97+
name: Frontend Tests
3698
runs-on: ubuntu-latest
99+
needs: frontend-lint
37100
defaults:
38101
run:
39102
working-directory: ./frontend
@@ -42,16 +105,49 @@ jobs:
42105
- name: Checkout repository
43106
uses: actions/checkout@v4
44107

45-
- name: Set up Node.js 18
108+
- name: Set up Node.js 20
46109
uses: actions/setup-node@v4
47110
with:
48-
node-version: '18'
111+
node-version: '20'
49112
cache: 'npm'
50113
cache-dependency-path: frontend/package-lock.json
51114

52115
- name: Install dependencies
53116
run: npm ci
54117

55-
- name: Build and Typecheck
56-
# Runs "tsc && vite build" as defined in package.json
118+
- name: Run tests
119+
run: npm test
120+
121+
- name: Build
57122
run: npm run build
123+
124+
security-audit:
125+
name: Security Audit
126+
runs-on: ubuntu-latest
127+
128+
steps:
129+
- name: Checkout repository
130+
uses: actions/checkout@v4
131+
132+
- name: Set up Node.js 20
133+
uses: actions/setup-node@v4
134+
with:
135+
node-version: '20'
136+
cache: 'npm'
137+
cache-dependency-path: frontend/package-lock.json
138+
139+
- name: Audit npm dependencies
140+
working-directory: ./frontend
141+
run: npm audit --audit-level=high
142+
continue-on-error: true # Don't fail on audit issues, just report
143+
144+
- name: Set up Python 3.12
145+
uses: actions/setup-python@v5
146+
with:
147+
python-version: '3.12'
148+
149+
- name: Check Python dependencies with pip-audit
150+
run: |
151+
pip install pip-audit
152+
pip-audit --ignore-vuln GHSA-xxxx || true # Report but don't fail
153+
continue-on-error: true

CLAUDE.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,27 @@ docker compose exec app pytest tests/ -v --tb=short --ignore=tests/integration
5252
# Run integration tests (requires all services running)
5353
docker compose exec app pytest tests/integration/ -v --tb=short
5454

55+
# Frontend tests
56+
npm test --prefix frontend
57+
5558
# Frontend build (includes TypeScript check)
56-
cd frontend && npm run build
59+
npm run build --prefix frontend
60+
```
61+
62+
### Linting & Code Quality
63+
64+
Install dev dependencies first: `pip install -e ".[dev]"`
65+
66+
```bash
67+
# Backend - run all linters
68+
ruff check app/ # Linting
69+
ruff format app/ # Auto-format code
70+
ruff format --check app/ # Check formatting without changes
71+
mypy app/ --ignore-missing-imports # Type checking
72+
bandit -r app/ -x app/tests --skip B101 # Security scan
73+
74+
# Frontend
75+
npm run lint --prefix frontend # TypeScript type check
5776
```
5877

5978
## Plugin Architecture

app/conversions/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
Provides conversions between game representations (e.g., EFG <-> NFG).
44
"""
55

6+
# Import converters for registration side effects
7+
from app.conversions import efg_nfg as _efg_nfg # noqa: F401
68
from app.conversions.registry import (
79
Conversion,
810
ConversionCheck,
911
ConversionRegistry,
1012
)
1113

12-
# Import converters for registration side effects
13-
from app.conversions import efg_nfg as _efg_nfg # noqa: F401
14-
1514
__all__ = [
1615
"Conversion",
1716
"ConversionCheck",

app/conversions/efg_export.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,8 @@ def traverse(node_id: str) -> list[str]:
110110
result.extend(traverse(target))
111111
else:
112112
# No target - create dummy terminal
113-
outcome_num = get_outcome_number(
114-
f"none_{node_id}_{action.get('label', '')}"
115-
)
116-
result.append(
117-
f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}'
118-
)
113+
outcome_num = get_outcome_number(f"none_{node_id}_{action.get('label', '')}")
114+
result.append(f't "" {outcome_num} "none" {{ {", ".join("0" for _ in players)} }}')
119115

120116
return result
121117

app/conversions/efg_nfg.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Mapping
5+
from collections.abc import Mapping
66

77
from app.config import ConversionConfig
88
from app.conversions.registry import Conversion, ConversionCheck
@@ -12,7 +12,7 @@
1212
resolve_payoffs,
1313
)
1414
from app.dependencies import get_conversion_registry
15-
from app.models import NormalFormGame, ExtensiveFormGame, Action, DecisionNode, Outcome
15+
from app.models import Action, DecisionNode, ExtensiveFormGame, NormalFormGame, Outcome
1616

1717
# =============================================================================
1818
# EFG -> NFG Conversion
@@ -27,9 +27,7 @@ def check_efg_to_nfg(game: ExtensiveFormGame | NormalFormGame) -> ConversionChec
2727
if len(game.players) != 2:
2828
return ConversionCheck(
2929
possible=False,
30-
blockers=[
31-
f"Matrix view requires exactly 2 players (game has {len(game.players)})"
32-
],
30+
blockers=[f"Matrix view requires exactly 2 players (game has {len(game.players)})"],
3331
)
3432

3533
# Estimate strategy count WITHOUT enumerating (could be exponential!)

app/conversions/registry.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from __future__ import annotations
88

99
from collections import deque
10+
from collections.abc import Callable
1011
from dataclasses import dataclass, field
11-
from typing import TYPE_CHECKING, Callable
12+
from typing import TYPE_CHECKING
1213

1314
if TYPE_CHECKING:
1415
from app.models import AnyGame
@@ -62,9 +63,7 @@ def _find_conversion_path(
6263
neighbors.setdefault(src, []).append(tgt)
6364

6465
# BFS to find shortest path
65-
queue: deque[tuple[str, list[tuple[str, str]]]] = deque(
66-
[(source_format, [])]
67-
)
66+
queue: deque[tuple[str, list[tuple[str, str]]]] = deque([(source_format, [])])
6867
visited = {source_format}
6968

7069
while queue:
@@ -80,9 +79,7 @@ def _find_conversion_path(
8079

8180
return None
8281

83-
def check(
84-
self, game: "AnyGame", target_format: str, *, quick: bool = False
85-
) -> ConversionCheck:
82+
def check(self, game: AnyGame, target_format: str, *, quick: bool = False) -> ConversionCheck:
8683
"""Check if a game can be converted to target format.
8784
8885
Supports chained conversions (e.g., MAID → EFG → NFG).
@@ -104,9 +101,7 @@ def check(
104101
if not path:
105102
return ConversionCheck(
106103
possible=False,
107-
blockers=[
108-
f"No conversion path from {source_format} to {target_format}"
109-
],
104+
blockers=[f"No conversion path from {source_format} to {target_format}"],
110105
)
111106

112107
# Quick check: only verify path exists and first step is possible
@@ -153,7 +148,7 @@ def check(
153148

154149
return ConversionCheck(possible=True, warnings=all_warnings)
155150

156-
def convert(self, game: "AnyGame", target_format: str) -> "AnyGame":
151+
def convert(self, game: AnyGame, target_format: str) -> AnyGame:
157152
"""Convert a game to target format.
158153
159154
Supports chained conversions (e.g., MAID → EFG → NFG).
@@ -175,17 +170,15 @@ def convert(self, game: "AnyGame", target_format: str) -> "AnyGame":
175170
check_result = conversion.can_convert(current_game)
176171

177172
if not check_result.possible:
178-
msg = (
179-
f"Cannot convert {src} to {tgt}: {', '.join(check_result.blockers)}"
180-
)
173+
msg = f"Cannot convert {src} to {tgt}: {', '.join(check_result.blockers)}"
181174
raise ValueError(msg)
182175

183176
current_game = conversion.convert(current_game)
184177

185178
return current_game
186179

187180
def available_conversions(
188-
self, game: "AnyGame", *, quick: bool = True
181+
self, game: AnyGame, *, quick: bool = True
189182
) -> dict[str, ConversionCheck]:
190183
"""Get all available conversions for a game.
191184
@@ -212,9 +205,7 @@ def available_conversions(
212205
continue
213206
check_result = self.check(game, target, quick=quick)
214207
# Only include if possible or has a path (even if blocked)
215-
if check_result.possible or self._find_conversion_path(
216-
source_format, target
217-
):
208+
if check_result.possible or self._find_conversion_path(source_format, target):
218209
results[target] = check_result
219210

220211
return results

app/conversions/remote.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def create_remote_conversion(
3838
"""
3939
client = RemoteServiceClient(plugin_url, service_name=plugin_name)
4040

41-
def can_convert(game: "AnyGame") -> ConversionCheck:
41+
def can_convert(game: AnyGame) -> ConversionCheck:
4242
"""Check if this game can be converted."""
4343
if game.format_name != source_format:
4444
return ConversionCheck(
@@ -47,11 +47,11 @@ def can_convert(game: "AnyGame") -> ConversionCheck:
4747
)
4848
return ConversionCheck(possible=True)
4949

50-
def convert(game: "AnyGame") -> "AnyGame":
50+
def convert(game: AnyGame) -> AnyGame:
5151
"""Convert the game via remote plugin."""
5252
from app.models.extensive_form import ExtensiveFormGame
53-
from app.models.normal_form import NormalFormGame
5453
from app.models.maid import MAIDGame
54+
from app.models.normal_form import NormalFormGame
5555

5656
endpoint = f"/convert/{source_format}-to-{target_format}"
5757
logger.debug("Converting via %s%s", plugin_url, endpoint)
@@ -67,12 +67,12 @@ def convert(game: "AnyGame") -> "AnyGame":
6767
raise ValueError(
6868
f"Cannot convert {source_format} to {target_format}: "
6969
f"plugin service is unreachable. Ensure the {plugin_name} plugin is running."
70-
)
71-
raise ValueError(f"Conversion failed: {e.error.message}")
70+
) from e
71+
raise ValueError(f"Conversion failed: {e.error.message}") from e
7272

7373
game_dict = response.get("game")
7474
if not game_dict:
75-
raise ValueError(f"Conversion response missing 'game' field")
75+
raise ValueError("Conversion response missing 'game' field")
7676

7777
# Convert to appropriate model based on format_name
7878
format_name = game_dict.get("format_name", target_format)
@@ -83,7 +83,7 @@ def convert(game: "AnyGame") -> "AnyGame":
8383
return MAIDGame(**game_dict)
8484
return ExtensiveFormGame(**game_dict)
8585
except Exception as e:
86-
raise ValueError(f"Failed to parse converted game: {e}")
86+
raise ValueError(f"Failed to parse converted game: {e}") from e
8787

8888
return Conversion(
8989
name=f"{source_format} to {target_format} (via {plugin_name})",

0 commit comments

Comments
 (0)