Skip to content

Commit 805bd67

Browse files
committed
Add GitHub Actions test workflow
1 parent b28e40d commit 805bd67

File tree

8 files changed

+956
-122
lines changed

8 files changed

+956
-122
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-ignore: # build all branches except:
7+
- 'dependabot/**' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
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 & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,59 @@
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 enum import Enum
7+
from typing import Any, List, Optional
8+
9+
from mcp.server.fastmcp import FastMCP
10+
from pydantic import Field
1011

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='''
12+
# Global variable for config path (will be set by parse_args_and_get_config)
13+
CONFIG_PATH = None
14+
15+
def parse_args_and_get_config():
16+
"""Parse command-line arguments and determine config path."""
17+
global CONFIG_PATH
18+
19+
# Determine how the script was invoked
20+
prog = None
21+
if sys.argv[0].endswith('main.py'):
22+
# Direct execution: python main.py
23+
prog = 'python main.py'
24+
25+
# Parse command-line arguments
26+
parser = argparse.ArgumentParser(
27+
prog=prog,
28+
description='ast-grep MCP Server - Provides structural code search capabilities via Model Context Protocol',
29+
epilog='''
2430
environment variables:
2531
AST_GREP_CONFIG Path to sgconfig.yaml file (overridden by --config flag)
2632
2733
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
34+
''',
35+
formatter_class=argparse.RawDescriptionHelpFormatter
36+
)
37+
parser.add_argument(
38+
'--config',
39+
type=str,
40+
metavar='PATH',
41+
help='Path to sgconfig.yaml file for customizing ast-grep behavior (language mappings, rule directories, etc.)'
42+
)
43+
args = parser.parse_args()
44+
45+
# Determine config path with precedence: --config flag > AST_GREP_CONFIG env > None
46+
if args.config:
47+
if not os.path.exists(args.config):
48+
print(f"Error: Config file '{args.config}' does not exist")
49+
sys.exit(1)
50+
CONFIG_PATH = args.config
51+
elif os.environ.get('AST_GREP_CONFIG'):
52+
env_config = os.environ.get('AST_GREP_CONFIG')
53+
if env_config and not os.path.exists(env_config):
54+
print(f"Error: Config file '{env_config}' specified in AST_GREP_CONFIG does not exist")
55+
sys.exit(1)
56+
CONFIG_PATH = env_config
5257

5358
# Initialize FastMCP server
5459
mcp = FastMCP("ast-grep")
@@ -62,7 +67,7 @@ class DumpFormat(Enum):
6267
def dump_syntax_tree(
6368
code: str = Field(description = "The code you need"),
6469
language: str = Field(description = "The language of the code"),
65-
format: DumpFormat = Field(description = "Code dump format. Available values: pattern, ast, cst", default = "cst"),
70+
format: DumpFormat = Field(description = "Code dump format. Available values: pattern, ast, cst", default = DumpFormat.CST),
6671
) -> str:
6772
"""
6873
Dump code's syntax structure or dump a query's pattern structure.
@@ -75,7 +80,7 @@ def dump_syntax_tree(
7580
Internally calls: ast-grep run --pattern <code> --lang <language> --debug-query=<format>
7681
"""
7782
result = run_ast_grep("run", ["--pattern", code, "--lang", language, f"--debug-query={format.value}"])
78-
return result.stderr.strip()
83+
return result.stderr.strip() # type: ignore[no-any-return]
7984

8085
@mcp.tool()
8186
def test_match_code_rule(
@@ -92,7 +97,7 @@ def test_match_code_rule(
9297
matches = json.loads(result.stdout.strip())
9398
if not matches:
9499
raise ValueError("No matches found for the given code and rule. Try adding `stopBy: end` to your inside/has rule.")
95-
return matches
100+
return matches # type: ignore[no-any-return]
96101

97102
@mcp.tool()
98103
def find_code(
@@ -131,7 +136,7 @@ def find_code(
131136
# Limit results if max_results is specified
132137
if max_results is not None and len(matches) > max_results:
133138
matches = matches[:max_results]
134-
return matches
139+
return matches # type: ignore[no-any-return]
135140
else:
136141
# Text format - return plain text output
137142
result = run_ast_grep("run", args + [project_folder])
@@ -150,7 +155,7 @@ def find_code(
150155
else:
151156
header = f"Found {len(non_empty_lines)} matches:\n"
152157
output = header + output
153-
return output
158+
return output # type: ignore[no-any-return]
154159

155160
@mcp.tool()
156161
def find_code_by_rule(
@@ -188,7 +193,7 @@ def find_code_by_rule(
188193
# Limit results if max_results is specified
189194
if max_results is not None and len(matches) > max_results:
190195
matches = matches[:max_results]
191-
return matches
196+
return matches # type: ignore[no-any-return]
192197
else:
193198
# Text format - return plain text output
194199
result = run_ast_grep("scan", args + [project_folder])
@@ -207,16 +212,21 @@ def find_code_by_rule(
207212
else:
208213
header = f"Found {len(non_empty_lines)} matches:\n"
209214
output = header + output
210-
return output
215+
return output # type: ignore[no-any-return]
211216

212217
def run_command(args: List[str], input_text: Optional[str] = None) -> subprocess.CompletedProcess:
213218
try:
219+
# On Windows, if ast-grep is installed via npm, it's a batch file
220+
# that requires shell=True to execute properly
221+
use_shell = (sys.platform == "win32" and args[0] == "ast-grep")
222+
214223
result = subprocess.run(
215224
args,
216225
capture_output=True,
217226
input=input_text,
218227
text=True,
219-
check=True # Raises CalledProcessError if return code is non-zero
228+
check=True, # Raises CalledProcessError if return code is non-zero
229+
shell=use_shell
220230
)
221231
return result
222232
except subprocess.CalledProcessError as e:
@@ -237,6 +247,7 @@ def run_mcp_server() -> None:
237247
Run the MCP server.
238248
This function is used to start the MCP server when this script is run directly.
239249
"""
250+
parse_args_and_get_config()
240251
mcp.run(transport="stdio")
241252

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