phase-barrier 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,34 @@
1
+ """反捷径校验 Skill:强制编码 Agent 遵循标准工程师 SOP(阶段门禁)。
2
+
3
+ 用法::
4
+
5
+ from anti_shortcut import AntiShortcutSkill
6
+
7
+ skill = AntiShortcutSkill(workspace=".", user_request="实现一个斐波那契函数")
8
+ tools = skill.install({"write_file": my_write, "execute_command": my_exec})
9
+ result = tools["advance_stage"](2) # 完成 spec 后推进
10
+ """
11
+ from importlib.metadata import PackageNotFoundError, version as _distribution_version
12
+
13
+ from .config import STAGES, GateConfig, load_config
14
+ from .integration import bootstrap, install_into, load_plugins, register_integration
15
+ from .skill import AntiShortcutSkill
16
+ from .state import StateManager
17
+
18
+ try:
19
+ __version__ = _distribution_version("phase-barrier")
20
+ except PackageNotFoundError: # 直接从源码运行(未安装)时的占位版本
21
+ __version__ = "0.0.0.dev0"
22
+
23
+ __all__ = [
24
+ "AntiShortcutSkill",
25
+ "StateManager",
26
+ "GateConfig",
27
+ "STAGES",
28
+ "load_config",
29
+ "bootstrap",
30
+ "install_into",
31
+ "load_plugins",
32
+ "register_integration",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,107 @@
1
+ """命令行入口:外部门禁检查与阶段推进(供编排器 / 人工监督使用)。
2
+
3
+ 用法::
4
+
5
+ python -m anti_shortcut inspect [--workspace .] [--json]
6
+ python -m anti_shortcut advance --to 2 [--workspace .] [--json]
7
+ python -m anti_shortcut --version
8
+
9
+ ``advance`` 与 Agent 内部的 ``advance_stage`` 走同一套证据校验:
10
+ 通过返回退出码 0,被拒绝返回退出码 1 并打印原因。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+ from .config import STAGES
21
+ from .skill import AntiShortcutSkill
22
+
23
+
24
+ def _build_skill(args: argparse.Namespace) -> AntiShortcutSkill:
25
+ return AntiShortcutSkill(
26
+ args.workspace,
27
+ config=args.config,
28
+ user_request=getattr(args, "user_request", "") or "",
29
+ )
30
+
31
+
32
+ def _cmd_inspect(args: argparse.Namespace) -> int:
33
+ skill = _build_skill(args)
34
+ tr = skill.state.get_evidence("last_test_run") or {}
35
+ payload = {
36
+ "workspace": str(skill.workspace),
37
+ "current_stage": skill.current_stage,
38
+ "stage_name": skill.stage_name,
39
+ "completed_stages": skill.state.completed_stages,
40
+ "complete": skill.is_complete,
41
+ "last_test_run": (
42
+ {k: tr.get(k) for k in ("exit_code", "passed", "summary")} if tr else None
43
+ ),
44
+ }
45
+ if args.json:
46
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
47
+ else:
48
+ print(f"workspace : {payload['workspace']}")
49
+ print(f"current_stage : {payload['current_stage']}({payload['stage_name']})")
50
+ print(f"completed_stages: {payload['completed_stages']}")
51
+ print(f"complete : {payload['complete']}")
52
+ if payload["last_test_run"]:
53
+ ltr = payload["last_test_run"]
54
+ print(
55
+ f"last_test_run : exit_code={ltr['exit_code']} "
56
+ f"passed={ltr['passed']} summary={ltr['summary']!r}"
57
+ )
58
+ return 0
59
+
60
+
61
+ def _cmd_advance(args: argparse.Namespace) -> int:
62
+ skill = _build_skill(args)
63
+ result = skill.advance_stage(args.to)
64
+ if args.json:
65
+ print(json.dumps(result, ensure_ascii=False, indent=2))
66
+ elif result["success"]:
67
+ print(f"OK: {result['message']}")
68
+ else:
69
+ print(f"REJECTED: {result['error']}")
70
+ return 0 if result["success"] else 1
71
+
72
+
73
+ def build_parser() -> argparse.ArgumentParser:
74
+ parser = argparse.ArgumentParser(
75
+ prog="python -m anti_shortcut",
76
+ description="反捷径校验 Skill 命令行:状态检查与阶段推进",
77
+ )
78
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
79
+ sub = parser.add_subparsers(dest="command", required=True)
80
+
81
+ common = argparse.ArgumentParser(add_help=False)
82
+ common.add_argument("--workspace", type=str, default=".", help="工作区根目录(默认当前目录)")
83
+ common.add_argument("--config", type=str, default=None, help="YAML 配置文件路径")
84
+ common.add_argument("--json", action="store_true", help="以 JSON 输出")
85
+
86
+ p_inspect = sub.add_parser("inspect", parents=[common], help="查看当前门禁状态")
87
+ p_inspect.set_defaults(func=_cmd_inspect)
88
+
89
+ p_advance = sub.add_parser("advance", parents=[common], help="推进阶段(校验当前阶段证据)")
90
+ p_advance.add_argument("--to", type=int, required=True, help="目标阶段(必须等于当前阶段 + 1)")
91
+ p_advance.add_argument("--user-request", type=str, default="", help="用户需求原文(首次初始化时记录)")
92
+ p_advance.set_defaults(func=_cmd_advance)
93
+ return parser
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> int:
97
+ try:
98
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
99
+ except (AttributeError, ValueError): # pragma: no cover
100
+ pass
101
+ parser = build_parser()
102
+ args = parser.parse_args(argv)
103
+ return args.func(args)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
anti_shortcut/audit.py ADDED
@@ -0,0 +1,109 @@
1
+ """审计日志:结构化 JSON 日志(优先 structlog,缺省回退 stdlib)。
2
+
3
+ 记录内容:阶段状态变更、工具拦截事件、证据校验结果。
4
+ 日志文件位于 ``<workspace>/.agent_gate/audit.log``,每个 Skill 实例使用独立
5
+ logger(按文件句柄),同一进程内的多个工作区互不串扰。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ try:
14
+ import structlog
15
+
16
+ _HAS_STRUCTLOG = True
17
+ except ImportError: # pragma: no cover
18
+ _HAS_STRUCTLOG = False
19
+
20
+ LOGGER_NAME = "anti_shortcut.audit"
21
+
22
+
23
+ class _JsonFallbackFormatter(logging.Formatter):
24
+ """无 structlog 时的简易 JSON 格式化器。"""
25
+
26
+ def format(self, record: logging.LogRecord) -> str:
27
+ import json
28
+
29
+ payload = {
30
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
31
+ "level": record.levelname,
32
+ "event": record.getMessage(),
33
+ }
34
+ for key, value in getattr(record, "payload", {}).items():
35
+ payload[key] = value
36
+ return json.dumps(payload, ensure_ascii=False)
37
+
38
+
39
+ class _TeeLogger:
40
+ """同时写入文件 logger 与控制台 logger 的简单组合。"""
41
+
42
+ def __init__(self, *loggers) -> None:
43
+ self._loggers = loggers
44
+
45
+ def _emit(self, level: str, event, **kwargs):
46
+ for lg in self._loggers:
47
+ getattr(lg, level)(event, **kwargs)
48
+
49
+ def info(self, event, **kwargs):
50
+ self._emit("info", event, **kwargs)
51
+
52
+ def warning(self, event, **kwargs):
53
+ self._emit("warning", event, **kwargs)
54
+
55
+ def error(self, event, **kwargs):
56
+ self._emit("error", event, **kwargs)
57
+
58
+ def debug(self, event, **kwargs):
59
+ self._emit("debug", event, **kwargs)
60
+
61
+
62
+ def get_audit_logger(log_file: Path, *, console: bool = False, level: int = logging.INFO):
63
+ """创建审计 logger(独立文件句柄,进程内可安全创建多个)。
64
+
65
+ :param log_file: 审计日志文件路径
66
+ :param console: 是否同时输出到控制台
67
+ """
68
+ log_file = Path(log_file)
69
+ log_file.parent.mkdir(parents=True, exist_ok=True)
70
+
71
+ if _HAS_STRUCTLOG:
72
+ json_processors = [
73
+ structlog.processors.TimeStamper(fmt="iso"),
74
+ structlog.processors.add_log_level,
75
+ structlog.processors.StackInfoRenderer(),
76
+ structlog.processors.format_exc_info,
77
+ structlog.processors.JSONRenderer(ensure_ascii=False),
78
+ ]
79
+ file_logger = structlog.wrap_logger(
80
+ structlog.PrintLogger(file=open(log_file, "a", encoding="utf-8")),
81
+ processors=json_processors,
82
+ wrapper_class=structlog.make_filtering_bound_logger(level),
83
+ cache_logger_on_first_use=True,
84
+ )
85
+ if not console:
86
+ return file_logger
87
+ console_logger = structlog.wrap_logger(
88
+ structlog.PrintLogger(file=sys.stdout),
89
+ processors=[
90
+ structlog.processors.TimeStamper(fmt="iso"),
91
+ structlog.processors.add_log_level,
92
+ structlog.processors.ConsoleRenderer(colors=False),
93
+ ],
94
+ wrapper_class=structlog.make_filtering_bound_logger(level),
95
+ cache_logger_on_first_use=True,
96
+ )
97
+ return _TeeLogger(file_logger, console_logger)
98
+
99
+ # 回退:标准库 logging + JSON Formatter
100
+ std_logger = logging.getLogger(f"{LOGGER_NAME}:{log_file}")
101
+ std_logger.setLevel(level)
102
+ if not std_logger.handlers:
103
+ handler = logging.FileHandler(log_file, encoding="utf-8")
104
+ handler.setFormatter(_JsonFallbackFormatter())
105
+ std_logger.addHandler(handler)
106
+ if console:
107
+ std_logger.addHandler(logging.StreamHandler(sys.stdout))
108
+ std_logger.propagate = False
109
+ return std_logger
@@ -0,0 +1,106 @@
1
+ """阶段门禁配置:Pydantic 模型 + YAML 加载。
2
+
3
+ 配置允许项目自定义阶段划分、证据要求、文件模式、测试命令等,
4
+ 从而在“严格门禁”与“灵活性”之间取得平衡(见方案第 5 章/第 10 章)。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, Field, model_validator
13
+
14
+ # 阶段名称(与方案第 5 章保持一致)
15
+ STAGES: dict[int, str] = {
16
+ 0: "需求接收",
17
+ 1: "Spec 设计",
18
+ 2: "测试用例编写",
19
+ 3: "实现代码",
20
+ 4: "运行测试",
21
+ 5: "修复与回归",
22
+ 6: "交付",
23
+ }
24
+
25
+ DEFAULT_SPEC_SECTIONS = ["## 需求分析", "## 设计方案", "## 接口定义"]
26
+
27
+ # 默认测试命令正则(匹配命令前缀),可按语言扩展
28
+ DEFAULT_TEST_COMMANDS: list[str] = [
29
+ r"^\s*python3?\s+(-m\s+)?pytest\b",
30
+ r"^\s*pytest\b",
31
+ r"^\s*python3?\s+-m\s+unittest\b",
32
+ r"^\s*npm\s+test\b",
33
+ r"^\s*npx\s+(jest|vitest|mocha|playwright)\b",
34
+ r"^\s*(go|rust)\s+test\b",
35
+ r"^\s*cargo\s+test\b",
36
+ r"^\s*gradle\s+test\b",
37
+ r"^\s*mvn\s+test\b",
38
+ r"^\s*tox\b",
39
+ r"^\s*unittest\b",
40
+ ]
41
+
42
+ # 默认测试文件匹配模式(fnmatch / Path.match 语义)
43
+ DEFAULT_TEST_FILE_PATTERNS: list[str] = [
44
+ "test_*.py",
45
+ "*_test.py",
46
+ "tests/**/test_*.py",
47
+ "tests/**/*_test.py",
48
+ ]
49
+
50
+ DEFAULT_SOURCE_FILE_PATTERNS: list[str] = ["*.py"]
51
+
52
+
53
+ class GateConfig(BaseModel):
54
+ """反捷径校验 Skill 的可配置项。所有字段都有合理默认值,YAML 可部分覆盖。"""
55
+
56
+ workspace: Path = Path(".")
57
+ gate_dir_name: str = ".agent_gate"
58
+ state_file_name: str = "state.json"
59
+ audit_log_name: str = "audit.log"
60
+ # ---- 阶段 1:Spec ----
61
+ spec_file: str = "spec.md"
62
+ spec_sections: list[str] = Field(default_factory=lambda: list(DEFAULT_SPEC_SECTIONS))
63
+ spec_min_chars: int = 120
64
+ # ---- 阶段 2:测试用例 ----
65
+ test_file_patterns: list[str] = Field(default_factory=lambda: list(DEFAULT_TEST_FILE_PATTERNS))
66
+ min_test_functions: int = 2
67
+ require_assert_per_test: bool = True
68
+ # ---- 阶段 3:实现 ----
69
+ source_file_patterns: list[str] = Field(default_factory=lambda: list(DEFAULT_SOURCE_FILE_PATTERNS))
70
+ require_implementation: bool = True
71
+ # ---- 阶段 4/5:测试运行 ----
72
+ test_commands: list[str] = Field(default_factory=lambda: list(DEFAULT_TEST_COMMANDS))
73
+ max_test_output_tail: int = 4000
74
+ # ---- 安全 ----
75
+ protect_gate_dir: bool = True
76
+ # 允许 Agent 直接写入“其他”类型文件(如 README.md、docs),默认不限
77
+ allow_other_files_any_stage: bool = True
78
+
79
+ @model_validator(mode="after")
80
+ def _expand_workspace(self) -> "GateConfig":
81
+ self.workspace = Path(self.workspace).expanduser().resolve()
82
+ return self
83
+
84
+
85
+ def load_config(path: str | Path | dict[str, Any] | None = None) -> GateConfig:
86
+ """从 YAML 文件或字典加载配置,缺失字段使用默认值。
87
+
88
+ - ``None``:纯默认配置
89
+ - ``dict``:字段覆盖
90
+ - ``Path``/``str``:读取 YAML 文件后覆盖
91
+ """
92
+ if path is None:
93
+ data: dict[str, Any] = {}
94
+ elif isinstance(path, (str, Path)):
95
+ p = Path(path)
96
+ if not p.exists():
97
+ raise FileNotFoundError(f"配置文件不存在: {p}")
98
+ with p.open("r", encoding="utf-8") as fh:
99
+ data = yaml.safe_load(fh) or {}
100
+ if not isinstance(data, dict):
101
+ raise ValueError(f"配置文件 {p} 顶层必须是映射(dict)")
102
+ elif isinstance(path, dict):
103
+ data = path
104
+ else:
105
+ raise TypeError(f"不支持的配置类型: {type(path)!r}")
106
+ return GateConfig(**data)
@@ -0,0 +1,108 @@
1
+ """集成层:插件加载与通用 Agent 工具注册表接入。
2
+
3
+ 对接方式(对应方案“技术栈选型—集成与部署”):
4
+
5
+ 1. **一键接入**:``bootstrap(agent_tools, workspace, ...)`` 创建 Skill、包装工具、
6
+ 注入 ``advance_stage`` 并加载集成插件,适合 Alpha-SWE 等以“工具表 dict”暴露工具的 Agent。
7
+ 2. **进程内插件**:``register_integration(name, installer)`` 注册,``installer(agent, skill)``
8
+ 负责把包装后的工具装回 Agent;宿主进程启动时调用 ``load_plugins(agent, skill)``。
9
+ 3. **入口点插件**:发布为独立包的插件可声明 ``anti_shortcut.integrations`` 入口点,
10
+ ``load_plugins`` 自动发现并执行其 ``install``。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import importlib.metadata
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+
18
+ from .skill import AntiShortcutSkill
19
+
20
+ __all__ = [
21
+ "ENTRY_POINT_GROUP",
22
+ "bootstrap",
23
+ "install_into",
24
+ "load_plugins",
25
+ "register_integration",
26
+ ]
27
+
28
+ ENTRY_POINT_GROUP = "anti_shortcut.integrations"
29
+
30
+ # 进程内集成注册表:name -> installer(agent, skill)
31
+ _registry: dict[str, Callable[[Any, AntiShortcutSkill], None]] = {}
32
+
33
+
34
+ def register_integration(
35
+ name: str,
36
+ installer: Callable[[Any, AntiShortcutSkill], None],
37
+ ) -> None:
38
+ """进程内注册一个集成插件。
39
+
40
+ :param name: 插件名(重复注册会覆盖)
41
+ :param installer: ``installer(agent, skill)``,负责把 ``skill`` 包装后的工具装回 ``agent``
42
+ """
43
+ if not callable(installer):
44
+ raise TypeError("installer 必须可调用")
45
+ _registry[name] = installer
46
+
47
+
48
+ def load_plugins(
49
+ agent: Any = None,
50
+ skill: AntiShortcutSkill | None = None,
51
+ ) -> list[str]:
52
+ """加载并执行所有集成插件(进程内注册表 + 已安装包的入口点)。
53
+
54
+ :param agent: 宿主 Agent 对象(原样传给 installer)
55
+ :param skill: 已创建的 Skill 实例
56
+ :return: 已加载的插件名列表
57
+ """
58
+ loaded: list[str] = []
59
+ for name, installer in _registry.items():
60
+ installer(agent, skill)
61
+ loaded.append(name)
62
+ try:
63
+ entry_points = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
64
+ except (TypeError, importlib.metadata.PackageNotFoundError): # pragma: no cover
65
+ entry_points = ()
66
+ for ep in entry_points:
67
+ plugin = ep.load()
68
+ installer = plugin if callable(plugin) else getattr(plugin, "install", None)
69
+ if installer is None:
70
+ raise TypeError(
71
+ f"入口点插件 {ep.name!r} 必须整体可调用,或导出 install(agent, skill)"
72
+ )
73
+ installer(agent, skill)
74
+ loaded.append(ep.name)
75
+ return loaded
76
+
77
+
78
+ def install_into(
79
+ agent_tools: dict[str, Callable],
80
+ skill: AntiShortcutSkill,
81
+ ) -> dict[str, Callable]:
82
+ """把 Skill 包装后的工具写回 Agent 工具表(等价于 ``skill.install``)。"""
83
+ return skill.install(agent_tools)
84
+
85
+
86
+ def bootstrap(
87
+ agent_tools: dict[str, Callable],
88
+ workspace: str | Path,
89
+ config: Any = None,
90
+ user_request: str = "",
91
+ *,
92
+ agent: Any = None,
93
+ load_integrations: bool = True,
94
+ ) -> AntiShortcutSkill:
95
+ """一步完成:创建 Skill -> 包装 Agent 工具 -> 加载集成插件。
96
+
97
+ :param agent_tools: Agent 的工具表,如 ``{"write_file": ..., "execute_command": ...}``
98
+ :param workspace: 工作区根目录
99
+ :param config: Skill 配置(YAML 路径 / dict / GateConfig / None)
100
+ :param user_request: 用户需求原文(阶段 0 证据)
101
+ :param agent: 宿主 Agent 对象(透传给集成插件)
102
+ :param load_integrations: 是否加载已注册的集成插件
103
+ """
104
+ skill = AntiShortcutSkill(workspace, config=config, user_request=user_request)
105
+ install_into(agent_tools, skill)
106
+ if load_integrations:
107
+ load_plugins(agent=agent, skill=skill)
108
+ return skill
@@ -0,0 +1,144 @@
1
+ """工具拦截辅助:命令分类、门禁目录保护、shell 写路径提取、测试输出摘要。
2
+
3
+ - 识别测试运行命令(可配置正则)
4
+ - 识别试图访问/篡改门禁目录的命令
5
+ - 从 shell 命令中提取“被写入的路径”(重定向、mv/cp 目标、sed -i、rm/touch 等),
6
+ 从而让 ``execute_command`` 的写操作受到与 ``write_file`` 相同的阶段限制
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shlex
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .config import GateConfig
16
+
17
+
18
+ # ---------- 测试命令识别 ----------
19
+
20
+ def is_test_command(command: str, config: GateConfig) -> bool:
21
+ """判断命令是否为测试运行命令(按 config.test_commands 正则匹配前缀)。"""
22
+ cmd = (command or "").strip()
23
+ if not cmd:
24
+ return False
25
+ for pattern in config.test_commands:
26
+ try:
27
+ if re.search(pattern, cmd, flags=re.IGNORECASE):
28
+ return True
29
+ except re.error:
30
+ continue
31
+ return False
32
+
33
+
34
+ # ---------- 门禁目录保护 ----------
35
+
36
+ def touches_gate_dir(command: str, gate_dir: Path) -> bool:
37
+ """判断命令是否可能访问或篡改门禁目录(.agent_gate)。"""
38
+ gate_dir = Path(gate_dir)
39
+ name = gate_dir.name
40
+ try:
41
+ tokens = shlex.split(command, posix=True)
42
+ except ValueError:
43
+ tokens = command.split()
44
+ for raw in tokens:
45
+ tok = raw.strip('"\'')
46
+ if not tok:
47
+ continue
48
+ if tok == name or tok == str(gate_dir):
49
+ return True
50
+ if tok.startswith(name + "/") or tok.startswith(name + "\\"):
51
+ return True
52
+ if tok.startswith(str(gate_dir) + "/") or tok.startswith(str(gate_dir) + "\\"):
53
+ return True
54
+ return False
55
+
56
+
57
+ # ---------- shell 写路径提取 ----------
58
+
59
+ def _looks_like_sed_expr(token: str) -> bool:
60
+ """粗略判断 token 是否为 sed 表达式(如 s/a/b/、/pattern/、1,5d)。"""
61
+ if "/" in token:
62
+ return True
63
+ return bool(re.match(r"^[0-9$!~,]*[sSdDcCpPaAiI]", token))
64
+
65
+
66
+ def extract_written_paths(command: str) -> list[str]:
67
+ """从 shell 命令中提取可能被写入的路径(启发式,用于阶段门禁检查)。"""
68
+ try:
69
+ tokens = shlex.split(command, posix=True)
70
+ except ValueError:
71
+ tokens = command.split()
72
+ paths: list[str] = []
73
+ n = len(tokens)
74
+ i = 0
75
+ while i < n:
76
+ tok = tokens[i]
77
+ if tok in (">", ">>", "2>", "2>>", "1>", "1>>"):
78
+ if i + 1 < n:
79
+ nxt = tokens[i + 1]
80
+ if not nxt.startswith("&"):
81
+ paths.append(nxt)
82
+ elif tok in ("mv", "cp", "install", "rename"):
83
+ dest = [t for t in tokens[i + 1:] if not t.startswith("-")]
84
+ if dest:
85
+ paths.append(dest[-1])
86
+ elif tok == "sed":
87
+ j = i + 1
88
+ if j < n and tokens[j].startswith("-i"):
89
+ j += 1 # 跳过 -i 或 -i.bak
90
+ while j < n and tokens[j].startswith("-"):
91
+ opt = tokens[j]
92
+ j += 1
93
+ if opt in ("-e", "--expression", "-f", "--file") and j < n:
94
+ j += 1
95
+ if j < n and _looks_like_sed_expr(tokens[j]):
96
+ j += 1 # 跳过 sed 表达式
97
+ for t in tokens[j:]:
98
+ if not t.startswith("-"):
99
+ paths.append(t)
100
+ elif tok in ("rm", "touch", "tee", "truncate", "unlink", "shred"):
101
+ for t in tokens[i + 1:]:
102
+ if t == "--" or t.startswith("-"):
103
+ continue
104
+ paths.append(t)
105
+ i += 1
106
+ return paths
107
+
108
+
109
+ # ---------- 测试输出摘要 ----------
110
+
111
+ def summarize_test_output(
112
+ output: str,
113
+ exit_code: int | None = None,
114
+ *,
115
+ max_tail: int = 4000,
116
+ ) -> dict[str, Any]:
117
+ """把测试命令输出规整为结构化记录:退出码、是否通过、摘要、输出尾部。"""
118
+ text = output or ""
119
+ if exit_code is None:
120
+ passed = bool(re.search(r"\b\d+ passed\b|tests? (passed|ok)\b|All tests? passed", text, re.IGNORECASE))
121
+ if re.search(r"\b\d+ failed\b|FAILED|ERRORS?\b", text, re.IGNORECASE):
122
+ passed = False
123
+ else:
124
+ passed = exit_code == 0
125
+
126
+ lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
127
+ tail = lines[-20:]
128
+ tail_text = "\n".join(tail)[-max_tail:]
129
+
130
+ # 尝试提取 pytest / unittest 风格摘要行
131
+ summary = ""
132
+ for ln in reversed(tail):
133
+ if re.search(r"\b\d+ (passed|failed|error|skipped|warning)\b|tests? (passed|ok|failed)\b|OK|FAILED", ln, re.IGNORECASE):
134
+ summary = ln.strip()
135
+ break
136
+ if not summary and tail:
137
+ summary = tail[-1][:300]
138
+
139
+ return {
140
+ "exit_code": exit_code,
141
+ "passed": passed,
142
+ "summary": summary,
143
+ "output_tail": tail_text,
144
+ }