@finleywdx/ai-init 0.1.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.
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Finley 质量门禁 —— 在提交前统一跑 lint / typecheck / test。
4
+
5
+ 用法:
6
+ python .finley/scripts/gate.py # 前端 + 后端全部门禁
7
+ python .finley/scripts/gate.py --only frontend # 只跑前端
8
+ python .finley/scripts/gate.py --only backend # 只跑后端
9
+ python .finley/scripts/gate.py --dry-run # 只打印将要执行的命令,不真正运行
10
+
11
+ 命令来源:
12
+ 1. 读取 .finley/config.yaml 的 quality.frontend / quality.backend;
13
+ 2. 若某条命令是占位(形如 "<占位: ...>"),则自动探测:
14
+ - 前端:读 package.json 的 scripts(lint / typecheck|type-check / test);
15
+ - 后端:读 pyproject.toml,检测 ruff / mypy / pytest。
16
+
17
+ 任一命令失败 -> 整体门禁失败(进程非零退出)并清晰列出失败项。
18
+ 纯标准库实现(json / tomllib / subprocess / argparse),不依赖任何第三方包。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ try:
31
+ import tomllib # Python 3.11+
32
+ except ModuleNotFoundError: # pragma: no cover - 仅在 <3.11 触发
33
+ print("[Finley] 需要 Python 3.11+(缺少标准库 tomllib)。", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+
37
+ PLACEHOLDER_PREFIX = "<占位"
38
+
39
+
40
+ # =============================================================================
41
+ # 极简 YAML 读取(仅支持 Finley config.yaml 的已知结构:两级嵌套 + 标量字符串)
42
+ # =============================================================================
43
+
44
+ def _strip_inline_comment(value: str) -> str:
45
+ """去掉未被引号包裹的行内注释。"""
46
+ in_single = in_double = False
47
+ for i, ch in enumerate(value):
48
+ if ch == "'" and not in_double:
49
+ in_single = not in_single
50
+ elif ch == '"' and not in_single:
51
+ in_double = not in_double
52
+ elif ch == "#" and not in_single and not in_double:
53
+ return value[:i]
54
+ return value
55
+
56
+
57
+ def _unquote(value: str) -> str:
58
+ value = value.strip()
59
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
60
+ return value[1:-1]
61
+ return value
62
+
63
+
64
+ def load_simple_yaml(path: Path) -> dict:
65
+ """解析 Finley 自有的简单 YAML(缩进两空格、key: value、字符串/整数标量)。
66
+
67
+ 这是一个针对已知 schema 的最小实现,不追求覆盖完整 YAML 规范。
68
+ """
69
+ root: dict = {}
70
+ # 缩进栈:[(indent, container_dict), ...]
71
+ stack: list[tuple[int, dict]] = [(-1, root)]
72
+
73
+ for raw in path.read_text(encoding="utf-8").splitlines():
74
+ line = _strip_inline_comment(raw).rstrip()
75
+ if not line.strip():
76
+ continue
77
+
78
+ indent = len(line) - len(line.lstrip(" "))
79
+ stripped = line.strip()
80
+ if ":" not in stripped:
81
+ continue
82
+
83
+ key, _, rest = stripped.partition(":")
84
+ key = key.strip()
85
+ rest = rest.strip()
86
+
87
+ # 回退到正确的父容器
88
+ while stack and indent <= stack[-1][0]:
89
+ stack.pop()
90
+ if not stack:
91
+ stack = [(-1, root)]
92
+ parent = stack[-1][1]
93
+
94
+ if rest == "":
95
+ # 新的嵌套映射
96
+ child: dict = {}
97
+ parent[key] = child
98
+ stack.append((indent, child))
99
+ else:
100
+ value = _unquote(_strip_inline_comment(rest))
101
+ if value.isdigit():
102
+ parent[key] = int(value)
103
+ else:
104
+ parent[key] = value
105
+
106
+ return root
107
+
108
+
109
+ # =============================================================================
110
+ # 前端 / 后端命令探测
111
+ # =============================================================================
112
+
113
+ def is_placeholder(cmd: Optional[str]) -> bool:
114
+ return not cmd or cmd.strip() == "" or cmd.strip().startswith(PLACEHOLDER_PREFIX)
115
+
116
+
117
+ def detect_pkg_runner(repo_root: Path) -> str:
118
+ """根据锁文件推断包管理器命令前缀。"""
119
+ if (repo_root / "pnpm-lock.yaml").exists():
120
+ return "pnpm"
121
+ if (repo_root / "yarn.lock").exists():
122
+ return "yarn"
123
+ if (repo_root / "bun.lockb").exists():
124
+ return "bun"
125
+ return "npm"
126
+
127
+
128
+ def detect_frontend(repo_root: Path, kind: str) -> Optional[str]:
129
+ """从 package.json 的 scripts 探测前端命令。kind ∈ {lint, typecheck, test}。"""
130
+ pkg_path = repo_root / "package.json"
131
+ if not pkg_path.exists():
132
+ return None
133
+ try:
134
+ scripts = json.loads(pkg_path.read_text(encoding="utf-8")).get("scripts", {})
135
+ except (json.JSONDecodeError, OSError):
136
+ return None
137
+
138
+ runner = detect_pkg_runner(repo_root)
139
+
140
+ if kind == "lint":
141
+ candidates = ["lint"]
142
+ elif kind == "typecheck":
143
+ candidates = ["typecheck", "type-check", "tsc"]
144
+ elif kind == "test":
145
+ candidates = ["test"]
146
+ else:
147
+ candidates = []
148
+
149
+ for name in candidates:
150
+ if name in scripts:
151
+ if name == "test":
152
+ # npm/yarn/pnpm/bun 都支持简写
153
+ return f"{runner} test"
154
+ return f"{runner} run {name}"
155
+ return None
156
+
157
+
158
+ def detect_backend(repo_root: Path, kind: str) -> Optional[str]:
159
+ """从 pyproject.toml 探测后端命令。kind ∈ {lint, typecheck, test}。"""
160
+ pyproject = repo_root / "pyproject.toml"
161
+ if not pyproject.exists():
162
+ return None
163
+ try:
164
+ with pyproject.open("rb") as f:
165
+ data = tomllib.load(f)
166
+ except (tomllib.TOMLDecodeError, OSError):
167
+ return None
168
+
169
+ tool = data.get("tool", {}) if isinstance(data.get("tool"), dict) else {}
170
+
171
+ # 收集所有依赖声明字符串,便于关键字探测
172
+ deps_blob = json.dumps(data.get("project", {}).get("dependencies", []))
173
+ optional = data.get("project", {}).get("optional-dependencies", {})
174
+ if isinstance(optional, dict):
175
+ for group in optional.values():
176
+ deps_blob += json.dumps(group)
177
+ # PDM/Poetry 等的依赖组
178
+ deps_blob += json.dumps(tool.get("poetry", {}).get("dependencies", {}))
179
+ deps_blob = deps_blob.lower()
180
+
181
+ def has(name: str) -> bool:
182
+ return name in tool or name in deps_blob
183
+
184
+ if kind == "lint" and has("ruff"):
185
+ return "ruff check ."
186
+ if kind == "typecheck" and has("mypy"):
187
+ return "mypy ."
188
+ if kind == "test" and ("pytest" in tool or "pytest" in deps_blob):
189
+ return "pytest"
190
+ return None
191
+
192
+
193
+ # =============================================================================
194
+ # 门禁执行
195
+ # =============================================================================
196
+
197
+ def resolve_commands(repo_root: Path, config: dict, side: str) -> list[tuple[str, str]]:
198
+ """解析出某一侧(frontend/backend)要跑的 (标签, 命令) 列表。"""
199
+ quality = config.get("quality", {}) if isinstance(config.get("quality"), dict) else {}
200
+ section = quality.get(side, {}) if isinstance(quality.get(side), dict) else {}
201
+
202
+ resolved: list[tuple[str, str]] = []
203
+ for kind in ("lint", "typecheck", "test"):
204
+ cmd = section.get(kind)
205
+ if is_placeholder(cmd):
206
+ detector = detect_frontend if side == "frontend" else detect_backend
207
+ cmd = detector(repo_root, kind)
208
+ if cmd and not is_placeholder(cmd):
209
+ resolved.append((f"{side}:{kind}", cmd))
210
+ return resolved
211
+
212
+
213
+ def run_command(label: str, cmd: str, repo_root: Path, dry_run: bool) -> bool:
214
+ print(f"\n\033[1m▶ [{label}]\033[0m {cmd}")
215
+ if dry_run:
216
+ print(" (dry-run 跳过执行)")
217
+ return True
218
+ result = subprocess.run(cmd, shell=True, cwd=str(repo_root))
219
+ ok = result.returncode == 0
220
+ if ok:
221
+ print(f" \033[32m✔ 通过\033[0m [{label}]")
222
+ else:
223
+ print(f" \033[31m✗ 失败\033[0m [{label}](退出码 {result.returncode})")
224
+ return ok
225
+
226
+
227
+ def main() -> int:
228
+ parser = argparse.ArgumentParser(description="Finley 质量门禁:统一跑 lint / typecheck / test")
229
+ parser.add_argument("--only", choices=["frontend", "backend"], help="只运行某一侧")
230
+ parser.add_argument("--dry-run", action="store_true", help="只打印命令,不真正执行")
231
+ args = parser.parse_args()
232
+
233
+ script_dir = Path(__file__).resolve().parent
234
+ repo_root = script_dir.parent.parent # .finley/scripts/gate.py -> repo root
235
+ config_path = repo_root / ".finley" / "config.yaml"
236
+
237
+ if not config_path.exists():
238
+ print(f"[Finley] 未找到配置文件:{config_path}", file=sys.stderr)
239
+ return 1
240
+
241
+ config = load_simple_yaml(config_path)
242
+
243
+ sides = [args.only] if args.only else ["frontend", "backend"]
244
+ all_commands: list[tuple[str, str]] = []
245
+ for side in sides:
246
+ all_commands.extend(resolve_commands(repo_root, config, side))
247
+
248
+ print("=" * 60)
249
+ print("Finley 质量门禁")
250
+ print("=" * 60)
251
+
252
+ if not all_commands:
253
+ print(
254
+ "\n[Finley] 未解析到任何可执行的门禁命令。\n"
255
+ " → 请在 .finley/config.yaml 的 quality.frontend / quality.backend 里\n"
256
+ " 填入真实命令,或确保 package.json / pyproject.toml 里存在可被探测的脚本。",
257
+ file=sys.stderr,
258
+ )
259
+ # 没有命令视为门禁未配置:非零退出,避免「假绿」。
260
+ return 2
261
+
262
+ failures: list[str] = []
263
+ for label, cmd in all_commands:
264
+ if not run_command(label, cmd, repo_root, args.dry_run):
265
+ failures.append(label)
266
+
267
+ print("\n" + "=" * 60)
268
+ if failures:
269
+ print(f"\033[31m门禁失败\033[0m:{len(failures)}/{len(all_commands)} 项未通过")
270
+ for label in failures:
271
+ print(f" ✗ {label}")
272
+ print("=" * 60)
273
+ return 1
274
+
275
+ print(f"\033[32m门禁全部通过\033[0m:{len(all_commands)}/{len(all_commands)} 项")
276
+ print("=" * 60)
277
+ return 0
278
+
279
+
280
+ if __name__ == "__main__":
281
+ sys.exit(main())
File without changes