@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.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli.js +437 -0
- package/package.json +52 -0
- package/templates/AGENTS.section.md +41 -0
- package/templates/agents-skills/finley-memory/SKILL.md +60 -0
- package/templates/agents-skills/finley-quality-gate/SKILL.md +62 -0
- package/templates/finley/config.yaml +51 -0
- package/templates/finley/scripts/add_session.py +280 -0
- package/templates/finley/scripts/gate.py +281 -0
- package/templates/finley/workspace/.gitkeep +0 -0
|
@@ -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
|