agentseed-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/LICENSE +202 -0
  3. package/README.ja.md +320 -0
  4. package/README.md +318 -0
  5. package/README.zh.md +306 -0
  6. package/bin/cli.js +37 -0
  7. package/mcp.json +12 -0
  8. package/package.json +30 -0
  9. package/plugin.json +22 -0
  10. package/server/.agentseed/verification-log.jsonl +2 -0
  11. package/server/__pycache__/guard_cli.cpython-313.pyc +0 -0
  12. package/server/__pycache__/guard_engine.cpython-313.pyc +0 -0
  13. package/server/__pycache__/test_cli.cpython-313-pytest-9.1.1.pyc +0 -0
  14. package/server/__pycache__/test_cli.cpython-313.pyc +0 -0
  15. package/server/__pycache__/test_features.cpython-313-pytest-9.1.1.pyc +0 -0
  16. package/server/__pycache__/test_features.cpython-313.pyc +0 -0
  17. package/server/__pycache__/test_guard.cpython-313-pytest-9.1.1.pyc +0 -0
  18. package/server/__pycache__/test_guard.cpython-313.pyc +0 -0
  19. package/server/__pycache__/test_hook.cpython-313-pytest-9.1.1.pyc +0 -0
  20. package/server/__pycache__/test_hook.cpython-313.pyc +0 -0
  21. package/server/__pycache__/test_manifests.cpython-313-pytest-9.1.1.pyc +0 -0
  22. package/server/__pycache__/test_manifests.cpython-313.pyc +0 -0
  23. package/server/__pycache__/test_server.cpython-313-pytest-9.1.1.pyc +0 -0
  24. package/server/__pycache__/test_server.cpython-313.pyc +0 -0
  25. package/server/engine/__init__.py +64 -0
  26. package/server/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/server/engine/__pycache__/audit.cpython-313.pyc +0 -0
  28. package/server/engine/__pycache__/config.cpython-313.pyc +0 -0
  29. package/server/engine/__pycache__/hallucination.cpython-313.pyc +0 -0
  30. package/server/engine/__pycache__/imports.cpython-313.pyc +0 -0
  31. package/server/engine/__pycache__/plugin.cpython-313.pyc +0 -0
  32. package/server/engine/__pycache__/sandbox.cpython-313.pyc +0 -0
  33. package/server/engine/__pycache__/schema.cpython-313.pyc +0 -0
  34. package/server/engine/__pycache__/symbols.cpython-313.pyc +0 -0
  35. package/server/engine/__pycache__/version.cpython-313.pyc +0 -0
  36. package/server/engine/audit.py +84 -0
  37. package/server/engine/config.py +131 -0
  38. package/server/engine/hallucination.py +254 -0
  39. package/server/engine/imports.py +136 -0
  40. package/server/engine/plugin.py +367 -0
  41. package/server/engine/sandbox.py +287 -0
  42. package/server/engine/schema.py +193 -0
  43. package/server/engine/symbols.py +984 -0
  44. package/server/engine/version.py +17 -0
  45. package/server/guard_cli.py +455 -0
  46. package/server/guard_engine.py +111 -0
  47. package/server/guard_hook.py +404 -0
  48. package/server/guard_server.py +472 -0
  49. package/server/requirements.txt +7 -0
  50. package/server/test_cli.py +132 -0
  51. package/server/test_features.py +426 -0
  52. package/server/test_guard.py +828 -0
  53. package/server/test_hook.py +331 -0
  54. package/server/test_manifests.py +70 -0
  55. package/server/test_server.py +247 -0
  56. package/skills/verify-before-code/SKILL.ja.md +116 -0
  57. package/skills/verify-before-code/SKILL.md +140 -0
  58. package/skills/verify-before-code/SKILL.zh.md +117 -0
  59. package/skills/verify-before-code/references/DEFAULT-NORMS.md +52 -0
  60. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.ja.md +121 -0
  61. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.md +166 -0
  62. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.zh.md +145 -0
  63. package/skills/verify-before-code/references/PROMPT-POOL.ja.md +248 -0
  64. package/skills/verify-before-code/references/PROMPT-POOL.md +282 -0
  65. package/skills/verify-before-code/references/PROMPT-POOL.zh.md +252 -0
  66. package/skills/verify-before-code/references/SDD-CONTRACT.ja.md +61 -0
  67. package/skills/verify-before-code/references/SDD-CONTRACT.md +66 -0
  68. package/skills/verify-before-code/references/SDD-CONTRACT.zh.md +58 -0
  69. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.ja.md +62 -0
  70. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.md +62 -0
  71. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.zh.md +54 -0
  72. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.ja.md +68 -0
  73. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.md +73 -0
  74. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.zh.md +68 -0
  75. package/skills/verify-before-code/scripts/check.ps1 +52 -0
  76. package/skills/verify-before-code/scripts/check.sh +44 -0
@@ -0,0 +1,426 @@
1
+ """AgentSeed feature tests: line numbers, suppress, CJK tokens,
2
+ sandbox policy, config validation, audit trail, fixtures, perf gate."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ import unittest
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ import guard_engine as engine # type: ignore
16
+
17
+
18
+ class TestSymbolsLineNumbersAndSuppress(unittest.TestCase):
19
+ SOURCE = (
20
+ "import os\n"
21
+ "\n"
22
+ "def a():\n"
23
+ " return ghost_one()\n"
24
+ "\n"
25
+ "def b():\n"
26
+ " x = ghost_two + 1\n"
27
+ " return x\n"
28
+ )
29
+
30
+ def test_suspects_detail_carries_lines(self):
31
+ r = engine.detect_undefined_symbols(self.SOURCE)
32
+ self.assertEqual(r["suspects"], ["ghost_one", "ghost_two"])
33
+ lines = {d["name"]: d["line"] for d in r["suspects_detail"]}
34
+ self.assertEqual(lines, {"ghost_one": 4, "ghost_two": 7})
35
+
36
+ def test_suppress_filters_and_reports(self):
37
+ r = engine.detect_undefined_symbols(self.SOURCE, suppress=["ghost_two"])
38
+ self.assertEqual(r["suspects"], ["ghost_one"])
39
+ self.assertEqual(r["suppressed"], ["ghost_two"])
40
+ self.assertEqual([d["name"] for d in r["suspects_detail"]], ["ghost_one"])
41
+
42
+ def test_no_suppress_reports_empty_list(self):
43
+ r = engine.detect_undefined_symbols(self.SOURCE)
44
+ self.assertEqual(r["suppressed"], [])
45
+
46
+
47
+ class TestCjkTokens(unittest.TestCase):
48
+ def test_chinese_stub_tokens_hit_as_substrings(self):
49
+ src = "# 占位实现,稍后补充\n# 待实现\nx = 1\n"
50
+ r = engine.scan_hallucination_words(src)
51
+ words = {h["word"] for h in r["hits"]}
52
+ self.assertIn("占位", words)
53
+ self.assertTrue(any(h["group"] == "stub_code" for h in r["hits"]))
54
+
55
+ def test_chinese_oversold_blocks_by_default(self):
56
+ r = engine.scan_hallucination_words("# 保证通过\n")
57
+ self.assertTrue(r["blocking"])
58
+ self.assertEqual(r["hits"][0]["group"], "oversold")
59
+
60
+ def test_extra_tokens_extend_pool(self):
61
+ base = engine.scan_hallucination_words("x = frobnicate_now()\n")
62
+ self.assertEqual(base["hits"], [])
63
+ ext = engine.scan_hallucination_words(
64
+ "x = frobnicate_now()\n",
65
+ extra_tokens={"fabricated": ["frobnicate_now"]},
66
+ )
67
+ self.assertEqual(len(ext["hits"]), 1)
68
+ self.assertEqual(ext["hits"][0]["group"], "fabricated")
69
+ self.assertTrue(ext["blocking"]) # fabricated defaults to error
70
+
71
+ def test_extra_tokens_invalid_group_ignored(self):
72
+ r = engine.scan_hallucination_words("x = todo()\n", extra_tokens={"nope_group": ["todo"]})
73
+ self.assertEqual(len(r["hits"]), 1) # builtin stub hit only
74
+
75
+
76
+ class TestSandboxAllowPolicy(unittest.TestCase):
77
+ def test_unlisted_binary_blocked_without_running(self):
78
+ # Never spawned: the policy gate refuses BEFORE execution (-10).
79
+ r = engine.sandbox_run(
80
+ ["some-unlisted-tool", "--flag"],
81
+ allowed_prefixes=["python", "pytest"],
82
+ )
83
+ self.assertEqual(r["exit_code"], -10)
84
+ self.assertIn("sandbox_allowed_prefixes", r["stderr"])
85
+
86
+ def test_listed_basename_allowed(self):
87
+ r = engine.sandbox_run(
88
+ [sys.executable, "-c", "print(7)"],
89
+ allowed_prefixes=[os.path.basename(sys.executable)],
90
+ )
91
+ self.assertEqual(r["exit_code"], 0)
92
+ self.assertIn("7", r["stdout"])
93
+
94
+ def test_none_means_unrestricted(self):
95
+ # Cross-platform: actually runs on Linux/macOS too.
96
+ r = engine.sandbox_run([sys.executable, "-c", "print('ok')"], allowed_prefixes=None)
97
+ self.assertEqual(r["exit_code"], 0)
98
+
99
+
100
+ class TestSandboxAllowPolicyHardening(unittest.TestCase):
101
+ """Regressions for the allowlist-bypass fixes: separator-boundary
102
+ prefix matching and PATH-resolved execution (no cwd shadowing)."""
103
+
104
+ def test_prefix_requires_separator_boundary(self):
105
+ from engine import sandbox as sb
106
+
107
+ with tempfile.TemporaryDirectory() as d:
108
+ allowed_dir = os.path.join(d, "safe")
109
+ sibling = os.path.join(d, "safe-x")
110
+ self.assertTrue(
111
+ sb._matches_allowlist(os.path.join(allowed_dir, "tool.exe"), [allowed_dir])
112
+ )
113
+ # Without the boundary fix, prefix "d/safe" would match "d/safe-x/app.exe".
114
+ self.assertFalse(sb._matches_allowlist(os.path.join(sibling, "app.exe"), [allowed_dir]))
115
+
116
+ def test_bare_entry_tolerates_exe_suffix(self):
117
+ from engine import sandbox as sb
118
+
119
+ base = os.path.basename(sys.executable)
120
+ stem = base[:-4] if base.lower().endswith(".exe") else base
121
+ self.assertTrue(sb._matches_allowlist(sys.executable, [stem]))
122
+
123
+ def test_path_qualified_sibling_entry_never_basename_matches(self):
124
+ from engine import sandbox as sb
125
+
126
+ with tempfile.TemporaryDirectory() as d:
127
+ # An entry that LOOKS like a directory must not match via basename.
128
+ entry = os.path.join(d, "tools")
129
+ self.assertFalse(sb._matches_allowlist(os.path.join(d, "tools", "x"), ["tools"]))
130
+ self.assertTrue(sb._matches_allowlist(os.path.join(d, "tools", "x"), [entry]))
131
+
132
+ def test_unresolvable_allowlisted_name_is_refused_not_spawned(self):
133
+ r = engine.sandbox_run(
134
+ ["definitely-not-a-real-bin-xyz"],
135
+ 5,
136
+ allowed_prefixes=["definitely-not-a-real-bin-xyz"],
137
+ )
138
+ self.assertEqual(r["exit_code"], -10)
139
+
140
+ def test_cwd_planted_executable_cannot_shadow_allowlisted_basename(self):
141
+ exe_name = os.path.basename(sys.executable)
142
+ with tempfile.TemporaryDirectory() as d:
143
+ with open(os.path.join(d, exe_name), "w", encoding="utf-8") as fh:
144
+ fh.write("print('HIJACKED')\n")
145
+ r = engine.sandbox_run(
146
+ [exe_name, "-c", "print('clean')"],
147
+ 20,
148
+ cwd=d,
149
+ allowed_prefixes=[exe_name],
150
+ )
151
+ self.assertEqual(r["exit_code"], 0, r)
152
+ self.assertNotIn("HIJACKED", r["stdout"])
153
+ self.assertIn("clean", r["stdout"])
154
+
155
+ def test_relative_command_resolves_against_cwd_not_server(self):
156
+ # Policy-checked path must BE the executed path: a relative command
157
+ # resolves against the run cwd, so a planted binary in the caller
158
+ # cwd is judged where it actually lives, not where the server sits.
159
+ from engine import sandbox as sb
160
+
161
+ with tempfile.TemporaryDirectory() as d:
162
+ real = os.path.join(d, "real")
163
+ planted = os.path.join(d, "planted")
164
+ os.makedirs(real)
165
+ os.makedirs(planted)
166
+ r = sb.sandbox_run(["./prog.exe"], 5, cwd=planted, allowed_prefixes=[real])
167
+ self.assertEqual(r["exit_code"], -10, r) # outside the allowed dir
168
+ r2 = sb.sandbox_run(["./prog.exe"], 5, cwd=real, allowed_prefixes=[real])
169
+ self.assertNotEqual(r2["exit_code"], -10, r2) # policy passed (file absent -> -2)
170
+
171
+ def test_env_scrub_drops_credential_like_vars(self):
172
+ marker = "AGENTSEED_TEST_FAKE_API_TOKEN"
173
+ os.environ[marker] = "leak-me"
174
+ try:
175
+ code = (
176
+ "import os, sys; sys.stdout.write("
177
+ f"'TOKEN-SEEN' if os.environ.get({marker!r}) else 'SCRUBBED')"
178
+ )
179
+ scrubbed = engine.sandbox_run([sys.executable, "-c", code], 20, env_mode="scrub")
180
+ inherited = engine.sandbox_run([sys.executable, "-c", code], 20, env_mode="inherit")
181
+ finally:
182
+ os.environ.pop(marker, None)
183
+ self.assertEqual(scrubbed["exit_code"], 0, scrubbed)
184
+ self.assertIn("SCRUBBED", scrubbed["stdout"], scrubbed)
185
+ self.assertIn("TOKEN-SEEN", inherited["stdout"])
186
+
187
+ def test_timeout_reaps_grandchild(self):
188
+ import subprocess as sp
189
+ import time as _time
190
+
191
+ inner = (
192
+ "import subprocess\n"
193
+ f"child = subprocess.Popen([{sys.executable!r}, '-c', 'import time; time.sleep(60)'])\n"
194
+ "print(child.pid, flush=True)\n"
195
+ "child.wait()\n"
196
+ )
197
+ r = engine.sandbox_run([sys.executable, "-c", inner], 2)
198
+ self.assertTrue(r["timed_out"], r)
199
+ gc_pid = int(r["stdout"].strip())
200
+
201
+ if os.name == "nt":
202
+ listing = sp.run(
203
+ ["tasklist", "/FI", f"PID eq {gc_pid}"],
204
+ capture_output=True,
205
+ text=True,
206
+ encoding="utf-8",
207
+ errors="replace",
208
+ ).stdout.lower()
209
+ self.assertNotIn(str(gc_pid), listing.replace(",", ""), listing)
210
+ elif os.path.isdir("/proc"):
211
+ # A SIGKILLed orphan can linger as a zombie until reaped; signal-0
212
+ # probes succeed on zombies, so inspect the kernel state instead.
213
+ def _alive(pid: int) -> bool:
214
+ try:
215
+ with open(f"/proc/{pid}/stat", encoding="utf-8") as fh:
216
+ return fh.read().rsplit(")", 1)[1].split()[0] != "Z"
217
+ except (FileNotFoundError, ProcessLookupError):
218
+ return False
219
+
220
+ deadline = _time.monotonic() + 5.0
221
+ while _alive(gc_pid) and _time.monotonic() < deadline:
222
+ _time.sleep(0.2)
223
+ self.assertFalse(_alive(gc_pid), f"grandchild {gc_pid} survived tree-kill")
224
+ # other platforms: timed_out assertion above is the contract
225
+
226
+ def test_cli_sandbox_policy_block_exits_nonzero(self):
227
+ import subprocess
228
+
229
+ here = os.path.dirname(os.path.abspath(__file__))
230
+ cli = os.path.join(here, "guard_cli.py")
231
+ with tempfile.TemporaryDirectory() as d:
232
+ cfg = os.path.join(d, "agentseed.config.json")
233
+ with open(cfg, "w", encoding="utf-8") as fh:
234
+ json.dump({"sandbox_allowed_prefixes": ["definitely-not-a-bin"]}, fh)
235
+ env = dict(os.environ, AGENTSEED_CONFIG=cfg)
236
+ proc = subprocess.run(
237
+ [sys.executable, cli, "sandbox", "--", "other-unlisted-tool", "--flag"],
238
+ capture_output=True,
239
+ text=True,
240
+ encoding="utf-8",
241
+ errors="replace",
242
+ env=env,
243
+ timeout=60,
244
+ cwd=here,
245
+ )
246
+ self.assertEqual(proc.returncode, 1)
247
+ self.assertIn("-10", proc.stdout)
248
+
249
+
250
+ class TestConfigUnknownKeysAndExtras(unittest.TestCase):
251
+ def test_unknown_keys_listed(self):
252
+ self.assertEqual(
253
+ engine.unknown_config_keys({"allowlist": [], "alowlist": [], "wht": 1}),
254
+ ["alowlist", "wht"],
255
+ )
256
+ self.assertEqual(engine.unknown_config_keys({}), [])
257
+
258
+ def test_extra_tokens_validator(self):
259
+ out = engine.config_extra_tokens({"extra_tokens": {"stub_code": ["待办"], "bogus": ["x"]}})
260
+ self.assertEqual(out, {"stub_code": ["待办"]})
261
+ self.assertIsNone(engine.config_extra_tokens({"extra_tokens": "x"}))
262
+ self.assertIsNone(engine.config_extra_tokens({}))
263
+
264
+
265
+ class TestAuditTrail(unittest.TestCase):
266
+ def test_record_appends_jsonl(self):
267
+ with tempfile.TemporaryDirectory() as d:
268
+ r1 = engine.record_verification(
269
+ "task A", [{"tool": "verify_code", "status": "pass"}], data_dir=d
270
+ )
271
+ self.assertTrue(r1["ok"])
272
+ r2 = engine.record_verification(
273
+ "task B",
274
+ [
275
+ {"tool": "sandbox_run", "status": "fail", "summary": "boom"},
276
+ {"tool": "bogus", "status": "invalid-status"},
277
+ ],
278
+ data_dir=d,
279
+ )
280
+ self.assertEqual(r2["entries"], 2)
281
+ with open(r2["path"], encoding="utf-8") as fh:
282
+ lines = [json.loads(ln) for ln in fh if ln.strip()]
283
+ self.assertEqual(len(lines), 2)
284
+ self.assertEqual(lines[0]["checks"][0]["tool"], "verify_code")
285
+ self.assertEqual(
286
+ lines[1]["checks"], [{"tool": "sandbox_run", "status": "fail", "summary": "boom"}]
287
+ )
288
+
289
+ def test_record_rejects_blank_task(self):
290
+ self.assertFalse(engine.record_verification(" ", [])["ok"])
291
+
292
+
293
+ class TestExampleFixtures(unittest.TestCase):
294
+ EXAMPLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "examples", "plugins")
295
+
296
+ def test_good_plugin_conforms(self):
297
+ r = engine.check_plugin_conformance(
298
+ os.path.abspath(os.path.join(self.EXAMPLES, "good-plugin"))
299
+ )
300
+ self.assertTrue(r["ok"], r["errors"])
301
+
302
+ def test_broken_plugin_flagged(self):
303
+ r = engine.check_plugin_conformance(
304
+ os.path.abspath(os.path.join(self.EXAMPLES, "broken-plugin"))
305
+ )
306
+ self.assertFalse(r["ok"])
307
+ joined = " ".join(r["errors"])
308
+ self.assertIn("Broken-Demo", joined)
309
+ self.assertIn("privateExtra", joined)
310
+
311
+
312
+ class TestPerfBaseline(unittest.TestCase):
313
+ """Loose gate: a pathological slowdown must not ship silently."""
314
+
315
+ def test_1mb_source_under_30s(self):
316
+ sys.path.insert(
317
+ 0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
318
+ )
319
+ import bench # noqa: PLC0415
320
+
321
+ src = bench.make_source(1.0)
322
+ t0 = time.perf_counter()
323
+ engine.detect_undefined_symbols(src)
324
+ engine.scan_hallucination_words(src)
325
+ elapsed = time.perf_counter() - t0
326
+ self.assertLess(elapsed, 30.0, f"1MB baseline regressed: {elapsed:.1f}s")
327
+
328
+
329
+ class TestCliRecordAndAsyncPolicy(unittest.TestCase):
330
+ HERE = os.path.dirname(os.path.abspath(__file__))
331
+
332
+ def _run(self, *args: str, env_extra=None):
333
+ import subprocess
334
+
335
+ env = dict(os.environ)
336
+ if env_extra:
337
+ env.update(env_extra)
338
+ return subprocess.run(
339
+ [sys.executable, os.path.join(self.HERE, "guard_cli.py"), *args],
340
+ capture_output=True,
341
+ text=True,
342
+ encoding="utf-8",
343
+ errors="replace",
344
+ timeout=60,
345
+ cwd=self.HERE,
346
+ env=env,
347
+ )
348
+
349
+ def test_cli_record_writes_log(self):
350
+ with tempfile.TemporaryDirectory() as d:
351
+ r = self._run(
352
+ "record",
353
+ "fix #7",
354
+ "--check",
355
+ "verify_code=pass",
356
+ "--check",
357
+ "scan=fail",
358
+ "--data-dir",
359
+ d,
360
+ )
361
+ self.assertEqual(r.returncode, 0, r.stderr)
362
+ log = os.path.join(d, "verification-log.jsonl")
363
+ with open(log, encoding="utf-8") as fh:
364
+ entries = [json.loads(ln) for ln in fh if ln.strip()]
365
+ self.assertEqual(len(entries), 1)
366
+ self.assertEqual(entries[0]["task"], "fix #7")
367
+ statuses = [c["status"] for c in entries[0]["checks"]]
368
+ self.assertEqual(statuses, ["pass", "fail"])
369
+
370
+ def test_async_sandbox_policy_blocks_via_server(self):
371
+ import subprocess
372
+
373
+ with tempfile.TemporaryDirectory() as d:
374
+ cfg = os.path.join(d, "cfg.json")
375
+ with open(cfg, "w", encoding="utf-8") as fh:
376
+ json.dump({"sandbox_allowed_prefixes": ["only-this-bin"]}, fh)
377
+ proc = subprocess.Popen(
378
+ [sys.executable, "-u", os.path.join(self.HERE, "guard_server.py")],
379
+ stdin=subprocess.PIPE,
380
+ stdout=subprocess.PIPE,
381
+ stderr=subprocess.DEVNULL,
382
+ env=dict(os.environ, AGENTSEED_CONFIG=cfg),
383
+ )
384
+ try:
385
+ req = {
386
+ "jsonrpc": "2.0",
387
+ "id": 1,
388
+ "method": "tools/call",
389
+ "params": {
390
+ "name": "sandbox_run",
391
+ "arguments": {"command": ["another-unlisted-bin", "x"]},
392
+ },
393
+ }
394
+ proc.stdin.write((json.dumps(req) + "\n").encode())
395
+ proc.stdin.flush()
396
+ frame = json.loads(proc.stdout.readline().decode())
397
+ text = json.loads(frame["result"]["content"][0]["text"])
398
+ self.assertEqual(text["exit_code"], -10)
399
+ finally:
400
+ # kill alone leaves the process un-reaped (ResourceWarning) and
401
+ # the stdin/stdout pipe buffers unclosed — wait() reaps it and
402
+ # closing the pipes silences the unclosed-file warnings.
403
+ proc.kill()
404
+ proc.wait()
405
+ for _pipe in (proc.stdin, proc.stdout):
406
+ if _pipe is not None and not _pipe.closed:
407
+ _pipe.close()
408
+
409
+
410
+ class TestDetectionBenchmark(unittest.TestCase):
411
+ """Regression lock: every injected defect class must stay caught with
412
+ zero false positives on the clean set (seeded synthetic corpus)."""
413
+
414
+ def test_corpus_precision_and_recall_are_perfect(self):
415
+ sys.path.insert(
416
+ 0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
417
+ )
418
+ import bench_detection # noqa: PLC0415
419
+
420
+ report = bench_detection.evaluate(bench_detection.build_corpus(4, 8, seed=7))
421
+ self.assertEqual(report["totals"]["fn"], 0, report)
422
+ self.assertEqual(report["totals"]["fp"], 0, report)
423
+
424
+
425
+ if __name__ == "__main__":
426
+ unittest.main(verbosity=2)