diff --git a/docs/plans/2026-05-25-ast-checker.md b/docs/plans/2026-05-25-ast-checker.md
new file mode 100644
index 0000000..5d1759e
--- /dev/null
+++ b/docs/plans/2026-05-25-ast-checker.md
@@ -0,0 +1,1587 @@
+# AST Checker Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add tree-sitter-based AST checking to enforce code structure rules on student submissions (Phase 1 MVP: Python3 + C, 9 engine types).
+
+**Architecture:** New `ast_checker/` module with engine-based rule checking, integrated into `JudgeDispatcher.judge()` post-judge. AST_CHECK_FAILED (status 10) is treated as AC for all statistics. Language mappings translate logical target names to tree-sitter node types.
+
+**Tech Stack:** tree-sitter (Python bindings), tree-sitter-python, tree-sitter-c, Django 6, Vue 3 + Naive UI
+
+**Design spec:** `docs/specs/2026-05-25-ast-checker-design.md`
+
+**Testing policy:** Do not write tests (per project CLAUDE.md).
+
+---
+
+### Task 1: Add tree-sitter dependencies
+
+**Files:**
+- Modify: `pyproject.toml`
+
+- [ ] **Step 1: Install tree-sitter packages**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+uv add tree-sitter tree-sitter-python tree-sitter-c
+```
+
+- [ ] **Step 2: Verify installation**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+uv run python -c "from tree_sitter import Language, Parser; import tree_sitter_python; import tree_sitter_c; print('OK')"
+```
+
+Expected: `OK`
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add pyproject.toml uv.lock
+git commit -m "deps: add tree-sitter with Python and C grammars"
+```
+
+---
+
+### Task 2: Backend model changes
+
+**Files:**
+- Modify: `submission/models.py`
+- Modify: `problem/models.py`
+- Modify: `problem/serializers.py`
+- Create migration via `makemigrations`
+
+- [ ] **Step 1: Add AST_CHECK_FAILED status and is_accepted helper to `submission/models.py`**
+
+Add `AST_CHECK_FAILED = 10` to the `JudgeStatus` enum and add `is_accepted()` function after the class:
+
+```python
+# In JudgeStatus class, after PARTIALLY_ACCEPTED = 8:
+ AST_CHECK_FAILED = 10, "AST Check Failed"
+```
+
+```python
+# After the JudgeStatus class, before the Submission class:
+def is_accepted(result):
+ return result in (JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED)
+```
+
+- [ ] **Step 2: Add ast_rules field to Problem model in `problem/models.py`**
+
+Add after the `show_flowchart` field (line 84):
+
+```python
+ # AST 代码结构检查规则
+ ast_rules = models.JSONField(null=True, blank=True, default=None)
+```
+
+- [ ] **Step 3: Update serializers in `problem/serializers.py`**
+
+Add `ast_rules` to `CreateOrEditProblemSerializer` (after the `flowchart_hint` field, around line 87):
+
+```python
+ # AST 规则
+ ast_rules = serializers.JSONField(required=False, allow_null=True, default=None)
+```
+
+Exclude `ast_rules` from student-facing serializers. In `ProblemSerializer.Meta.exclude` (around line 143), add `"ast_rules"`:
+
+```python
+ exclude = (
+ "test_case_score",
+ "test_case_id",
+ "visible",
+ "is_public",
+ "answers",
+ "ast_rules",
+ )
+```
+
+In `ProblemSafeSerializer.Meta.exclude` (around line 189), add `"ast_rules"`:
+
+```python
+ exclude = (
+ "test_case_score",
+ "test_case_id",
+ "visible",
+ "is_public",
+ "difficulty",
+ "submission_number",
+ "accepted_number",
+ "statistic_info",
+ "answers",
+ "ast_rules",
+ )
+```
+
+`ProblemAdminSerializer` uses `fields = "__all__"` — no change needed, `ast_rules` is included automatically.
+
+- [ ] **Step 4: Create and apply migration**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+python manage.py makemigrations problem submission
+python manage.py migrate
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add submission/models.py problem/models.py problem/serializers.py problem/migrations/ submission/migrations/
+git commit -m "feat: add AST_CHECK_FAILED status, is_accepted helper, ast_rules field"
+```
+
+---
+
+### Task 3: AST checker — language mappings
+
+**Files:**
+- Create: `ast_checker/__init__.py`
+- Create: `ast_checker/mappings/__init__.py`
+- Create: `ast_checker/mappings/python.py`
+- Create: `ast_checker/mappings/c.py`
+
+- [ ] **Step 1: Create `ast_checker/__init__.py`**
+
+```python
+```
+
+(Empty file — package marker only.)
+
+- [ ] **Step 2: Create `ast_checker/mappings/python.py`**
+
+```python
+PYTHON_MAPPING = {
+ "for_loop": "for_statement",
+ "while_loop": "while_statement",
+ "if_statement": "if_statement",
+ "else_clause": "else_clause",
+ "elif_clause": "elif_clause",
+ "break": "break_statement",
+ "continue": "continue_statement",
+ "function_definition": "function_definition",
+ "return": "return_statement",
+ "try_except": "try_statement",
+ "with_statement": "with_statement",
+ "list_comprehension": "list_comprehension",
+ "list_literal": "list",
+ "dict_literal": "dictionary",
+ "set_literal": "set",
+ "f_string": "format_string",
+ "import": "import_statement",
+ "import_from": "import_from_statement",
+ "assignment": "assignment",
+ "class_definition": "class_definition",
+ "+": "+",
+ "-": "-",
+ "*": "*",
+ "/": "/",
+ "//": "//",
+ "%": "%",
+ "**": "**",
+ "+=": "+=",
+ "-=": "-=",
+ "*=": "*=",
+ "/=": "/=",
+ "%=": "%=",
+ "==": "==",
+ "!=": "!=",
+ ">": ">",
+ ">=": ">=",
+ "<": "<",
+ "<=": "<=",
+ "and": "and",
+ "or": "or",
+ "not": "not",
+ "&": "&",
+ "|": "|",
+}
+```
+
+- [ ] **Step 3: Create `ast_checker/mappings/c.py`**
+
+```python
+C_MAPPING = {
+ "for_loop": "for_statement",
+ "while_loop": "while_statement",
+ "do_while": "do_statement",
+ "if_statement": "if_statement",
+ "else_clause": "else_clause",
+ "break": "break_statement",
+ "continue": "continue_statement",
+ "function_definition": "function_definition",
+ "return": "return_statement",
+ "switch_statement": "switch_statement",
+ "case_statement": "case_statement",
+ "assignment": "assignment_expression",
+ "struct": "struct_specifier",
+ "include": "preproc_include",
+ "+": "+",
+ "-": "-",
+ "*": "*",
+ "/": "/",
+ "%": "%",
+ "+=": "+=",
+ "-=": "-=",
+ "*=": "*=",
+ "/=": "/=",
+ "%=": "%=",
+ "==": "==",
+ "!=": "!=",
+ ">": ">",
+ ">=": ">=",
+ "<": "<",
+ "<=": "<=",
+ "and": "&&",
+ "or": "||",
+ "not": "!",
+ "&": "&",
+ "|": "|",
+ "++": "++",
+ "--": "--",
+}
+```
+
+- [ ] **Step 4: Create `ast_checker/mappings/__init__.py`**
+
+```python
+from tree_sitter import Language
+
+from .c import C_MAPPING
+from .python import PYTHON_MAPPING
+
+_MAPPINGS = {
+ "Python3": PYTHON_MAPPING,
+ "C": C_MAPPING,
+}
+
+_LANGUAGES: dict[str, Language] = {}
+
+
+def _init_languages():
+ try:
+ import tree_sitter_python as tspython
+
+ _LANGUAGES["Python3"] = Language(tspython.language())
+ except ImportError:
+ pass
+ try:
+ import tree_sitter_c as tsc
+
+ _LANGUAGES["C"] = Language(tsc.language())
+ except ImportError:
+ pass
+
+
+_init_languages()
+
+
+def get_mapping(language: str) -> dict:
+ return _MAPPINGS.get(language, {})
+
+
+def get_language(language: str) -> Language | None:
+ return _LANGUAGES.get(language)
+```
+
+- [ ] **Step 5: Verify mappings load**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+uv run python -c "from ast_checker.mappings import get_mapping, get_language; print(get_language('Python3')); print(len(get_mapping('Python3')))"
+```
+
+Expected: a Language object and a number (e.g., `Language(...)` and `42`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add ast_checker/
+git commit -m "feat: add ast_checker mappings for Python3 and C"
+```
+
+---
+
+### Task 4: AST checker — engine framework and Phase 1 engines
+
+**Files:**
+- Create: `ast_checker/engines/__init__.py`
+- Create: `ast_checker/engines/base.py`
+- Create: `ast_checker/engines/node_exists.py`
+- Create: `ast_checker/engines/node_count.py`
+- Create: `ast_checker/engines/function_call.py`
+- Create: `ast_checker/engines/method_call.py`
+- Create: `ast_checker/engines/operator.py`
+
+- [ ] **Step 1: Create `ast_checker/engines/base.py`**
+
+```python
+class BaseEngine:
+ @staticmethod
+ def collect_nodes(node, node_type):
+ results = []
+ if node.type == node_type:
+ results.append(node)
+ for child in node.children:
+ results.extend(BaseEngine.collect_nodes(child, node_type))
+ return results
+
+ @staticmethod
+ def has_node(node, node_type):
+ if node.type == node_type:
+ return True
+ return any(BaseEngine.has_node(child, node_type) for child in node.children)
+
+ def check(self, tree, rule, language, mapping) -> list[str]:
+ raise NotImplementedError
+```
+
+- [ ] **Step 2: Create `ast_checker/engines/node_exists.py`**
+
+```python
+from .base import BaseEngine
+
+
+class MustExistNodeEngine(BaseEngine):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ node_type = mapping.get(target, target)
+ if not self.has_node(tree.root_node, node_type):
+ return [rule.get("message", f"必须使用 {target}")]
+ return []
+
+
+class MustNotExistNodeEngine(BaseEngine):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ node_type = mapping.get(target, target)
+ if self.has_node(tree.root_node, node_type):
+ return [rule.get("message", f"不能使用 {target}")]
+ return []
+```
+
+- [ ] **Step 3: Create `ast_checker/engines/node_count.py`**
+
+```python
+from .base import BaseEngine
+
+
+class CountNodeEngine(BaseEngine):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ node_type = mapping.get(target, target)
+ nodes = self.collect_nodes(tree.root_node, node_type)
+ count = len(nodes)
+ min_count = rule.get("min")
+ max_count = rule.get("max")
+ if min_count is not None and count < min_count:
+ return [rule.get("message", f"{target} 至少出现 {min_count} 次,当前 {count} 次")]
+ if max_count is not None and count > max_count:
+ return [rule.get("message", f"{target} 至多出现 {max_count} 次,当前 {count} 次")]
+ return []
+```
+
+- [ ] **Step 4: Create `ast_checker/engines/function_call.py`**
+
+```python
+from .base import BaseEngine
+
+CALL_NODE_TYPES = {
+ "Python3": "call",
+ "C": "call_expression",
+}
+
+
+class _FunctionCallBase(BaseEngine):
+ def _find_function_calls(self, root, func_name, language):
+ call_type = CALL_NODE_TYPES.get(language, "call")
+ calls = self.collect_nodes(root, call_type)
+ matches = []
+ for call in calls:
+ func_node = call.child_by_field_name("function")
+ if func_node and func_node.type == "identifier" and func_node.text.decode() == func_name:
+ matches.append(call)
+ return matches
+
+
+class MustCallFunctionEngine(_FunctionCallBase):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ if not self._find_function_calls(tree.root_node, target, language):
+ return [rule.get("message", f"必须调用 {target}()")]
+ return []
+
+
+class MustNotCallFunctionEngine(_FunctionCallBase):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ if self._find_function_calls(tree.root_node, target, language):
+ return [rule.get("message", f"不能调用 {target}()")]
+ return []
+
+
+class CountFunctionCallEngine(_FunctionCallBase):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ count = len(self._find_function_calls(tree.root_node, target, language))
+ min_count = rule.get("min")
+ max_count = rule.get("max")
+ if min_count is not None and count < min_count:
+ return [rule.get("message", f"{target}() 至少调用 {min_count} 次,当前 {count} 次")]
+ if max_count is not None and count > max_count:
+ return [rule.get("message", f"{target}() 至多调用 {max_count} 次,当前 {count} 次")]
+ return []
+```
+
+- [ ] **Step 5: Create `ast_checker/engines/method_call.py`**
+
+```python
+from .base import BaseEngine
+
+CALL_NODE_TYPES = {
+ "Python3": "call",
+ "C": "call_expression",
+}
+
+
+class _MethodCallBase(BaseEngine):
+ def _find_method_calls(self, root, method_name, language):
+ if language == "C":
+ return []
+ call_type = CALL_NODE_TYPES.get(language, "call")
+ calls = self.collect_nodes(root, call_type)
+ matches = []
+ for call in calls:
+ func_node = call.child_by_field_name("function")
+ if func_node and func_node.type == "attribute":
+ attr_node = func_node.child_by_field_name("attribute")
+ if attr_node and attr_node.text.decode() == method_name:
+ matches.append(call)
+ return matches
+
+
+class MustCallMethodEngine(_MethodCallBase):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ if not self._find_method_calls(tree.root_node, target, language):
+ return [rule.get("message", f"必须调用 .{target}()")]
+ return []
+
+
+class MustNotCallMethodEngine(_MethodCallBase):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ if self._find_method_calls(tree.root_node, target, language):
+ return [rule.get("message", f"不能调用 .{target}()")]
+ return []
+```
+
+- [ ] **Step 6: Create `ast_checker/engines/operator.py`**
+
+```python
+from .base import BaseEngine
+
+
+class MustUseOperatorEngine(BaseEngine):
+ def check(self, tree, rule, language, mapping):
+ target = rule["target"]
+ mapped_op = mapping.get(target, target)
+ if not self.has_node(tree.root_node, mapped_op):
+ return [rule.get("message", f"必须使用 {target} 运算符")]
+ return []
+```
+
+- [ ] **Step 7: Create `ast_checker/engines/__init__.py`**
+
+```python
+from .function_call import CountFunctionCallEngine, MustCallFunctionEngine, MustNotCallFunctionEngine
+from .method_call import MustCallMethodEngine, MustNotCallMethodEngine
+from .node_count import CountNodeEngine
+from .node_exists import MustExistNodeEngine, MustNotExistNodeEngine
+from .operator import MustUseOperatorEngine
+
+ENGINES = {
+ "must_exist_node": MustExistNodeEngine(),
+ "must_not_exist_node": MustNotExistNodeEngine(),
+ "count_node": CountNodeEngine(),
+ "must_call_function": MustCallFunctionEngine(),
+ "must_not_call_function": MustNotCallFunctionEngine(),
+ "count_function_call": CountFunctionCallEngine(),
+ "must_call_method": MustCallMethodEngine(),
+ "must_not_call_method": MustNotCallMethodEngine(),
+ "must_use_operator": MustUseOperatorEngine(),
+}
+
+
+def get_engine(name: str):
+ return ENGINES.get(name)
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add ast_checker/engines/
+git commit -m "feat: add AST checker engine framework with 9 Phase 1 engines"
+```
+
+---
+
+### Task 5: AST checker — entry point
+
+**Files:**
+- Create: `ast_checker/checker.py`
+
+- [ ] **Step 1: Create `ast_checker/checker.py`**
+
+```python
+from tree_sitter import Parser
+
+from .engines import get_engine
+from .mappings import get_language, get_mapping
+
+
+def check_ast(code: str, language: str, rules: list[dict]) -> tuple[bool, list[str]]:
+ if not rules:
+ return True, []
+
+ ts_language = get_language(language)
+ if ts_language is None:
+ return True, []
+
+ mapping = get_mapping(language)
+
+ try:
+ parser = Parser(ts_language)
+ tree = parser.parse(code.encode("utf-8"))
+ except Exception:
+ return True, []
+
+ errors = []
+ for rule in rules:
+ engine = get_engine(rule.get("engine", ""))
+ if engine is None:
+ continue
+ rule_errors = engine.check(tree, rule, language, mapping)
+ errors.extend(rule_errors)
+
+ return len(errors) == 0, errors
+```
+
+- [ ] **Step 2: Smoke test**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+uv run python -c "
+from ast_checker.checker import check_ast
+# Should pass: code has a for loop
+ok, errs = check_ast('for i in range(10): print(i)', 'Python3', [{'engine': 'must_exist_node', 'target': 'for_loop', 'message': '必须使用 for'}])
+print('pass' if ok else 'fail', errs)
+# Should fail: code has no while loop
+ok, errs = check_ast('for i in range(10): print(i)', 'Python3', [{'engine': 'must_exist_node', 'target': 'while_loop', 'message': '必须使用 while'}])
+print('pass' if ok else 'fail', errs)
+"
+```
+
+Expected:
+```
+pass []
+fail ['必须使用 while']
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add ast_checker/checker.py
+git commit -m "feat: add check_ast entry point"
+```
+
+---
+
+### Task 6: JudgeDispatcher — AST check and statistics updates
+
+**Files:**
+- Modify: `judge/dispatcher.py`
+
+This task modifies 5 methods in `judge/dispatcher.py`. Read the file first, then apply changes method by method.
+
+**Critical invariant:** `is_accepted()` treats both `ACCEPTED` and `AST_CHECK_FAILED` as accepted. When storing status in profile dicts (`acm_problems_status`, `oi_problems_status`, `contest_problems_status`), always store `JudgeStatus.ACCEPTED` (0), never `AST_CHECK_FAILED` (10).
+
+- [ ] **Step 1: Update imports in `judge/dispatcher.py`**
+
+Change the import line:
+
+```python
+from submission.models import JudgeStatus, Submission
+```
+
+to:
+
+```python
+from submission.models import JudgeStatus, Submission, is_accepted
+```
+
+- [ ] **Step 2: Insert AST check in `judge()` method**
+
+In the `judge()` method, find the block that determines the submission result (around lines 204-209):
+
+```python
+ if not error_test_case:
+ self.submission.result = JudgeStatus.ACCEPTED
+ elif self.problem.rule_type == ProblemRuleType.ACM or len(error_test_case) == len(resp["data"]):
+ self.submission.result = error_test_case[0]["result"]
+ else:
+ self.submission.result = JudgeStatus.PARTIALLY_ACCEPTED
+```
+
+Insert AST check **after** this block and **before** `self.submission.save(...)` (line 210):
+
+```python
+ if self.submission.result == JudgeStatus.ACCEPTED:
+ ast_rules = self.problem.ast_rules
+ if ast_rules and language in ast_rules:
+ from ast_checker.checker import check_ast
+
+ passed, errors = check_ast(self.submission.code, language, ast_rules[language])
+ if not passed:
+ self.submission.result = JudgeStatus.AST_CHECK_FAILED
+ self.submission.statistic_info["err_info"] = "\n".join(errors)
+```
+
+- [ ] **Step 3: Update `update_problem_status_rejudge()` method**
+
+Replace the entire method body. Key changes:
+- Line 254: `self.last_result != JudgeStatus.ACCEPTED and self.submission.result == JudgeStatus.ACCEPTED` → `not is_accepted(self.last_result) and is_accepted(self.submission.result)`
+- Line 264: `!= JudgeStatus.ACCEPTED` → `not is_accepted(...)`
+- Line 265: store `JudgeStatus.ACCEPTED` when `is_accepted()`
+- Line 266: `== JudgeStatus.ACCEPTED` → `is_accepted()`
+- Lines 274, 279, 280: same pattern for OI mode
+
+```python
+ def update_problem_status_rejudge(self):
+ result = str(self.submission.result)
+ problem_id = str(self.problem.id)
+ with transaction.atomic():
+ # update problem status
+ problem = Problem.objects.select_for_update().get(contest_id=self.contest_id, id=self.problem.id)
+ if not is_accepted(self.last_result) and is_accepted(self.submission.result):
+ problem.accepted_number = F("accepted_number") + 1
+ problem_info = problem.statistic_info
+ problem_info[self.last_result] = problem_info.get(self.last_result, 1) - 1
+ problem_info[result] = problem_info.get(result, 0) + 1
+ problem.save(update_fields=["accepted_number", "statistic_info"])
+
+ profile = User.objects.select_for_update().get(id=self.submission.user_id).userprofile
+ if problem.rule_type == ProblemRuleType.ACM:
+ acm_problems_status = profile.acm_problems_status.get("problems", {})
+ if not is_accepted(acm_problems_status[problem_id]["status"]):
+ acm_problems_status[problem_id]["status"] = JudgeStatus.ACCEPTED if is_accepted(self.submission.result) else self.submission.result
+ if is_accepted(self.submission.result):
+ profile.accepted_number += 1
+ profile.acm_problems_status["problems"] = acm_problems_status
+ profile.save(update_fields=["accepted_number", "acm_problems_status"])
+
+ else:
+ oi_problems_status = profile.oi_problems_status.get("problems", {})
+ score = self.submission.statistic_info["score"]
+ if not is_accepted(oi_problems_status[problem_id]["status"]):
+ # minus last time score, add this tim score
+ profile.add_score(this_time_score=score,
+ last_time_score=oi_problems_status[problem_id]["score"])
+ oi_problems_status[problem_id]["score"] = score
+ oi_problems_status[problem_id]["status"] = JudgeStatus.ACCEPTED if is_accepted(self.submission.result) else self.submission.result
+ if is_accepted(self.submission.result):
+ profile.accepted_number += 1
+ profile.oi_problems_status["problems"] = oi_problems_status
+ profile.save(update_fields=["accepted_number", "oi_problems_status"])
+```
+
+- [ ] **Step 4: Update `update_problem_status()` method**
+
+Replace the entire method body. Key changes:
+- Line 292: `== JudgeStatus.ACCEPTED` → `is_accepted()`
+- Lines 305, 306, 309, 310, 311: store `JudgeStatus.ACCEPTED` when `is_accepted()`, use `is_accepted()` for comparisons
+- Lines 320, 323, 325, 330, 331: same for OI mode
+
+```python
+ def update_problem_status(self):
+ result = str(self.submission.result)
+ problem_id = str(self.problem.id)
+ with transaction.atomic():
+ # update problem status
+ problem = Problem.objects.select_for_update().get(contest_id=self.contest_id, id=self.problem.id)
+ problem.submission_number = F("submission_number") + 1
+ if is_accepted(self.submission.result):
+ problem.accepted_number = F("accepted_number") + 1
+ problem_info = problem.statistic_info
+ problem_info[result] = problem_info.get(result, 0) + 1
+ problem.save(update_fields=["accepted_number", "submission_number", "statistic_info"])
+
+ # update_userprofile
+ user = User.objects.select_for_update().get(id=self.submission.user_id)
+ user_profile = user.userprofile
+ user_profile.submission_number = F("submission_number") + 1
+ profile_status = JudgeStatus.ACCEPTED if is_accepted(self.submission.result) else self.submission.result
+ if problem.rule_type == ProblemRuleType.ACM:
+ acm_problems_status = user_profile.acm_problems_status.get("problems", {})
+ if problem_id not in acm_problems_status:
+ acm_problems_status[problem_id] = {"status": profile_status, "_id": self.problem._id}
+ if is_accepted(self.submission.result):
+ user_profile.accepted_number += 1
+ elif not is_accepted(acm_problems_status[problem_id]["status"]):
+ acm_problems_status[problem_id]["status"] = profile_status
+ if is_accepted(self.submission.result):
+ user_profile.accepted_number += 1
+ user_profile.acm_problems_status["problems"] = acm_problems_status
+ user_profile.save(update_fields=["submission_number", "accepted_number", "acm_problems_status"])
+
+ else:
+ oi_problems_status = user_profile.oi_problems_status.get("problems", {})
+ score = self.submission.statistic_info["score"]
+ if problem_id not in oi_problems_status:
+ user_profile.add_score(score)
+ oi_problems_status[problem_id] = {"status": profile_status,
+ "_id": self.problem._id,
+ "score": score}
+ if is_accepted(self.submission.result):
+ user_profile.accepted_number += 1
+ elif not is_accepted(oi_problems_status[problem_id]["status"]):
+ # minus last time score, add this time score
+ user_profile.add_score(this_time_score=score,
+ last_time_score=oi_problems_status[problem_id]["score"])
+ oi_problems_status[problem_id]["score"] = score
+ oi_problems_status[problem_id]["status"] = profile_status
+ if is_accepted(self.submission.result):
+ user_profile.accepted_number += 1
+ user_profile.oi_problems_status["problems"] = oi_problems_status
+ user_profile.save(update_fields=["submission_number", "accepted_number", "oi_problems_status"])
+```
+
+- [ ] **Step 5: Update `update_contest_problem_status()` method**
+
+Replace the entire method body. Key changes:
+- Lines 344-346: store `JudgeStatus.ACCEPTED` when `is_accepted()`, use `is_accepted()` for comparison
+- Lines 357, 362: same for OI mode
+- Line 371: `== JudgeStatus.ACCEPTED` → `is_accepted()`
+
+```python
+ def update_contest_problem_status(self):
+ with transaction.atomic():
+ user = User.objects.select_for_update().get(id=self.submission.user_id)
+ user_profile = user.userprofile
+ problem_id = str(self.problem.id)
+ profile_status = JudgeStatus.ACCEPTED if is_accepted(self.submission.result) else self.submission.result
+ if self.contest.rule_type == ContestRuleType.ACM:
+ contest_problems_status = user_profile.acm_problems_status.get("contest_problems", {})
+ if problem_id not in contest_problems_status:
+ contest_problems_status[problem_id] = {"status": profile_status, "_id": self.problem._id}
+ elif not is_accepted(contest_problems_status[problem_id]["status"]):
+ contest_problems_status[problem_id]["status"] = profile_status
+ else:
+ # 如果已AC, 直接跳过 不计入任何计数器
+ return
+ user_profile.acm_problems_status["contest_problems"] = contest_problems_status
+ user_profile.save(update_fields=["acm_problems_status"])
+
+ elif self.contest.rule_type == ContestRuleType.OI:
+ contest_problems_status = user_profile.oi_problems_status.get("contest_problems", {})
+ score = self.submission.statistic_info["score"]
+ if problem_id not in contest_problems_status:
+ contest_problems_status[problem_id] = {"status": profile_status,
+ "_id": self.problem._id,
+ "score": score}
+ else:
+ contest_problems_status[problem_id]["score"] = score
+ contest_problems_status[problem_id]["status"] = profile_status
+ user_profile.oi_problems_status["contest_problems"] = contest_problems_status
+ user_profile.save(update_fields=["oi_problems_status"])
+
+ problem = Problem.objects.select_for_update().get(contest_id=self.contest_id, id=self.problem.id)
+ result = str(self.submission.result)
+ problem_info = problem.statistic_info
+ problem_info[result] = problem_info.get(result, 0) + 1
+ problem.submission_number = F("submission_number") + 1
+ if is_accepted(self.submission.result):
+ problem.accepted_number = F("accepted_number") + 1
+ problem.save(update_fields=["submission_number", "accepted_number", "statistic_info"])
+```
+
+- [ ] **Step 6: Update `_update_acm_contest_rank()` method**
+
+Replace the entire method body. Key changes:
+- Lines 409, 424: `== JudgeStatus.ACCEPTED` → `is_accepted()`
+
+```python
+ def _update_acm_contest_rank(self, rank):
+ info = rank.submission_info.get(str(self.submission.problem_id))
+ # 因前面更改过,这里需要重新获取
+ problem = Problem.objects.select_for_update().get(contest_id=self.contest_id, id=self.problem.id)
+ # 此题提交过
+ if info:
+ if info["is_ac"]:
+ return
+
+ rank.submission_number += 1
+ if is_accepted(self.submission.result):
+ rank.accepted_number += 1
+ info["is_ac"] = True
+ info["ac_time"] = (self.submission.create_time - self.contest.start_time).total_seconds()
+ rank.total_time += info["ac_time"] + info["error_number"] * 20 * 60
+
+ if problem.accepted_number == 1:
+ info["is_first_ac"] = True
+ elif self.submission.result != JudgeStatus.COMPILE_ERROR:
+ info["error_number"] += 1
+
+ # 第一次提交
+ else:
+ rank.submission_number += 1
+ info = {"is_ac": False, "ac_time": 0, "error_number": 0, "is_first_ac": False}
+ if is_accepted(self.submission.result):
+ rank.accepted_number += 1
+ info["is_ac"] = True
+ info["ac_time"] = (self.submission.create_time - self.contest.start_time).total_seconds()
+ rank.total_time += info["ac_time"]
+
+ if problem.accepted_number == 1:
+ info["is_first_ac"] = True
+
+ elif self.submission.result != JudgeStatus.COMPILE_ERROR:
+ info["error_number"] = 1
+ rank.submission_info[str(self.submission.problem_id)] = info
+ rank.save(update_fields=["submission_info", "total_time", "accepted_number", "submission_number"])
+```
+
+`_update_oi_contest_rank()`: **NO CHANGE** — uses `statistic_info["score"]` set before AST check.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add judge/dispatcher.py
+git commit -m "feat: integrate AST check into JudgeDispatcher with is_accepted() statistics"
+```
+
+---
+
+### Task 7: Other backend query filter updates
+
+**Files:**
+- Modify: `account/views/oj.py`
+- Modify: `comment/views/oj.py`
+- Modify: `contest/views/admin.py`
+- Modify: `problem/views/oj.py`
+- Modify: `problem/views/admin.py`
+- Modify: `problemset/views/oj.py`
+- Modify: `problemset/management/commands/fix_problemset_progress.py`
+- Modify: `class_pk/views/oj.py`
+- Modify: `submission/views/admin.py`
+
+Every `result=JudgeStatus.ACCEPTED` query filter becomes `result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]`. Every `result != JudgeStatus.ACCEPTED` comparison becomes `not is_accepted(result)`.
+
+Read each file before editing to verify line numbers.
+
+- [ ] **Step 1: Update `account/views/oj.py`**
+
+Add import at top:
+
+```python
+from submission.models import JudgeStatus, Submission, is_accepted
+```
+
+(If `JudgeStatus` and `Submission` are already imported, just add `is_accepted`.)
+
+Line 468 — change:
+```python
+result=JudgeStatus.ACCEPTED,
+```
+to:
+```python
+result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
+```
+
+Line 483 — change:
+```python
+submissions = Submission.objects.filter(problem=problem, result=JudgeStatus.ACCEPTED)
+```
+to:
+```python
+submissions = Submission.objects.filter(problem=problem, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
+```
+
+- [ ] **Step 2: Update `comment/views/oj.py`**
+
+Line 31 — change:
+```python
+result=JudgeStatus.ACCEPTED,
+```
+to:
+```python
+result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
+```
+
+- [ ] **Step 3: Update `contest/views/admin.py`**
+
+Line 220 — change:
+```python
+submissions = Submission.objects.filter(contest=contest, result=JudgeStatus.ACCEPTED).order_by("-create_time")
+```
+to:
+```python
+submissions = Submission.objects.filter(contest=contest, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]).order_by("-create_time")
+```
+
+- [ ] **Step 4: Update `problem/views/oj.py`**
+
+Line 199 — change:
+```python
+result=JudgeStatus.ACCEPTED,
+```
+to:
+```python
+result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
+```
+
+Line 210 — change:
+```python
+result=JudgeStatus.ACCEPTED,
+```
+to:
+```python
+result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
+```
+
+Line 313 (in `ProblemYearlyACRateAPI`) — change:
+```python
+accepted=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
+```
+to:
+```python
+accepted=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
+```
+
+Line 241 (in `SimilarProblemAPI`): **NO CHANGE** — profile stores `ACCEPTED` (0).
+
+- [ ] **Step 5: Update `problem/views/admin.py`**
+
+Line 530 (in `StuckProblemsAPI`) — change:
+```python
+accepted=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
+```
+to:
+```python
+accepted=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
+```
+
+Line 596 (in `TopACTrendAPI`) — change:
+```python
+accepted=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
+```
+to:
+```python
+accepted=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
+```
+
+Lines 444, 472: **NO CHANGE** — these are full resets (`accepted_number = 0`).
+
+- [ ] **Step 6: Update `problemset/views/oj.py`**
+
+Add import:
+```python
+from submission.models import JudgeStatus, Submission, is_accepted
+```
+
+Line 190 — change:
+```python
+if submission.result != JudgeStatus.ACCEPTED:
+```
+to:
+```python
+if not is_accepted(submission.result):
+```
+
+- [ ] **Step 7: Update `problemset/management/commands/fix_problemset_progress.py`**
+
+Line 41 — change:
+```python
+result=JudgeStatus.ACCEPTED,
+```
+to:
+```python
+result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED],
+```
+
+- [ ] **Step 8: Update `class_pk/views/oj.py`**
+
+Line 280 — change:
+```python
+submissions.filter(result=JudgeStatus.ACCEPTED)
+```
+to:
+```python
+submissions.filter(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
+```
+
+Line 291 — change:
+```python
+submissions.filter(user_id=user_id, result=JudgeStatus.ACCEPTED)
+```
+to:
+```python
+submissions.filter(user_id=user_id, result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])
+```
+
+- [ ] **Step 9: Update `submission/views/admin.py`**
+
+Line 81 — change:
+```python
+accepted_count=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
+```
+to:
+```python
+accepted_count=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
+```
+
+Line 94 — change:
+```python
+accepted_count=Count("id", filter=Q(result=JudgeStatus.ACCEPTED)),
+```
+to:
+```python
+accepted_count=Count("id", filter=Q(result__in=[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED])),
+```
+
+- [ ] **Step 10: Verify no remaining raw `result=JudgeStatus.ACCEPTED` queries**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+grep -rn "result=JudgeStatus.ACCEPTED" --include="*.py" | grep -v migrations | grep -v __pycache__
+```
+
+Expected: Only the two NO-CHANGE lines in `judge/dispatcher.py` (lines 106 and 205) should remain.
+
+- [ ] **Step 11: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/OnlineJudge
+git add account/views/oj.py comment/views/oj.py contest/views/admin.py problem/views/oj.py problem/views/admin.py problemset/views/oj.py problemset/management/commands/fix_problemset_progress.py class_pk/views/oj.py submission/views/admin.py
+git commit -m "feat: update all query filters to treat AST_CHECK_FAILED as accepted"
+```
+
+---
+
+### Task 8: Frontend — status codes, types, and result display
+
+**Files:**
+- Modify: `ojnext/src/utils/constants.ts`
+- Modify: `ojnext/src/utils/types.ts`
+- Modify: `ojnext/src/oj/problem/components/SubmissionResult.vue`
+- Modify: `ojnext/src/oj/problem/components/SubmitCode.vue`
+
+- [ ] **Step 1: Add status code to `constants.ts`**
+
+In the `SubmissionStatus` enum, after `submitting = 9,` add:
+
+```typescript
+ ast_check_failed = 10,
+```
+
+In the `JUDGE_STATUS` object, after the `"9"` entry add:
+
+```typescript
+ "10": {
+ name: "代码检查未通过",
+ type: "warning",
+ },
+```
+
+- [ ] **Step 2: Update `types.ts`**
+
+Line 68 — change:
+
+```typescript
+export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
+```
+
+to:
+
+```typescript
+export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
+```
+
+Add `ast_rules` to the `Problem` interface. After `show_flowchart?: boolean` (around line 139), add:
+
+```typescript
+ ast_rules?: { [key: string]: { engine: string; target?: string; min?: number; max?: number; message: string }[] } | null
+```
+
+- [ ] **Step 3: Update `SubmissionResult.vue` — error message display**
+
+In the `msg` computed (around lines 36-38), add a case for `ast_check_failed` before the `err_info` check:
+
+```typescript
+ if (result === SubmissionStatus.ast_check_failed) {
+ msg += "你的答案是正确的,但是代码结构不符合要求:\n\n"
+ }
+```
+
+The full block becomes:
+
+```typescript
+ if (
+ result === SubmissionStatus.compile_error ||
+ result === SubmissionStatus.runtime_error
+ ) {
+ msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
+ }
+
+ if (result === SubmissionStatus.ast_check_failed) {
+ msg += "你的答案是正确的,但是代码结构不符合要求:\n\n"
+ }
+
+ if (props.submission.statistic_info?.err_info) {
+ msg += props.submission.statistic_info.err_info
+ }
+```
+
+- [ ] **Step 4: Update `SubmissionResult.vue` — test case table**
+
+In `infoTable` computed (around lines 109-114), add `ast_check_failed` to the conditions that return `[]`:
+
+```typescript
+ if (
+ result === SubmissionStatus.accepted ||
+ result === SubmissionStatus.compile_error ||
+ result === SubmissionStatus.runtime_error ||
+ result === SubmissionStatus.ast_check_failed
+ ) {
+ return []
+ }
+```
+
+- [ ] **Step 5: Update `SubmissionResult.vue` — hide AI hints for AST failures**
+
+In `showAIHint` computed (around line 55), add exclusion:
+
+```typescript
+ props.submission.result !== SubmissionStatus.ast_check_failed &&
+```
+
+The full block becomes:
+
+```typescript
+const showAIHint = computed(() => {
+ if (!props.submission) return false
+ return (
+ problemStore.failCount >= 3 &&
+ props.submission.result !== SubmissionStatus.accepted &&
+ props.submission.result !== SubmissionStatus.ast_check_failed &&
+ props.submission.result !== SubmissionStatus.pending &&
+ props.submission.result !== SubmissionStatus.judging &&
+ props.submission.result !== SubmissionStatus.submitting
+ )
+})
+```
+
+- [ ] **Step 6: Update `SubmitCode.vue` — fail count watcher**
+
+In the fail count watcher (around line 152), change:
+
+```typescript
+ if (result !== SubmissionStatus.accepted) {
+ problemStore.incrementFailCount()
+ }
+```
+
+to:
+
+```typescript
+ if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) {
+ problemStore.incrementFailCount()
+ }
+```
+
+- [ ] **Step 7: Update `SubmitCode.vue` — AC celebration watcher**
+
+In the AC celebration watcher (around lines 162-189), change the guard and add an early return for AST failures after updating status:
+
+```typescript
+watch(
+ () => submission.value?.result,
+ async (result) => {
+ if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) return
+
+ // 1. 刷新题目状态
+ problem.value!.my_status = 0
+
+ // 2. 创建ProblemSetSubmission记录,更新题单进度
+ if (problemSetId) {
+ await updateProblemSetProgress(
+ Number(problemSetId),
+ problem.value!.id,
+ submission.value!.id,
+ )
+ }
+
+ if (result !== SubmissionStatus.accepted) return
+
+ // 3. 放烟花
+ celebrate()
+
+ // 4. 显示评价框
+ if (!contestID && !problemSetId) {
+ showCommentPanelDelayed()
+ }
+
+ if (problemSetId) {
+ // 延迟回到题单页面
+ goToProblemSetDelayed()
+ }
+ },
+)
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /home/xuyue/Projects/OJ/ojnext
+git add src/utils/constants.ts src/utils/types.ts src/oj/problem/components/SubmissionResult.vue src/oj/problem/components/SubmitCode.vue
+git commit -m "feat: add AST_CHECK_FAILED status and update result display"
+```
+
+---
+
+### Task 9: Frontend — admin UI for AST rules
+
+**Files:**
+- Create: `ojnext/src/admin/problem/components/AstRulesEditor.vue`
+- Modify: `ojnext/src/admin/problem/detail.vue`
+
+- [ ] **Step 1: Create `AstRulesEditor.vue`**
+
+```vue
+
+
+
+