Skip to content

Commit b4164a0

Browse files
Merge pull request #300 from webtech-network/abstract-syntax-tree
Abstract syntax tree + forbidden keyword test
2 parents 79f80ac + 9af945d commit b4164a0

21 files changed

Lines changed: 803 additions & 20 deletions

autograder/autograder.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def build_pipeline(
169169
StepName.SANDBOX,
170170
StepName.PRE_FLIGHT,
171171
StepName.AI_BATCH,
172+
StepName.STRUCTURAL_ANALYSIS,
172173
StepName.GRADE,
173174
StepName.FOCUS,
174175
StepName.FEEDBACK,

autograder/models/abstract/test_function.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from abc import ABC, abstractmethod
2-
from typing import List, Optional
2+
from typing import List, Optional, Type
3+
4+
from pydantic import BaseModel
35

46
from autograder.models.dataclass.submission import SubmissionFile
57
from autograder.models.dataclass.test_result import TestResult
@@ -12,6 +14,11 @@ class TestFunction(ABC):
1214
An abstract base class for a single, executable test function.
1315
"""
1416

17+
@property
18+
def config_schema(self) -> Optional[Type[BaseModel]]:
19+
"""Optional Pydantic model to validate test parameters during tree building."""
20+
return None
21+
1522
@property
1623
@abstractmethod
1724
def name(self) -> str:

autograder/models/dataclass/step_result.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class StepName(Enum):
2828
PRE_FLIGHT = "PreFlightStep"
2929
SANDBOX = "SandboxStep"
3030
AI_BATCH = "AiBatchStep"
31+
STRUCTURAL_ANALYSIS = "StructuralAnalysisStep"
3132
GRADE = "GradeStep"
3233
FOCUS = "FocusStep"
3334
FEEDBACK = "FeedbackStep"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from dataclasses import dataclass
2+
from typing import Dict, Optional, TYPE_CHECKING
3+
4+
if TYPE_CHECKING:
5+
from ast_grep_py import SgRoot
6+
7+
@dataclass
8+
class StructuralAnalysisResult:
9+
"""
10+
Holds the results of structural analysis for a submission.
11+
12+
Attributes:
13+
roots: A dictionary mapping filenames to their corresponding ast-grep root nodes.
14+
If a file could not be parsed, the value is None.
15+
"""
16+
roots: Dict[str, Optional['SgRoot']]

autograder/models/pipeline_execution.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from autograder.models.criteria_tree import CriteriaTree
1313
from autograder.models.dataclass.focus import Focus
1414
from autograder.models.dataclass.grade_step_result import GradeStepResult
15+
from autograder.models.dataclass.structural_analysis_result import StructuralAnalysisResult
1516
from autograder.models.result_tree import ResultTree
1617
from sandbox_manager.sandbox_container import SandboxContainer
1718

@@ -116,6 +117,18 @@ def get_built_criteria_tree(self) -> "CriteriaTree":
116117
self._require_step_data(StepName.BUILD_TREE, "criteria tree"),
117118
)
118119

120+
def get_structural_analysis_result(self) -> Optional["StructuralAnalysisResult"]:
121+
"""
122+
Retrieves the StructuralAnalysisResult object if it was produced during the pipeline.
123+
"""
124+
if not self.has_step_result(StepName.STRUCTURAL_ANALYSIS):
125+
return None
126+
from autograder.models.dataclass.structural_analysis_result import StructuralAnalysisResult
127+
return cast(
128+
StructuralAnalysisResult,
129+
self._require_step_data(StepName.STRUCTURAL_ANALYSIS, "structural analysis result"),
130+
)
131+
119132
def get_sandbox(self) -> Optional["SandboxContainer"]:
120133
"""
121134
Retrieves the SandboxContainer object if it was created during the pipeline.

autograder/services/criteria_tree_service.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
from typing import List, Optional
33

4+
from pydantic import ValidationError
5+
46
from autograder.models.abstract.template import Template
57
from autograder.models.abstract.test_function import TestFunction
68
from autograder.models.config.category import CategoryConfig
@@ -99,17 +101,26 @@ def __parse_test(self, config: TestConfig) -> TestNode:
99101
raise ValueError(f"Couldn't find test function '{function_name}'")
100102

101103
file_target = [config.file] if config.file else None
104+
test_params = config.get_kwargs_dict() or {}
105+
106+
# Perform early validation if the test function provides a schema.
107+
if test_function.config_schema:
108+
try:
109+
# We use model_validate to leverage Pydantic's validation logic.
110+
test_function.config_schema(**test_params)
111+
except ValidationError as e:
112+
raise ValueError(
113+
f"Invalid parameters for test '{config.name}' ({function_name}): {e}"
114+
) from e
102115

103116
test = TestNode(
104117
config.name,
105118
test_function,
106-
config.get_kwargs_dict() or {},
119+
test_params,
107120
file_target,
108121
config.weight if config.weight is not None else 100.0,
109122
)
110123

111-
112-
113124
return test
114125

115126
def __parse_category(self, category_name, config: CategoryConfig) -> CategoryNode:

autograder/services/grader_service.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def grade_from_tree(
3434
submission_language=None,
3535
locale: str = "en",
3636
pre_computed_results: Optional[Dict[str, TestResult]] = None,
37+
structural_analysis=None,
3738
) -> ResultTree:
3839
"""Traverse the generic built criteria tree to resolve inputs, grades and report to ResultTree."""
3940
base_result = self.process_category(
@@ -43,6 +44,7 @@ def grade_from_tree(
4344
submission_language=submission_language,
4445
locale=locale,
4546
pre_computed_results=pre_computed_results,
47+
structural_analysis=structural_analysis,
4648
)
4749
root = RootResultNode(name="root", base=base_result)
4850

@@ -54,6 +56,7 @@ def grade_from_tree(
5456
submission_language=submission_language,
5557
locale=locale,
5658
pre_computed_results=pre_computed_results,
59+
structural_analysis=structural_analysis,
5760
)
5861
root.bonus = bonus_result
5962

@@ -65,6 +68,7 @@ def grade_from_tree(
6568
submission_language=submission_language,
6669
locale=locale,
6770
pre_computed_results=pre_computed_results,
71+
structural_analysis=structural_analysis,
6872
)
6973
root.penalty = penalty_result
7074

@@ -104,6 +108,7 @@ def __process_holder(
104108
submission_language=None,
105109
locale: str = "en",
106110
pre_computed_results: Optional[Dict[str, TestResult]] = None,
111+
structural_analysis=None,
107112
) -> CategoryResultNode | SubjectResultNode:
108113
"""Process a category or subject node and create corresponding result node."""
109114

@@ -128,6 +133,7 @@ def __process_holder(
128133
submission_language=submission_language,
129134
locale=locale,
130135
pre_computed_results=pre_computed_results,
136+
structural_analysis=structural_analysis,
131137
)
132138
for inner_subject in holder.subjects
133139
]
@@ -144,6 +150,7 @@ def __process_holder(
144150
submission_language=submission_language,
145151
locale=locale,
146152
pre_computed_results=pre_computed_results,
153+
structural_analysis=structural_analysis,
147154
)
148155
for test in holder.tests
149156
]
@@ -174,6 +181,7 @@ def process_subject(
174181
submission_language=None,
175182
locale: str = "en",
176183
pre_computed_results: Optional[Dict[str, TestResult]] = None,
184+
structural_analysis=None,
177185
) -> SubjectResultNode:
178186
"""Process a subject node from criteria tree and create result node."""
179187
return self.__process_holder(
@@ -183,6 +191,7 @@ def process_subject(
183191
submission_language=submission_language,
184192
locale=locale,
185193
pre_computed_results=pre_computed_results,
194+
structural_analysis=structural_analysis,
186195
)
187196

188197
def process_test(
@@ -193,6 +202,7 @@ def process_test(
193202
submission_language=None,
194203
locale: str = "en",
195204
pre_computed_results: Optional[Dict[str, TestResult]] = None,
205+
structural_analysis=None,
196206
) -> TestResultNode:
197207
"""Execute a test and create a test result node.
198208
@@ -230,6 +240,8 @@ def process_test(
230240
sandbox=sandbox,
231241
locale=locale,
232242
pre_computed_results=pre_computed_results,
243+
structural_analysis=structural_analysis,
244+
submission_language=submission_language,
233245
**test_params,
234246
)
235247
return TestResultNode(
@@ -274,6 +286,7 @@ def process_category(
274286
submission_language=None,
275287
locale: str = "en",
276288
pre_computed_results: Optional[Dict[str, TestResult]] = None,
289+
structural_analysis=None,
277290
) -> CategoryResultNode:
278291
"""Process a category node from criteria tree and create result node."""
279292
return self.__process_holder(
@@ -283,4 +296,5 @@ def process_category(
283296
submission_language=submission_language,
284297
locale=locale,
285298
pre_computed_results=pre_computed_results,
299+
structural_analysis=structural_analysis,
286300
)

autograder/steps/grade_step.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,16 @@ def _execute(self, pipeline_exec: PipelineExecution) -> PipelineExecution:
5656
if pipeline_exec.has_step_result(StepName.AI_BATCH):
5757
pre_computed_results = pipeline_exec.get_step_result(StepName.AI_BATCH).data
5858

59+
structural_analysis = pipeline_exec.get_structural_analysis_result()
60+
5961
result_tree = self._grader_service.grade_from_tree(
6062
criteria_tree=criteria_tree,
6163
submission_files=pipeline_exec.submission.submission_files,
6264
sandbox=sandbox,
6365
submission_language=pipeline_exec.submission.language,
6466
locale=pipeline_exec.locale,
6567
pre_computed_results=pre_computed_results,
68+
structural_analysis=structural_analysis,
6669
)
6770

6871
# Create grading result

autograder/steps/step_registry.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from autograder.steps.pre_flight_step import PreFlightStep
1111
from autograder.steps.sandbox_step import SandboxStep
1212
from autograder.steps.ai_batch_step import AiBatchStep
13+
from autograder.steps.structural_analysis_step import StructuralAnalysisStep
1314
from autograder.steps.grade_step import GradeStep
1415
from autograder.steps.focus_step import FocusStep
1516
from autograder.steps.feedback_step import FeedbackStep
@@ -37,6 +38,7 @@ def __init__(self, config: Dict[str, Any]):
3738
StepName.PRE_FLIGHT: self._build_pre_flight,
3839
StepName.SANDBOX: self._build_sandbox,
3940
StepName.AI_BATCH: self._build_ai_batch,
41+
StepName.STRUCTURAL_ANALYSIS: self._build_structural_analysis,
4042
StepName.GRADE: self._build_grade,
4143
StepName.FOCUS: self._build_focus,
4244
StepName.FEEDBACK: self._build_feedback,
@@ -65,6 +67,9 @@ def _build_sandbox(self) -> Optional[Step]:
6567
def _build_ai_batch(self) -> Optional[Step]:
6668
return AiBatchStep()
6769

70+
def _build_structural_analysis(self) -> Optional[Step]:
71+
return StructuralAnalysisStep()
72+
6873
def _build_grade(self) -> Optional[Step]:
6974
return GradeStep()
7075

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import logging
2+
from typing import Dict, Optional
3+
4+
from autograder.models.abstract.step import Step
5+
from autograder.models.pipeline_execution import PipelineExecution
6+
from autograder.models.dataclass.step_result import StepResult, StepName
7+
from autograder.models.dataclass.structural_analysis_result import StructuralAnalysisResult
8+
from sandbox_manager.models.sandbox_models import Language
9+
10+
logger = logging.getLogger(__name__)
11+
12+
try:
13+
from ast_grep_py import SgRoot
14+
except ImportError:
15+
SgRoot = None
16+
17+
class StructuralAnalysisStep(Step):
18+
"""
19+
Parses submission files into ast-grep SgRoot objects.
20+
This enables structural pattern matching in subsequent grading steps.
21+
"""
22+
23+
@property
24+
def step_name(self) -> StepName:
25+
return StepName.STRUCTURAL_ANALYSIS
26+
27+
def _execute(self, pipeline_exec: PipelineExecution) -> PipelineExecution:
28+
submission = pipeline_exec.submission
29+
language = submission.language
30+
31+
if not language:
32+
logger.warning("No language specified for submission; skipping structural analysis.")
33+
return pipeline_exec.add_step_result(StepResult.success(self.step_name, StructuralAnalysisResult(roots={})))
34+
35+
if SgRoot is None:
36+
logger.error("ast-grep-py is not installed; structural analysis will be skipped.")
37+
return pipeline_exec.add_step_result(StepResult.fail(self.step_name, "ast-grep-py not installed"))
38+
39+
ast_grep_lang = self._map_language(language)
40+
if not ast_grep_lang:
41+
logger.warning(f"Language {language.value} is not supported by ast-grep; skipping.")
42+
return pipeline_exec.add_step_result(StepResult.success(self.step_name, StructuralAnalysisResult(roots={})))
43+
44+
roots: Dict[str, Optional[SgRoot]] = {}
45+
for filename, sub_file in submission.submission_files.items():
46+
# Only parse files that likely contain code
47+
if not self._is_code_file(filename):
48+
continue
49+
50+
try:
51+
roots[filename] = SgRoot(sub_file.content, ast_grep_lang)
52+
except Exception as e:
53+
logger.warning(f"Failed to parse {filename} with ast-grep: {e}")
54+
roots[filename] = None
55+
56+
result = StructuralAnalysisResult(roots=roots)
57+
return pipeline_exec.add_step_result(StepResult.success(self.step_name, result))
58+
59+
def _map_language(self, language: Language) -> Optional[str]:
60+
mapping = {
61+
Language.PYTHON: "python",
62+
Language.JAVA: "java",
63+
Language.NODE: "javascript",
64+
Language.CPP: "cpp",
65+
Language.C: "c",
66+
}
67+
return mapping.get(language)
68+
69+
def _is_code_file(self, filename: str) -> bool:
70+
"""Heuristic to avoid parsing non-code files."""
71+
# Common binary/config/doc extensions to ignore
72+
ignored_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.pdf', '.zip', '.tar', '.gz', '.json', '.yaml', '.yml', '.md', '.txt'}
73+
return not any(filename.lower().endswith(ext) for ext in ignored_extensions)

0 commit comments

Comments
 (0)