Skip to content

Commit 51e5f61

Browse files
committed
Add GitHub Actions test workflow
1 parent b28e40d commit 51e5f61

File tree

8 files changed

+956
-126
lines changed

8 files changed

+956
-126
lines changed

.github/workflows/test.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
2+
name: Tests
3+
4+
on: # https://docs.github.com/en/actions/reference/events-that-trigger-workflows
5+
push:
6+
branches:
7+
- '**'
8+
tags-ignore: # don't build tags
9+
- '**'
10+
paths-ignore:
11+
- '**/*.md'
12+
pull_request:
13+
paths-ignore:
14+
- '**/*.md'
15+
workflow_dispatch:
16+
# https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch
17+
18+
defaults:
19+
run:
20+
shell: bash
21+
22+
jobs:
23+
test:
24+
runs-on: ${{ matrix.os }}
25+
strategy:
26+
matrix:
27+
os: [ubuntu-latest, windows-latest, macos-latest]
28+
fail-fast: false
29+
30+
steps:
31+
- name: Git Checkout
32+
uses: actions/checkout@v4 # https://github.com/actions/checkout
33+
34+
- name: Install uv
35+
uses: astral-sh/setup-uv@v5
36+
with:
37+
enable-cache: true
38+
39+
- name: Set up Python
40+
run: uv python install
41+
42+
- name: Install ast-grep
43+
run: |
44+
npm install -g @ast-grep/cli
45+
ast-grep --version
46+
47+
- name: Install dependencies
48+
run: |
49+
uv sync --all-extras --dev
50+
51+
- name: Lint with ruff
52+
run: |
53+
uv run ruff check .
54+
55+
- name: Format check with ruff
56+
run: |
57+
uv run ruff format --check .
58+
continue-on-error: true # TODO
59+
60+
- name: Type check with mypy
61+
run: |
62+
uv run mypy main.py
63+
64+
- name: Run unit tests
65+
run: |
66+
uv run pytest tests/test_main.py -v --cov=main --cov-report=term-missing
67+
68+
- name: Run integration tests
69+
run: |
70+
uv run pytest tests/test_integration.py -v

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,8 @@ dist/
66
wheels/
77
*.egg-info
88

9+
# MyPy. Ruff, PyTest cache folders
10+
.*_cache/
11+
912
# Virtual environments
1013
.venv

main.py

Lines changed: 62 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,63 @@
1-
from typing import Any, List, Optional
2-
from mcp.server.fastmcp import FastMCP
3-
import subprocess
4-
from pydantic import Field
5-
import json
6-
from enum import Enum
71
import argparse
2+
import json
83
import os
4+
import subprocess
95
import sys
6+
from typing import Any, List, Literal, Optional
107

11-
# Determine how the script was invoked
12-
if sys.argv[0].endswith('main.py'):
13-
# Direct execution: python main.py
14-
prog = 'python main.py'
15-
else:
16-
# Installed script execution (via uvx, pip install, etc.)
17-
prog = None # Let argparse use the default
18-
19-
# Parse command-line arguments
20-
parser = argparse.ArgumentParser(
21-
prog=prog,
22-
description='ast-grep MCP Server - Provides structural code search capabilities via Model Context Protocol',
23-
epilog='''
8+
from mcp.server.fastmcp import FastMCP
9+
from pydantic import Field
10+
11+
# Global variable for config path (will be set by parse_args_and_get_config)
12+
CONFIG_PATH = None
13+
14+
def parse_args_and_get_config():
15+
"""Parse command-line arguments and determine config path."""
16+
global CONFIG_PATH
17+
18+
# Determine how the script was invoked
19+
prog = None
20+
if sys.argv[0].endswith('main.py'):
21+
# Direct execution: python main.py
22+
prog = 'python main.py'
23+
24+
# Parse command-line arguments
25+
parser = argparse.ArgumentParser(
26+
prog=prog,
27+
description='ast-grep MCP Server - Provides structural code search capabilities via Model Context Protocol',
28+
epilog='''
2429
environment variables:
2530
AST_GREP_CONFIG Path to sgconfig.yaml file (overridden by --config flag)
2631
2732
For more information, see: https://github.com/ast-grep/ast-grep-mcp
28-
''',
29-
formatter_class=argparse.RawDescriptionHelpFormatter
30-
)
31-
parser.add_argument(
32-
'--config',
33-
type=str,
34-
metavar='PATH',
35-
help='Path to sgconfig.yaml file for customizing ast-grep behavior (language mappings, rule directories, etc.)'
36-
)
37-
args = parser.parse_args()
38-
39-
# Determine config path with precedence: --config flag > AST_GREP_CONFIG env > None
40-
CONFIG_PATH = None
41-
if args.config:
42-
if not os.path.exists(args.config):
43-
print(f"Error: Config file '{args.config}' does not exist")
44-
sys.exit(1)
45-
CONFIG_PATH = args.config
46-
elif os.environ.get('AST_GREP_CONFIG'):
47-
env_config = os.environ.get('AST_GREP_CONFIG')
48-
if not os.path.exists(env_config):
49-
print(f"Error: Config file '{env_config}' specified in AST_GREP_CONFIG does not exist")
50-
sys.exit(1)
51-
CONFIG_PATH = env_config
33+
''',
34+
formatter_class=argparse.RawDescriptionHelpFormatter
35+
)
36+
parser.add_argument(
37+
'--config',
38+
type=str,
39+
metavar='PATH',
40+
help='Path to sgconfig.yaml file for customizing ast-grep behavior (language mappings, rule directories, etc.)'
41+
)
42+
args = parser.parse_args()
43+
44+
# Determine config path with precedence: --config flag > AST_GREP_CONFIG env > None
45+
if args.config:
46+
if not os.path.exists(args.config):
47+
print(f"Error: Config file '{args.config}' does not exist")
48+
sys.exit(1)
49+
CONFIG_PATH = args.config
50+
elif os.environ.get('AST_GREP_CONFIG'):
51+
env_config = os.environ.get('AST_GREP_CONFIG')
52+
if env_config and not os.path.exists(env_config):
53+
print(f"Error: Config file '{env_config}' specified in AST_GREP_CONFIG does not exist")
54+
sys.exit(1)
55+
CONFIG_PATH = env_config
5256

5357
# Initialize FastMCP server
5458
mcp = FastMCP("ast-grep")
5559

56-
class DumpFormat(Enum):
57-
Pattern = "pattern"
58-
CST = "cst"
59-
AST = "ast"
60+
DumpFormat = Literal["pattern", "cst", "ast"]
6061

6162
@mcp.tool()
6263
def dump_syntax_tree(
@@ -74,8 +75,8 @@ def dump_syntax_tree(
7475
7576
Internally calls: ast-grep run --pattern <code> --lang <language> --debug-query=<format>
7677
"""
77-
result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format.value}"])
78-
return result.stderr.strip()
78+
result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format}"])
79+
return result.stderr.strip() # type: ignore[no-any-return]
7980

8081
@mcp.tool()
8182
def test_match_code_rule(
@@ -92,7 +93,7 @@ def test_match_code_rule(
9293
matches = json.loads(result.stdout.strip())
9394
if not matches:
9495
raise ValueError("No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.")
95-
return matches
96+
return matches # type: ignore[no-any-return]
9697

9798
@mcp.tool()
9899
def find_code(
@@ -131,7 +132,7 @@ def find_code(
131132
# Limit results if max_results is specified
132133
if max_results is not None and len(matches) > max_results:
133134
matches = matches[:max_results]
134-
return matches
135+
return matches # type: ignore[no-any-return]
135136
else:
136137
# Text format - return plain text output
137138
result = run_ast_grep("run", args + [project_folder])
@@ -150,7 +151,7 @@ def find_code(
150151
else:
151152
header = f"Found {len(non_empty_lines)} matches:\n"
152153
output = header + output
153-
return output
154+
return output # type: ignore[no-any-return]
154155

155156
@mcp.tool()
156157
def find_code_by_rule(
@@ -188,7 +189,7 @@ def find_code_by_rule(
188189
# Limit results if max_results is specified
189190
if max_results is not None and len(matches) > max_results:
190191
matches = matches[:max_results]
191-
return matches
192+
return matches # type: ignore[no-any-return]
192193
else:
193194
# Text format - return plain text output
194195
result = run_ast_grep("scan", args + [project_folder])
@@ -207,16 +208,21 @@ def find_code_by_rule(
207208
else:
208209
header = f"Found {len(non_empty_lines)} matches:\n"
209210
output = header + output
210-
return output
211+
return output # type: ignore[no-any-return]
211212

212213
def run_command(args: List[str], input_text: Optional[str] = None) -> subprocess.CompletedProcess:
213214
try:
215+
# On Windows, if ast-grep is installed via npm, it's a batch file
216+
# that requires shell=True to execute properly
217+
use_shell = (sys.platform == "win32" and args[0] == "ast-grep")
218+
214219
result = subprocess.run(
215220
args,
216221
capture_output=True,
217222
input=input_text,
218223
text=True,
219-
check=True # Raises CalledProcessError if return code is non-zero
224+
check=True, # Raises CalledProcessError if return code is non-zero
225+
shell=use_shell
220226
)
221227
return result
222228
except subprocess.CalledProcessError as e:
@@ -237,6 +243,7 @@ def run_mcp_server() -> None:
237243
Run the MCP server.
238244
This function is used to start the MCP server when this script is run directly.
239245
"""
246+
parse_args_and_get_config()
240247
mcp.run(transport="stdio")
241248

242249
if __name__ == "__main__":

pyproject.toml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,49 @@ dependencies = [
99
"mcp[cli]>=1.6.0",
1010
]
1111

12+
[project.optional-dependencies]
13+
dev = [
14+
"pytest>=8.0.0",
15+
"pytest-cov>=5.0.0",
16+
"pytest-mock>=3.14.0",
17+
"ruff>=0.7.0",
18+
"mypy>=1.13.0",
19+
]
20+
1221
[project.scripts]
1322
ast-grep-server = "main:run_mcp_server"
23+
24+
[tool.pytest.ini_options]
25+
testpaths = ["tests"]
26+
python_files = ["test_*.py"]
27+
python_classes = ["Test*"]
28+
python_functions = ["test_*"]
29+
addopts = "-v"
30+
31+
[tool.coverage.run]
32+
source = ["main"]
33+
omit = ["tests/*"]
34+
35+
[tool.coverage.report]
36+
exclude_lines = [
37+
"pragma: no cover",
38+
"def __repr__",
39+
"if __name__ == .__main__.:",
40+
"raise NotImplementedError",
41+
"pass",
42+
"except ImportError:",
43+
]
44+
45+
[tool.ruff]
46+
line-length = 140
47+
target-version = "py313"
48+
49+
[tool.ruff.lint]
50+
select = ["E", "F", "I", "N", "W"]
51+
52+
[tool.mypy]
53+
python_version = "3.13"
54+
warn_return_any = true
55+
warn_unused_configs = true
56+
disallow_untyped_defs = false
57+
ignore_missing_imports = true

tests/fixtures/example.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
def hello():
2+
print("Hello, World!")
3+
4+
5+
def add(a, b):
6+
return a + b
7+
8+
9+
class Calculator:
10+
def multiply(self, x, y):
11+
return x * y

0 commit comments

Comments
 (0)