# 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 ``` - [ ] **Step 2: Integrate `AstRulesEditor` into `detail.vue`** Add import (around line 5, with the other component imports): ```typescript import AstRulesEditor from "./components/AstRulesEditor.vue" ``` Add `ast_rules` to the `problem` default storage object (around line 89, after `show_flowchart: false,`): ```typescript ast_rules: null as { [key: string]: any[] } | null, ``` In `getProblemDetail()`, add `ast_rules` loading (after the flowchart fields, around line 178): ```typescript problem.value.ast_rules = data.ast_rules ?? null ``` In the template, add the `AstRulesEditor` component. Place it after the code area section and before the test case area section (around line 646, before ``). Find this divider: ```html

测试用例区域

``` Insert before that divider: ```html

测试用例区域

``` - [ ] **Step 3: Start dev server and test** ```bash cd /home/xuyue/Projects/OJ/ojnext npm start ``` Open the admin problem edit page in a browser. Verify: 1. "代码规则检查" collapsible section appears between code area and test case area 2. Expanding it shows language tabs matching the selected languages 3. "添加规则" button adds a rule row 4. Engine dropdown shows grouped options 5. Target field changes based on engine type (dropdown for nodes, input for functions, dropdown for operators) 6. Min/max fields appear only for count engines 7. Delete button removes a rule 8. Switching language tabs shows per-language rules - [ ] **Step 4: Commit** ```bash cd /home/xuyue/Projects/OJ/ojnext git add src/admin/problem/components/AstRulesEditor.vue src/admin/problem/detail.vue git commit -m "feat: add admin UI for AST rule configuration" ``` --- ## End-to-End Verification After all tasks are complete, verify the full flow: 1. **Admin**: Edit a problem, add AST rules (e.g., Python3: must_exist_node → for_loop) 2. **Submit**: Submit a Python3 solution that passes all test cases but uses `while` instead of `for` 3. **Expected**: Submission result shows "代码检查未通过" with error "必须使用 for 循环" 4. **Statistics**: Problem `accepted_number` increases, user profile shows the problem as solved (green AC icon), `statistic_info` has a `"10"` entry 5. **Submit again**: Submit with `for` loop — should show "答案正确" with confetti