Skip to content

Commit 8346c72

Browse files
committed
Address Copilot review feedback
1 parent 8b64d0c commit 8346c72

7 files changed

Lines changed: 200 additions & 13 deletions

File tree

scripts/workflow_guard.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def pre_tool(payload: dict[str, Any]) -> int:
136136
"checker_build": "必须先通过 stress_test_run(completed_rounds == total_rounds),再构建 checker。",
137137
"problem_validate": "必须先通过 stress_test_run(completed_rounds == total_rounds),再进行验证。",
138138
"problem_generate_tests": "必须先通过 problem_validate(验证通过),才能生成最终测试数据。",
139-
"problem_pack_polygon": "必须先生成最终测试数据,并且生成数量 > 0,再进行打包。",
139+
"problem_pack_polygon": "必须先生成最终测试数据并通过 problem_verify_tests(passed),再进行打包。",
140140
}
141141

142142
tool_input = payload.get("tool_input", {})
@@ -185,6 +185,7 @@ def pre_tool(payload: dict[str, Any]) -> int:
185185

186186
if short_name == "problem_pack_polygon" and not (
187187
state["tests_generated"] and state.get("generated_test_count", 0) > 0
188+
and state.get("tests_verified", False)
188189
):
189190
deny(reasons["problem_pack_polygon"])
190191
return 0
@@ -210,6 +211,12 @@ def post_tool(payload: dict[str, Any]) -> int:
210211
save_state(problem_dir, state)
211212
return 0
212213

214+
if short_name == "problem_verify_tests" and not success:
215+
state = load_state(problem_dir)
216+
state["tests_verified"] = False
217+
save_state(problem_dir, state)
218+
return 0
219+
213220
if not success:
214221
return 0
215222

@@ -245,6 +252,9 @@ def post_tool(payload: dict[str, Any]) -> int:
245252
generated_tests = data.get("generated_tests", [])
246253
state["tests_generated"] = bool(generated_tests)
247254
state["generated_test_count"] = len(generated_tests)
255+
state["tests_verified"] = False
256+
elif short_name == "problem_verify_tests":
257+
state["tests_verified"] = bool(data.get("passed", False))
248258
elif short_name == "problem_pack_polygon":
249259
state["packaged"] = True
250260

src/autocode_mcp/tools/problem.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -493,31 +493,37 @@ def _resolve_tests_dir(
493493
output_dir: str | None,
494494
) -> tuple[str | None, ToolResult | None]:
495495
"""解析并校验测试输出目录,防止清理时误删题目文件或外部目录。"""
496-
problem_root = os.path.abspath(problem_dir)
496+
problem_root = os.path.realpath(problem_dir)
497497
raw_output_dir = output_dir or "tests"
498498
tests_dir = raw_output_dir
499499
if not os.path.isabs(tests_dir):
500500
tests_dir = os.path.join(problem_root, tests_dir)
501501
tests_dir = os.path.abspath(tests_dir)
502+
resolved_tests_dir = os.path.realpath(tests_dir)
502503

503504
try:
504-
common = os.path.commonpath([problem_root, tests_dir])
505+
common = os.path.commonpath([problem_root, resolved_tests_dir])
505506
except ValueError:
506507
common = ""
507508
if os.path.normcase(common) != os.path.normcase(problem_root):
508509
return None, ToolResult.fail("output_dir must be inside problem_dir")
509510

510-
if os.path.normcase(tests_dir) == os.path.normcase(problem_root):
511+
if os.path.normcase(resolved_tests_dir) == os.path.normcase(problem_root):
511512
return None, ToolResult.fail("output_dir cannot be the problem_dir root")
512513

513514
reserved_dirs = {"files", "solutions", "statements"}
514515
for reserved in reserved_dirs:
515-
reserved_path = os.path.join(problem_root, reserved)
516-
if os.path.normcase(os.path.commonpath([reserved_path, tests_dir])) == os.path.normcase(
517-
reserved_path
518-
):
516+
reserved_path = os.path.realpath(os.path.join(problem_root, reserved))
517+
try:
518+
reserved_common = os.path.commonpath([reserved_path, resolved_tests_dir])
519+
except ValueError:
520+
reserved_common = ""
521+
if os.path.normcase(reserved_common) == os.path.normcase(reserved_path):
519522
return None, ToolResult.fail(f"output_dir cannot be reserved directory: {reserved}")
520523

524+
if os.path.exists(tests_dir) and os.path.islink(tests_dir):
525+
return None, ToolResult.fail(f"output_dir cannot be a symlink: {tests_dir}")
526+
521527
if os.path.exists(tests_dir) and not os.path.isdir(tests_dir):
522528
return None, ToolResult.fail(f"output_dir exists and is not a directory: {tests_dir}")
523529

src/autocode_mcp/tools/solution.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import os
6+
import shutil
67
from typing import Literal
78

89
from ..utils.platform import get_exe_extension
@@ -97,11 +98,15 @@ async def execute(
9798
exe_ext = get_exe_extension()
9899
canonical_path = os.path.join(solutions_dir, f"{effective_name}.cpp")
99100
binary_path = os.path.join(solutions_dir, f"{effective_name}{exe_ext}")
101+
standard_source_path = os.path.join(solutions_dir, f"{solution_type}.cpp")
102+
standard_binary_path = os.path.join(solutions_dir, f"{solution_type}{exe_ext}")
100103

101-
# 保存到标准位置(其他工具依赖此路径)
104+
# 保存自定义命名文件,并保留 sol.cpp/brute.cpp 供打包和默认流程使用。
102105
try:
103106
with open(canonical_path, "w", encoding="utf-8") as f:
104107
f.write(resolved.code)
108+
if os.path.normcase(canonical_path) != os.path.normcase(standard_source_path):
109+
shutil.copy2(canonical_path, standard_source_path)
105110
except Exception as e:
106111
return ToolResult.fail(f"Failed to save code: {str(e)}")
107112

@@ -119,11 +124,18 @@ async def execute(
119124
)
120125

121126
binary_size = os.path.getsize(binary_path) if os.path.exists(binary_path) else 0
127+
if os.path.normcase(binary_path) != os.path.normcase(standard_binary_path):
128+
try:
129+
shutil.copy2(binary_path, standard_binary_path)
130+
except Exception as e:
131+
return ToolResult.fail(f"Failed to save standard binary: {str(e)}")
122132

123133
return ToolResult.ok(
124134
source_path=compile_source,
125135
canonical_path=canonical_path,
136+
standard_source_path=standard_source_path,
126137
binary_path=binary_path,
138+
standard_binary_path=standard_binary_path,
127139
binary_size=binary_size,
128140
compile_log=result.stderr,
129141
effective_name=effective_name,

src/autocode_mcp/tools/test_verify.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,13 @@ def _check_file_count(self, tests_dir: str) -> dict:
182182

183183
non_numeric = [f for f in in_files if not Path(f).stem.isdigit()]
184184
numeric_indices = sorted(int(Path(f).stem) for f in in_files if Path(f).stem.isdigit())
185-
expected_indices = list(range(1, len(numeric_indices) + 1))
185+
numeric_index_set = set(numeric_indices)
186+
expected_indices = list(range(1, max(numeric_indices) + 1)) if numeric_indices else []
186187
missing_indices = [
187-
idx for idx in expected_indices if idx not in numeric_indices
188-
] if numeric_indices else []
188+
idx for idx in expected_indices if idx not in numeric_index_set
189+
]
189190
duplicate_indices = sorted(
190-
idx for idx in set(numeric_indices) if numeric_indices.count(idx) > 1
191+
idx for idx in numeric_index_set if numeric_indices.count(idx) > 1
191192
)
192193

193194
passed = (

tests/test_tools/test_problem.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,30 @@ async def test_problem_generate_tests_rejects_unsafe_output_dir():
324324
assert "output_dir must be inside problem_dir" in result.error
325325

326326

327+
@pytest.mark.asyncio
328+
async def test_problem_generate_tests_rejects_symlinked_output_dir():
329+
"""测试拒绝指向题目目录外部的符号链接输出目录。"""
330+
tool = ProblemGenerateTestsTool()
331+
332+
with tempfile.TemporaryDirectory() as tmpdir:
333+
problem_dir = os.path.join(tmpdir, "symlink_output")
334+
outside_dir = os.path.join(tmpdir, "outside_tests")
335+
link_dir = os.path.join(problem_dir, "tests_link")
336+
os.makedirs(os.path.join(problem_dir, "files"))
337+
os.makedirs(os.path.join(problem_dir, "solutions"))
338+
os.makedirs(outside_dir)
339+
340+
try:
341+
os.symlink(outside_dir, link_dir, target_is_directory=True)
342+
except (OSError, NotImplementedError):
343+
pytest.skip("symlink creation is not available")
344+
345+
result = await tool.execute(problem_dir=problem_dir, output_dir="tests_link")
346+
347+
assert not result.success
348+
assert "output_dir must be inside problem_dir" in result.error
349+
350+
327351
def test_problem_generate_tests_clear_only_generated_files():
328352
"""测试清理输出目录时只删除旧的 .in/.ans 文件。"""
329353
tool = ProblemGenerateTestsTool()
@@ -406,6 +430,23 @@ def test_problem_verify_tests_file_count_requires_contiguous_numeric_names():
406430
assert result["missing_indices"] == [2]
407431

408432

433+
def test_problem_verify_tests_file_count_reports_large_gaps():
434+
"""测试跳到大编号时会报告完整缺失区间。"""
435+
tool = ProblemVerifyTestsTool()
436+
437+
with tempfile.TemporaryDirectory() as tmpdir:
438+
for name in ["01.in", "01.ans", "100.in", "100.ans"]:
439+
with open(os.path.join(tmpdir, name), "w", encoding="utf-8") as f:
440+
f.write("x\n")
441+
442+
result = tool._check_file_count(tmpdir)
443+
444+
assert not result["passed"]
445+
assert result["missing_indices"][0] == 2
446+
assert result["missing_indices"][-1] == 99
447+
assert len(result["missing_indices"]) == 98
448+
449+
409450
@pytest.mark.asyncio
410451
async def test_problem_pack_polygon_dynamic_test_count():
411452
"""测试 Polygon 打包使用动态 test-count。"""

tests/test_tools/test_solution.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,34 @@ async def test_solution_build():
277277
assert os.path.exists(result.data["binary_path"])
278278

279279

280+
@pytest.mark.asyncio
281+
async def test_solution_build_custom_name_keeps_standard_files(monkeypatch):
282+
"""测试自定义命名构建时仍保留 sol.cpp/sol 可供默认流程使用。"""
283+
tool = SolutionBuildTool()
284+
285+
async def fake_build(source_path, binary_path, compiler="g++", include_dirs=None):
286+
with open(binary_path, "w", encoding="utf-8") as f:
287+
f.write("binary")
288+
return CompileResult(success=True, binary_path=binary_path)
289+
290+
monkeypatch.setattr(tool, "build", fake_build)
291+
292+
with tempfile.TemporaryDirectory() as tmpdir:
293+
result = await tool.execute(
294+
problem_dir=tmpdir,
295+
solution_type="sol",
296+
name="accepted",
297+
code=SIMPLE_CPP,
298+
)
299+
300+
assert result.success
301+
assert os.path.exists(os.path.join(tmpdir, "solutions", "accepted.cpp"))
302+
assert os.path.exists(os.path.join(tmpdir, "solutions", "sol.cpp"))
303+
assert os.path.exists(result.data["binary_path"])
304+
assert os.path.exists(result.data["standard_binary_path"])
305+
assert result.data["effective_name"] == "accepted"
306+
307+
280308
@pytest.mark.asyncio
281309
async def test_solution_build_brute():
282310
"""测试暴力解法构建。"""

tests/test_workflow_guard.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,92 @@ def test_post_tool_marks_stress_passed(tmp_path):
7272

7373
assert exit_code == 0
7474
assert state["stress_passed"] is True
75+
76+
77+
def test_pre_tool_denies_pack_before_tests_verified(tmp_path, capsys):
78+
module = load_module()
79+
problem_dir = tmp_path / "problem"
80+
(problem_dir / "files").mkdir(parents=True)
81+
(problem_dir / "solutions").mkdir(parents=True)
82+
state = {
83+
"problem_dir": str(problem_dir),
84+
"created": True,
85+
"sol_built": True,
86+
"brute_built": True,
87+
"validator_ready": True,
88+
"validator_accuracy": 1.0,
89+
"generator_built": True,
90+
"stress_passed": True,
91+
"checker_ready": False,
92+
"validation_passed": True,
93+
"tests_generated": True,
94+
"generated_test_count": 3,
95+
"tests_verified": False,
96+
"packaged": False,
97+
}
98+
module.save_state(str(problem_dir), state)
99+
100+
payload = {
101+
"tool_name": "mcp__autocode__problem_pack_polygon",
102+
"tool_input": {"problem_dir": str(problem_dir)},
103+
}
104+
105+
exit_code = module.pre_tool(payload)
106+
captured = capsys.readouterr().out
107+
108+
assert exit_code == 0
109+
parsed = json.loads(captured)
110+
assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
111+
assert "problem_verify_tests" in parsed["hookSpecificOutput"]["permissionDecisionReason"]
112+
113+
114+
def test_post_tool_marks_tests_verified(tmp_path):
115+
module = load_module()
116+
problem_dir = tmp_path / "problem"
117+
(problem_dir / "files").mkdir(parents=True)
118+
(problem_dir / "solutions").mkdir(parents=True)
119+
120+
payload = {
121+
"tool_name": "mcp__autocode__problem_verify_tests",
122+
"tool_input": {"problem_dir": str(problem_dir)},
123+
"tool_response": {
124+
"structuredContent": {
125+
"success": True,
126+
"data": {"passed": True},
127+
}
128+
},
129+
}
130+
131+
exit_code = module.post_tool(payload)
132+
state = module.load_state(str(problem_dir))
133+
134+
assert exit_code == 0
135+
assert state["tests_verified"] is True
136+
137+
138+
def test_post_tool_clears_tests_verified_after_regeneration(tmp_path):
139+
module = load_module()
140+
problem_dir = tmp_path / "problem"
141+
(problem_dir / "files").mkdir(parents=True)
142+
(problem_dir / "solutions").mkdir(parents=True)
143+
state = module.infer_state(str(problem_dir))
144+
state["tests_verified"] = True
145+
module.save_state(str(problem_dir), state)
146+
147+
payload = {
148+
"tool_name": "mcp__autocode__problem_generate_tests",
149+
"tool_input": {"problem_dir": str(problem_dir)},
150+
"tool_response": {
151+
"structuredContent": {
152+
"success": True,
153+
"data": {"generated_tests": [1, 2]},
154+
}
155+
},
156+
}
157+
158+
exit_code = module.post_tool(payload)
159+
state = module.load_state(str(problem_dir))
160+
161+
assert exit_code == 0
162+
assert state["tests_generated"] is True
163+
assert state["tests_verified"] is False

0 commit comments

Comments
 (0)