@hupan56/wlkj 2.0.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 (87) hide show
  1. package/bin/cli.js +213 -0
  2. package/package.json +11 -0
  3. package/templates/cli.js +198 -0
  4. package/templates/qoder/commands/wl-code.md +43 -0
  5. package/templates/qoder/commands/wl-commit.md +30 -0
  6. package/templates/qoder/commands/wl-init.md +80 -0
  7. package/templates/qoder/commands/wl-insight.md +51 -0
  8. package/templates/qoder/commands/wl-prd.md +199 -0
  9. package/templates/qoder/commands/wl-report.md +166 -0
  10. package/templates/qoder/commands/wl-search.md +52 -0
  11. package/templates/qoder/commands/wl-spec.md +18 -0
  12. package/templates/qoder/commands/wl-status.md +51 -0
  13. package/templates/qoder/commands/wl-task.md +71 -0
  14. package/templates/qoder/commands/wl-test.md +42 -0
  15. package/templates/qoder/config.toml +5 -0
  16. package/templates/qoder/config.yaml +141 -0
  17. package/templates/qoder/hooks/inject-workflow-state.py +117 -0
  18. package/templates/qoder/hooks/session-start.py +204 -0
  19. package/templates/qoder/rules/wl-pipeline.md +105 -0
  20. package/templates/qoder/scripts/add_session.py +245 -0
  21. package/templates/qoder/scripts/benchmark.py +209 -0
  22. package/templates/qoder/scripts/build_style_index.py +268 -0
  23. package/templates/qoder/scripts/code_index.py +41 -0
  24. package/templates/qoder/scripts/collect_prds.py +31 -0
  25. package/templates/qoder/scripts/common/__init__.py +0 -0
  26. package/templates/qoder/scripts/common/active_task.py +230 -0
  27. package/templates/qoder/scripts/common/atomicio.py +172 -0
  28. package/templates/qoder/scripts/common/developer.py +161 -0
  29. package/templates/qoder/scripts/common/eval_api.py +144 -0
  30. package/templates/qoder/scripts/common/feishu.py +278 -0
  31. package/templates/qoder/scripts/common/filelock.py +211 -0
  32. package/templates/qoder/scripts/common/identity.py +285 -0
  33. package/templates/qoder/scripts/common/mentions.py +134 -0
  34. package/templates/qoder/scripts/common/paths.py +311 -0
  35. package/templates/qoder/scripts/common/reqid.py +218 -0
  36. package/templates/qoder/scripts/common/search_engine.py +205 -0
  37. package/templates/qoder/scripts/common/task_utils.py +342 -0
  38. package/templates/qoder/scripts/common/terms.py +234 -0
  39. package/templates/qoder/scripts/common/utf8.py +38 -0
  40. package/templates/qoder/scripts/context_pack.py +196 -0
  41. package/templates/qoder/scripts/eval_prd.py +225 -0
  42. package/templates/qoder/scripts/export.py +487 -0
  43. package/templates/qoder/scripts/git_sync.py +1087 -0
  44. package/templates/qoder/scripts/handoff.py +22 -0
  45. package/templates/qoder/scripts/init_developer.py +76 -0
  46. package/templates/qoder/scripts/init_doctor.py +527 -0
  47. package/templates/qoder/scripts/install_qoderwork.py +339 -0
  48. package/templates/qoder/scripts/learn.py +67 -0
  49. package/templates/qoder/scripts/notify.py +5 -0
  50. package/templates/qoder/scripts/parse_prds.py +33 -0
  51. package/templates/qoder/scripts/report.py +281 -0
  52. package/templates/qoder/scripts/role.py +39 -0
  53. package/templates/qoder/scripts/run_weekly_update.bat +17 -0
  54. package/templates/qoder/scripts/run_weekly_update.sh +20 -0
  55. package/templates/qoder/scripts/search_index.py +352 -0
  56. package/templates/qoder/scripts/setup.py +453 -0
  57. package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
  58. package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
  59. package/templates/qoder/scripts/status.py +389 -0
  60. package/templates/qoder/scripts/syncgate.py +330 -0
  61. package/templates/qoder/scripts/task.py +954 -0
  62. package/templates/qoder/scripts/team.py +29 -0
  63. package/templates/qoder/scripts/team_sync.py +419 -0
  64. package/templates/qoder/scripts/workspace_init.py +102 -0
  65. package/templates/qoder/settings.json +53 -0
  66. package/templates/qoder/skills/design-review/SKILL.md +25 -0
  67. package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
  68. package/templates/qoder/skills/prd-review/SKILL.md +36 -0
  69. package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
  70. package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
  71. package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
  72. package/templates/qoder/skills/test-generator/SKILL.md +72 -0
  73. package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
  74. package/templates/qoder/skills/wl-init/SKILL.md +67 -0
  75. package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
  76. package/templates/qoder/skills/wl-report/SKILL.md +87 -0
  77. package/templates/qoder/skills/wl-search/SKILL.md +75 -0
  78. package/templates/qoder/skills/wl-status/SKILL.md +61 -0
  79. package/templates/qoder/skills/wl-task/SKILL.md +58 -0
  80. package/templates/qoder/templates/prd-full-template.md +103 -0
  81. package/templates/qoder/templates/prd-quick-template.md +69 -0
  82. package/templates/qoder/templates/prototype-app.html +344 -0
  83. package/templates/qoder/templates/prototype-web.html +310 -0
  84. package/templates/root/AGENTS.md +182 -0
  85. package/templates/root/README-pipeline.md +56 -0
  86. package/templates/root/ROLES.md +85 -0
  87. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
@@ -0,0 +1,205 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ search_engine.py - 搜索倒排索引 + mtime 缓存 (性能优化 A2)
4
+
5
+ 把 search_keywords 的 O(n) 全扫描 3267 keys 降到 O(命中数)。
6
+
7
+ 设计:
8
+ - exact_index: {word_lower: [keyword_keys]} 精确匹配, O(1) 查找
9
+ - prefix/suffix 索引用 trigram (3-gram) 加速子串匹配
10
+ - 缓存序列化到 data/index/.inverted-cache.json, 含源 mtime
11
+ - 只在 keyword-index.json 变化时重建
12
+
13
+ 核心 API:
14
+ get_inverted_index(repo_root) -> dict # 带缓存的倒排索引
15
+ search_keywords_fast(words, inverted, ki) -> {kw: files} # 快速搜索
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from typing import Dict, List, Optional, Set, Tuple
22
+
23
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
24
+ if _THIS_DIR not in sys.path:
25
+ sys.path.insert(0, _THIS_DIR)
26
+
27
+ from atomicio import safe_read_json, atomic_write_json
28
+
29
+ __all__ = [
30
+ "get_inverted_index",
31
+ "search_keywords_fast",
32
+ "build_inverted_index",
33
+ "MIN_FUZZY_LEN",
34
+ ]
35
+
36
+ MIN_FUZZY_LEN = 4 # 与 search_index.py 保持一致
37
+
38
+
39
+ def _trigrams(s: str) -> Set[str]:
40
+ """生成字符串的所有 3-gram (小写)。短于 3 字符则返回 {字符串本身}。"""
41
+ s = s.lower()
42
+ if len(s) < 3:
43
+ return {s}
44
+ return {s[i:i+3] for i in range(len(s) - 2)}
45
+
46
+
47
+ def build_inverted_index(keyword_index: Dict[str, list]) -> dict:
48
+ """从 keyword_index 构建倒排索引。
49
+
50
+ Returns:
51
+ {
52
+ "exact": {word_lower: [keyword_keys]}, # 精确匹配
53
+ "trigram": {trigram: set(keyword_keys)}, # 3-gram → 含它的 keys
54
+ "keywords": [所有 keyword_keys 排序] # 用于回退全扫描
55
+ }
56
+ """
57
+ exact = {}
58
+ trigram = {}
59
+ all_keys = sorted(keyword_index.keys())
60
+
61
+ for kw in all_keys:
62
+ kl = kw.lower()
63
+ # 精确索引
64
+ exact.setdefault(kl, []).append(kw)
65
+ # trigram 索引 (用于子串匹配的候选集缩小)
66
+ for tg in _trigrams(kl):
67
+ trigram.setdefault(tg, set()).add(kw)
68
+
69
+ # set 转 list 便于 JSON 序列化
70
+ trigram_serializable = {tg: sorted(keys) for tg, keys in trigram.items()}
71
+
72
+ return {
73
+ "exact": exact,
74
+ "trigram": trigram_serializable,
75
+ "keywords": all_keys,
76
+ }
77
+
78
+
79
+ def get_inverted_index(repo_root: str) -> Optional[dict]:
80
+ """获取倒排索引 (带 mtime 缓存)。
81
+
82
+ 缓存逻辑:
83
+ - 读 .inverted-cache.json 的 source_mtime
84
+ - 与 keyword-index.json 当前 mtime 比对
85
+ - 不一致或缓存缺失则重建 + 持久化
86
+
87
+ Returns:
88
+ 倒排索引 dict, 或 None (keyword-index 不存在时)。
89
+ """
90
+ index_dir = os.path.join(repo_root, "data", "index")
91
+ ki_path = os.path.join(index_dir, "keyword-index.json")
92
+ cache_path = os.path.join(index_dir, ".inverted-cache.json")
93
+
94
+ if not os.path.isfile(ki_path):
95
+ return None
96
+
97
+ try:
98
+ ki_mtime = os.path.getmtime(ki_path)
99
+ except OSError:
100
+ return None
101
+
102
+ # 尝试读缓存
103
+ cached = safe_read_json(cache_path, default=None)
104
+ if cached and cached.get("source_mtime") == ki_mtime:
105
+ # 缓存命中 (trigram 的 set 反序列化为 list, 搜索时兼容)
106
+ return cached
107
+
108
+ # 缓存失效或缺失, 重建
109
+ ki = safe_read_json(ki_path, default=None)
110
+ if not ki:
111
+ return None
112
+
113
+ inverted = build_inverted_index(ki)
114
+ inverted["source_mtime"] = ki_mtime
115
+ inverted["built_at"] = os.path.getmtime(ki_path)
116
+
117
+ # 持久化 (best-effort, 失败不阻塞搜索)
118
+ try:
119
+ atomic_write_json(cache_path, inverted)
120
+ except Exception:
121
+ pass
122
+
123
+ return inverted
124
+
125
+
126
+ def _candidate_keys(word: str, inverted: dict) -> List[str]:
127
+ """用倒排索引找到候选 keyword keys (缩小扫描集)。
128
+
129
+ 策略:
130
+ - 用 trigram 交集找候选 (子串匹配的双方至少共享一个 trigram)
131
+ - 精确命中必然包含在 trigram 候选里 (相同字符串共享所有 trigram)
132
+ - 候选为空则回退全部 keys (兜底, 罕见)
133
+
134
+ 关键: 不能在精确命中后短路 —— query "insurance" 还要匹配
135
+ "insurancepolicy" 等 (query 是 kw 的子串), 所以必须返回 trigram 候选集。
136
+ """
137
+ wl = word.lower()
138
+ tgs = _trigrams(wl)
139
+ trigram_index = inverted.get("trigram", {})
140
+ candidate_sets = []
141
+ for tg in tgs:
142
+ keys = trigram_index.get(tg)
143
+ if keys:
144
+ candidate_sets.append(set(keys))
145
+
146
+ if not candidate_sets:
147
+ # 词太短无 trigram (如 2 字符), 回退精确 + 全扫描兜底
148
+ exact = inverted.get("exact", {}).get(wl)
149
+ if exact:
150
+ return list(exact)
151
+ return inverted.get("keywords", [])
152
+
153
+ # 取并集: query 含 kw 或 kw 含 query, 至少一个 trigram 重叠
154
+ candidates = set()
155
+ for cs in candidate_sets:
156
+ candidates |= cs
157
+
158
+ return sorted(candidates)
159
+
160
+
161
+ def search_keywords_fast(
162
+ words: List[str],
163
+ inverted: Optional[dict],
164
+ keyword_index: Dict[str, list],
165
+ ) -> Dict[str, list]:
166
+ """快速关键词搜索 (用倒排索引缩小候选集)。
167
+
168
+ Args:
169
+ words: 查询词列表 (已小写化、已 CN 扩展)。
170
+ inverted: 倒排索引 (None 则回退到全扫描)。
171
+ keyword_index: 原始 keyword-index (用于取 files)。
172
+
173
+ Returns:
174
+ {keyword_key: files} 匹配结果。
175
+ """
176
+ matches = {}
177
+
178
+ if inverted is None:
179
+ # 回退: 全扫描 (旧逻辑)
180
+ for word in words:
181
+ for kw, files in keyword_index.items():
182
+ if _match(word, kw):
183
+ matches[kw] = files
184
+ return matches
185
+
186
+ for word in words:
187
+ candidates = _candidate_keys(word, inverted)
188
+ for kw in candidates:
189
+ files = keyword_index.get(kw)
190
+ if files and _match(word, kw):
191
+ matches[kw] = files
192
+
193
+ return matches
194
+
195
+
196
+ def _match(query_word: str, kw: str) -> bool:
197
+ """与 search_index._match_keyword 一致的匹配逻辑。"""
198
+ w, k = query_word.lower(), kw.lower()
199
+ if w == k:
200
+ return True
201
+ if len(k) >= MIN_FUZZY_LEN and k in w:
202
+ return True
203
+ if len(w) >= MIN_FUZZY_LEN and w in k:
204
+ return True
205
+ return False
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ QODER Pipeline - 任务工具函数
5
+
6
+ 提供:
7
+ - resolve_task_dir: 解析任务目录 (支持名称/相对路径/绝对路径)
8
+ - run_task_hooks: 运行任务生命周期钩子
9
+ - load_task_json: 加载 task.json
10
+ - write_task_json: 写入 task.json
11
+
12
+ 参考: Trellis 的 common/task_utils.py 设计
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .paths import DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_tasks_dir
24
+
25
+ # 原子写 (解决半写损坏)
26
+ try:
27
+ from .atomicio import atomic_write_json
28
+ except ImportError:
29
+ def atomic_write_json(path, data, indent=2, ensure_ascii=False, sort_keys=True):
30
+ import json as _j
31
+ Path(path).write_text(_j.dumps(data, indent=indent, ensure_ascii=ensure_ascii,
32
+ sort_keys=sort_keys) + "\n", encoding="utf-8")
33
+
34
+
35
+ # =============================================================================
36
+ # 任务目录解析
37
+ # =============================================================================
38
+
39
+ def resolve_task_dir(task_input: str, repo_root: Path | None = None) -> Path:
40
+ """解析任务目录路径。
41
+
42
+ 支持三种输入:
43
+ 1. 纯名称/全名: "my-task" / "06-10-my-task" -> workspace/tasks/<name>
44
+ 2. 相对路径: "workspace/tasks/06-10-my-task" -> 完整路径
45
+ 3. 绝对路径: 直接使用
46
+
47
+ 安全变更 (零信任): 不再做模糊子串匹配 (旧版 "a" 可能匹配到他人的
48
+ "06-10-alpha" 导致误操作)。现在要求精确名或唯一前缀。
49
+
50
+ Args:
51
+ task_input: 任务标识 (名称或路径)。
52
+ repo_root: 项目根目录, 默认自动检测。
53
+
54
+ Returns:
55
+ 任务目录的绝对路径。
56
+
57
+ Raises:
58
+ FileNotFoundError: 找不到任务。
59
+ ValueError: 名称歧义 (多个候选)。
60
+ """
61
+ if repo_root is None:
62
+ repo_root = get_repo_root()
63
+
64
+ input_path = Path(task_input)
65
+
66
+ # 已经是绝对路径且存在
67
+ if input_path.is_absolute() and input_path.is_dir():
68
+ return input_path.resolve()
69
+
70
+ # 相对路径
71
+ full_from_root = (repo_root / task_input).resolve()
72
+ if full_from_root.is_dir():
73
+ return full_from_root
74
+
75
+ # 纯名称: 在 tasks 目录下精确匹配
76
+ tasks_dir = get_tasks_dir(repo_root)
77
+ direct_match = tasks_dir / task_input
78
+ if direct_match.is_dir():
79
+ return direct_match.resolve()
80
+
81
+ # 精确匹配失败 —— 查唯一前缀匹配 (不允许歧义)
82
+ if tasks_dir.is_dir():
83
+ candidates = [
84
+ d for d in tasks_dir.iterdir()
85
+ if d.is_dir() and (
86
+ d.name == task_input or # 全名精确
87
+ d.name.endswith("-" + task_input) or # "06-10-my-task".endswith("-my-task")
88
+ d.name.startswith(task_input + "-") # "06-10".startswith("06-10-...")
89
+ )
90
+ ]
91
+ if len(candidates) == 1:
92
+ return candidates[0].resolve()
93
+ if len(candidates) > 1:
94
+ names = ", ".join(d.name for d in candidates[:5])
95
+ raise ValueError(
96
+ "任务名 '%s' 歧义, 匹配到 %d 个: %s。请用完整任务名。"
97
+ % (task_input, len(candidates), names)
98
+ )
99
+
100
+ # 不存在 —— 返回原始路径 (调用方决定怎么报错)
101
+ return tasks_dir / task_input
102
+
103
+
104
+ # =============================================================================
105
+ # 任务 JSON 读写
106
+ # =============================================================================
107
+
108
+ def load_task_json(task_dir: Path) -> dict | None:
109
+ """加载 task.json 文件。
110
+
111
+ Args:
112
+ task_dir: 任务目录路径。
113
+
114
+ Returns:
115
+ 解析后的 dict, 文件不存在或解析失败返回 None。
116
+ """
117
+ task_json = task_dir / FILE_TASK_JSON
118
+ if not task_json.is_file():
119
+ return None
120
+
121
+ try:
122
+ return json.loads(task_json.read_text(encoding="utf-8"))
123
+ except (json.JSONDecodeError, OSError, IOError):
124
+ return None
125
+
126
+
127
+ def write_task_json(task_dir: Path, data: dict) -> bool:
128
+ """原子写入 task.json 文件 (temp + os.replace, 写失败原文件不变)。
129
+
130
+ Args:
131
+ task_dir: 任务目录路径。
132
+ data: 要写入的数据。
133
+
134
+ Returns:
135
+ 成功返回 True。
136
+ """
137
+ task_json = task_dir / FILE_TASK_JSON
138
+ try:
139
+ # 原子写: sort_keys=False 保留插入顺序 (task.json 字段顺序有意义)
140
+ atomic_write_json(
141
+ str(task_json), data,
142
+ indent=2, ensure_ascii=False, sort_keys=False,
143
+ )
144
+ return True
145
+ except (OSError, IOError) as e:
146
+ print(f"Warning: Failed to write task.json: {e}", file=sys.stderr)
147
+ return False
148
+
149
+
150
+ # =============================================================================
151
+ # ACL: 任务操作权限校验 (零信任)
152
+ # =============================================================================
153
+
154
+ def assert_can_modify_task(
155
+ task_dir: Path,
156
+ action: str,
157
+ repo_root: Path | None = None,
158
+ ) -> str:
159
+ """校验当前开发者是否有权修改该任务。
160
+
161
+ 零信任 ACL: 只有 creator / assignee / admin 角色能修改。
162
+ 其他人 start/finish/archive 别人的任务会被拒绝。
163
+
164
+ Args:
165
+ task_dir: 任务目录。
166
+ action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
167
+ repo_root: 项目根。
168
+
169
+ Returns:
170
+ 当前开发者名 (校验通过)。
171
+
172
+ Raises:
173
+ PermissionError: 无权操作。
174
+ """
175
+ if repo_root is None:
176
+ repo_root = get_repo_root()
177
+ repo_root = Path(repo_root) # 统一为 Path
178
+
179
+ # 读当前开发者
180
+ try:
181
+ from .paths import get_developer
182
+ except ImportError:
183
+ from paths import get_developer # type: ignore
184
+ dev = get_developer(repo_root)
185
+ if not dev:
186
+ raise PermissionError(
187
+ "[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
188
+ )
189
+
190
+ task = load_task_json(task_dir)
191
+ if not task:
192
+ # 任务不存在 —— 让上层处理, 这里放行 (创建场景)
193
+ return dev
194
+
195
+ creator = (task.get("creator") or "").strip()
196
+ assignee = (task.get("assignee") or "").strip()
197
+ authorized = {creator, assignee}
198
+ authorized.discard("")
199
+
200
+ # admin 角色可操作任意任务
201
+ try:
202
+ from .identity import get_member
203
+ m = get_member(dev, repo_root)
204
+ if m and m.get("role") == "admin":
205
+ return dev
206
+ except Exception:
207
+ pass # identity 模块不可用则退化为只看 creator/assignee
208
+
209
+ if dev not in authorized:
210
+ raise PermissionError(
211
+ "[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
212
+ "只有任务负责人或 admin 可操作。"
213
+ % (action, task_dir.name, dev, creator or "?", assignee or "?")
214
+ )
215
+ return dev
216
+
217
+
218
+ # =============================================================================
219
+ # 生命周期钩子
220
+ # =============================================================================
221
+
222
+ def run_task_hooks(
223
+ hook_name: str,
224
+ task_json_path: Path,
225
+ repo_root: Path | None = None,
226
+ ) -> None:
227
+ """运行任务生命周期钩子。
228
+
229
+ 从 config.yaml 读取钩子配置并执行。
230
+
231
+ Args:
232
+ hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
233
+ task_json_path: task.json 的路径。
234
+ repo_root: 项目根目录, 默认自动检测。
235
+ """
236
+ if repo_root is None:
237
+ repo_root = get_repo_root()
238
+
239
+ config_path = repo_root / ".qoder" / "config.yaml"
240
+ if not config_path.is_file():
241
+ return
242
+
243
+ # 简单的 YAML 解析 (不依赖 PyYAML)
244
+ try:
245
+ import yaml
246
+ with open(config_path, "r", encoding="utf-8") as f:
247
+ config = yaml.safe_load(f)
248
+ except ImportError:
249
+ # 没有 PyYAML, 用简单解析
250
+ config = _simple_yaml_parse(config_path)
251
+
252
+ if not config:
253
+ return
254
+
255
+ hooks = config.get("hooks", {})
256
+ commands = hooks.get(hook_name, [])
257
+ if not commands:
258
+ return
259
+
260
+ env = os.environ.copy()
261
+ env["TASK_JSON_PATH"] = str(task_json_path)
262
+
263
+ for cmd in commands:
264
+ try:
265
+ print(f" Running hook [{hook_name}]: {cmd}")
266
+ result = subprocess.run(
267
+ cmd,
268
+ shell=True,
269
+ cwd=str(repo_root),
270
+ env=env,
271
+ capture_output=True,
272
+ text=True,
273
+ timeout=30,
274
+ )
275
+ if result.returncode != 0:
276
+ print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
277
+ except subprocess.TimeoutExpired:
278
+ print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
279
+ except Exception as e:
280
+ print(f" Warning: Hook error: {e}", file=sys.stderr)
281
+
282
+
283
+ def _simple_yaml_parse(path: Path) -> dict:
284
+ """简单的 YAML 解析器 (不依赖外部库)。
285
+
286
+ 只支持两级嵌套和列表。
287
+
288
+ Args:
289
+ path: YAML 文件路径。
290
+
291
+ Returns:
292
+ 解析后的 dict。
293
+ """
294
+ result = {}
295
+ current_key = None
296
+ current_list = None
297
+
298
+ try:
299
+ lines = path.read_text(encoding="utf-8").splitlines()
300
+ except (OSError, IOError):
301
+ return result
302
+
303
+ for line in lines:
304
+ stripped = line.strip()
305
+ if not stripped or stripped.startswith("#"):
306
+ continue
307
+
308
+ indent = len(line) - len(line.lstrip())
309
+
310
+ if indent == 0 and ":" in stripped and not stripped.startswith("-"):
311
+ # 顶层 key
312
+ if current_key and current_list is not None:
313
+ result[current_key] = current_list
314
+ key, _, value = stripped.partition(":")
315
+ current_key = key.strip()
316
+ current_list = None
317
+ if value.strip():
318
+ result[current_key] = value.strip()
319
+ current_key = None
320
+
321
+ elif indent > 0 and stripped.startswith("- "):
322
+ # 列表项
323
+ if current_list is None:
324
+ current_list = []
325
+ item = stripped[2:].strip().strip("'\"")
326
+ current_list.append(item)
327
+
328
+ elif indent > 0 and ":" in stripped and current_key:
329
+ # 子键值对
330
+ if current_list is not None:
331
+ result[current_key] = current_list
332
+ current_list = None
333
+ key, _, value = stripped.partition(":")
334
+ if isinstance(result.get(current_key), dict):
335
+ result[current_key][key.strip()] = value.strip().strip("'\"")
336
+ else:
337
+ result[current_key] = {key.strip(): value.strip().strip("'\"")}
338
+
339
+ if current_key and current_list is not None:
340
+ result[current_key] = current_list
341
+
342
+ return result