diff --git a/.gitea/ci/sync_superpowers.sh b/.gitea/ci/sync_superpowers.sh index 10ed891b..fd6c5c91 100644 --- a/.gitea/ci/sync_superpowers.sh +++ b/.gitea/ci/sync_superpowers.sh @@ -60,58 +60,7 @@ done printf "%s\n" "${names[@]}" | sort > "$SUPERPOWERS_LIST" -update_block() { - local file="$1" - local start="" - local end="" - local tmp - - tmp="$(mktemp)" - { - echo "### Third-party Skills (superpowers)" - echo "" - echo "$start" - while IFS= read -r name; do - [ -n "$name" ] || continue - echo "- $name" - done < "$SUPERPOWERS_LIST" - echo "$end" - } > "$tmp" - - if grep -q "$start" "$file"; then - awk -v start="$start" -v end="$end" -v block="$tmp" ' - BEGIN { - while ((getline line < block) > 0) { buf[++n] = line } - close(block) - inblock=0 - replaced=0 - } - { - if (!replaced && $0 == start) { - for (i=1; i<=n; i++) print buf[i] - inblock=1 - replaced=1 - next - } - if (inblock) { - if ($0 == end) { inblock=0 } - next - } - print - } - ' "$file" > "${file}.tmp" - mv "${file}.tmp" "$file" - else - echo "" >> "$file" - cat "$tmp" >> "$file" - fi - - rm -f "$tmp" -} - -update_block "SKILLS.md" - -git add codex/skills SKILLS.md "$SUPERPOWERS_LIST" +git add codex/skills "$SUPERPOWERS_LIST" if git diff --cached --quiet; then echo "No changes to sync." diff --git a/.gitignore b/.gitignore index 9e0016ff..dc7ba621 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ tags reports/ .worktrees/ + +scripts/__pycache__ +tests/__pycache__ +tests/cli/__pycache__ diff --git a/SKILLS.md b/SKILLS.md index 8c0b5ee7..62e29da9 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -159,32 +159,6 @@ python docs/standards/playbook/scripts/playbook.py -config playbook.toml ## 9. Third-party Skills (superpowers) 来源:`codex/skills/.sources/superpowers.list`(第三方来源清单)。 -本节仅列出 superpowers 体系 skills,与本 Playbook 原生 skills 分离。 - -### Third-party Skills (superpowers) - -### Third-party Skills (superpowers) - -### Third-party Skills (superpowers) - -### Third-party Skills (superpowers) - - -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ -- \ - --- diff --git a/repo-issues.md b/repo-issues.md new file mode 100644 index 00000000..44495ea2 --- /dev/null +++ b/repo-issues.md @@ -0,0 +1,326 @@ +# 仓库问题清单 + +本文档整理当前仓库中已经确认的问题,重点覆盖可复现现象、根因、影响范围和建议修复方向。 + +## 适用范围 + +- 仓库:`playbook` +- 分析时间:2026-03-09 +- 当前分析环境:Windows + PowerShell + Python 3.12 + +## 问题总览 + +| ID | 严重级别 | 主题 | 主要影响 | +| --- | -------- | ---------------------------------------------- | --------------------------------------- | +| 1 | 高 | Windows 下 TOML 配置解析失效 | 大量 CLI 功能无法执行 | +| 2 | 中 | `SKILLS.md` 中 Third-party 内容重复维护 | 文档容易漂移,测试契约错误 | +| 3 | 中 | `.agents/index.md` 不会随语言集合更新 | 生成产物前后不一致 | +| 4 | 低 | 本地验证文档默认依赖 POSIX shell | 平台前提不清晰,易误导新环境用户 | +| 5 | 低 | `load_config()` 真实入口测试曾缺失(现已补齐) | 当前无需增加 Windows runner | +| 6 | 低 | Python 缓存忽略已补上(现已缓解) | 当前工作区不会再因测试产生 pycache 噪音 | + +## 问题 1:Windows 下 TOML 配置解析失效 + +### 位置 + +- `scripts/playbook.py` +- 关键入口: + - `load_config()` + - `main()` + +### 现象 + +在当前 Windows 环境中,`playbook.py` 使用 `tomllib.loads()` 解析配置文件时,会因为双引号字符串中的反斜杠路径而直接抛出异常。 + +典型错误: + +```text +tomllib.TOMLDecodeError: Invalid hex value (at line 3, column 21) +``` + +### 根因 + +当前逻辑是: + +1. 只要运行环境存在 `tomllib`,就直接调用 `tomllib.loads(raw)`。 +2. Windows 临时目录路径通常形如 `D:\...\tmp`。 +3. 这些路径被直接写进 TOML 的双引号字符串后,反斜杠会被 TOML 当作转义前缀。 +4. 结果在真正执行任何动作之前就解析失败。 + +仓库虽然实现了 `loads_toml_minimal()`,但只有 `tomllib` 不存在时才会启用;`tomllib` 存在但解析失败时不会回退。 + +### 影响 + +会阻断以下动作的正常执行: + +- `vendor` +- `sync_memory_bank` +- `sync_rules` +- `sync_prompts` +- `sync_standards` +- `install_skills` +- `format_md` + +### 证据 + +- `scripts/playbook.py` +- `tests/cli/test_playbook_cli.py` +- `tests/test_format_md_action.py` +- `tests/test_gitattributes_modes.py` +- `tests/test_no_backup_flags.py` +- `tests/test_sync_directory_actions.py` +- `tests/test_sync_templates_placeholders.py` +- `tests/test_vendor_snapshot_templates.py` + +### 修复建议 + +- 在 `tomllib.TOMLDecodeError` 时回退到 `loads_toml_minimal()`。 +- 或统一要求 Windows 路径在 TOML 中使用单引号或双反斜杠。 +- 最好同时补针对 `load_config()` 的 Windows 路径测试,而不是只测备用解析器。 + +## 问题 2:`SKILLS.md` 中 Third-party 内容重复维护 + +### 位置 + +- `SKILLS.md` +- `codex/skills/.sources/superpowers.list` +- `.gitea/ci/sync_superpowers.sh` +- `tests/test_superpowers_list_sync.py` + +### 现象 + +`SKILLS.md` 的 Third-party Skills (superpowers) 一节同时承担了两种职责: + +1. 声明第三方技能的来源是 `codex/skills/.sources/superpowers.list` +2. 在文档内部再次内嵌一份 third-party skills 列表 + +这种设计会制造两份需要同步维护的信息源;一旦同步脚本没有正确执行或生成产物未提交,文档内容和来源清单就会漂移,测试也会随之失败。 + +### 根因 + +Third-party skills 的唯一真相来源本应是 `codex/skills/.sources/superpowers.list`,但仓库同时又要求 `.gitea/ci/sync_superpowers.sh` 把这份列表回写进 `SKILLS.md`。文档承担了“路由页”和“列表副本”两种角色,导致重复维护。 + +### 影响 + +- `SKILLS.md` 容易与真实来源清单失步。 +- 一致性测试会围绕错误契约失败。 +- vendoring 快照会携带多余且容易过期的 third-party 列表副本。 + +### 证据 + +- `SKILLS.md` +- `codex/skills/.sources/superpowers.list` +- `tests/test_superpowers_list_sync.py` +- `.gitea/ci/sync_superpowers.sh` + +### 修复建议 + +- 保留 `SKILLS.md`,但将 Third-party Skills (superpowers) 一节降级为路由页。 +- 在 `SKILLS.md` 中仅保留来源说明:`codex/skills/.sources/superpowers.list`。 +- 停止由同步脚本向 `SKILLS.md` 回写 third-party skills 列表。 +- 将测试契约改为验证“route-only”,而不是验证 `SKILLS.md` 内嵌列表与来源清单完全一致。 + +## 问题 3:`.agents/index.md` 不会随语言集合更新 + +### 位置 + +- `scripts/playbook.py` +- 关键函数: + - `sync_standards_action()` + - `create_agents_index()` + +### 现象 + +首次执行 `sync_standards` 时会创建 `.agents/index.md`。但之后如果同步语言集合发生变化,这个文件不会刷新。 + +实测场景: + +1. 第一次同步:`langs = ["tsl"]` +2. 第二次同步:`langs = ["tsl", "cpp"]` + +结果: + +- `AGENTS.md` 已更新为同时列出 TSL 和 C++ +- `.agents/index.md` 仍然只保留首次创建时的 TSL 入口 + +### 根因 + +`create_agents_index()` 中只要发现 `.agents/index.md` 已存在,就直接返回,不会执行任何重写或区块更新。 + +### 影响 + +- 同一次同步产物内部不一致。 +- `AGENTS.md` 和 `.agents/index.md` 会长期漂移。 +- 后续代理或人工阅读时,可能误以为只有单语言规则生效。 + +### 证据 + +- `scripts/playbook.py` +- 手工复现实验:两次连续执行 `sync_standards` + +### 修复建议 + +- 把 `.agents/index.md` 改成幂等重生成。 +- 或为它增加类似 `AGENTS.md` 的区块更新机制。 +- 最好补一个回归测试,覆盖“第二次同步增加语言”的场景。 + +## 问题 4:本地验证文档默认依赖 POSIX shell,缺少平台前提说明 + +### 位置 + +- `CONTRIBUTING.md` +- `tests/README.md` +- `tests/templates/*.sh` +- `tests/integration/check_doc_links.sh` + +### 现象 + +仓库文档默认要求执行多条 `sh ...` 命令,例如: + +```text +sh tests/integration/check_doc_links.sh +``` + +对于未安装 Git Bash / WSL / Git for Windows 的 Windows 环境,这类命令会直接失败,例如: + +```text +sh: The term 'sh' is not recognized +``` + +当前分析环境中 `sh` 实际可用(来自 Git for Windows),因此上述报错在本机未复现。问题的核心不在于“仓库必然无法在 Windows 运行”,而在于文档没有明确说明这些检查默认依赖 POSIX shell。 + +### 根因 + +模板验证和文档检查采用 POSIX shell 脚本实现,且文档没有明确声明运行这些命令需要 `sh` 环境(例如 Git Bash / WSL / Git for Windows)。仓库也没有提供 PowerShell 或 Python 的替代入口。 + +### 影响 + +- 新环境用户可能误以为任意 Windows PowerShell 都能直接执行这些本地检查。 +- 实际开发体验与 CI(Linux)存在环境前提差异,但文档没有明确提示。 +- 这是文档说明问题,不是当前部署链路或 CI 链路的阻塞故障。 + +### 证据 + +- `tests/README.md` +- `CONTRIBUTING.md` +- `tests/templates/validate_python_templates.sh` +- `tests/templates/validate_cpp_templates.sh` +- `tests/templates/validate_ci_templates.sh` +- `tests/templates/validate_project_templates.sh` +- `tests/integration/check_doc_links.sh` + +### 修复建议 + +- 在 `CONTRIBUTING.md` 和 `tests/README.md` 中明确说明这些本地检查默认需要 `sh` 环境。 +- 推荐使用 Git Bash / WSL / Git for Windows,并说明 CI 以 Linux 为准。 +- 只有在仓库明确要支持 Windows 本地开发时,再考虑补充 PowerShell 或 Python 替代入口。 + +## 问题 5:`load_config()` 真实入口测试曾缺失(现已补齐) + +### 位置 + +- `.gitea/workflows/test.yml` +- `scripts/playbook.py` +- `tests/test_toml_edge_cases.py` + +### 现象 + +这个问题按原描述“CI 只跑 Ubuntu,因此 Windows 回归会长期漏检”现在已经不完全成立。 + +当前仓库虽然仍然只有 `ubuntu-22.04` runner,但已经补上了针对主入口 `load_config()` 的回归测试,而且测试输入直接使用 Windows 风格路径。由于这里的故障点是 TOML 字符串解析,而不是 Windows 系统调用,这类测试在 Linux 上同样能有效守住回归。 + +### 根因 + +原先真正的问题不是“没有 Windows runner”本身,而是: + +- 测试只覆盖了备用解析器 `loads_toml_minimal()`。 +- 没有覆盖真实入口 `load_config()`。 +- 因而让人误以为主路径已有保护。 + +这个缺口现在已经通过 `tests/test_toml_edge_cases.py` 中的 `load_config()` 回归测试补上。 + +### 影响 + +- 历史上的漏测风险已显著下降。 +- 在当前“部署需跨平台、测试和工作流默认 Linux”这一边界下,它不再构成单独阻塞问题。 +- 只有当后续部署链路引入真正依赖 Windows OS 行为的逻辑时,才需要重新评估是否增加 Windows runner。 + +### 证据 + +- `.gitea/workflows/test.yml` +- `scripts/playbook.py` +- `tests/test_toml_edge_cases.py` +- `python -m unittest tests.test_toml_edge_cases -v` + +### 处理建议 + +- 保持 Linux CI 不变。 +- 持续保留 `load_config()` 的 Windows 路径回归测试。 +- 不再把“增加 Windows runner”作为当前问题的默认修复项;只有出现真正的 OS 级差异时再引入。 + +## 问题 6:Python 缓存忽略已补上(现已缓解) + +### 位置 + +- `.gitignore` + +### 现象 + +这个问题按原描述也已不再成立。`.gitignore` 已显式忽略当前仓库会产生的 Python 缓存目录: + +- `scripts/__pycache__` +- `tests/__pycache__` +- `tests/cli/__pycache__` + +并且 `git check-ignore -v` 可以验证这些路径当前都会被忽略。 + +### 根因 + +历史上的问题确实是缺少缓存忽略项;当前仓库已经通过定向规则补上。结合仓库里 Python 文件目前只分布在 `scripts/`、`tests/` 和 `tests/cli/`,这些规则已经覆盖实际产物路径。 + +### 影响 + +- 当前工作区不会再因为测试生成的 `__pycache__` 目录而变脏。 +- 剩余的只是“规则偏定向、不够通用”的风格问题,不再是当前确认问题。 + +### 证据 + +- `.gitignore` +- `git check-ignore -v scripts/__pycache__/x.pyc tests/__pycache__/x.pyc tests/cli/__pycache__/x.pyc` +- `rg --files -g "*.py"` + +### 处理建议 + +- 保持现有规则即可。 +- 如果后续新增 Python 目录,再考虑收敛为通用规则:`__pycache__/`、`*.pyc`、`*.pyo`。 + +## 已执行验证 + +已执行: + +```text +python -m unittest discover -s tests/cli -v +python -m unittest discover -s tests -p "test_*.py" -v +``` + +结果摘要: + +- `tests/cli`:3 通过,6 失败 +- `tests`:13 通过,14 失败 + +无法在当前环境完成: + +```text +sh tests/integration/check_doc_links.sh +``` + +原因: + +- 当前 Windows 环境缺少 `sh` + +## 建议修复优先级 + +1. 先修问题 1:否则大部分 CLI 回归测试都无法有效验证。 +2. 再修问题 2 和问题 3:这两项会直接影响产出内容正确性。 +3. 然后处理问题 4 和问题 5:补平台说明和测试覆盖。 +4. 最后处理问题 6:属于低风险但高频噪音问题。 diff --git a/scripts/playbook.py b/scripts/playbook.py index bd0f064f..8dc2cc7f 100644 --- a/scripts/playbook.py +++ b/scripts/playbook.py @@ -21,6 +21,7 @@ ORDER = [ ] SCRIPT_DIR = Path(__file__).resolve().parent PLAYBOOK_ROOT = SCRIPT_DIR.parent +PATH_CONFIG_KEYS = {"project_root", "target_dir", "agents_home", "codex_home"} def usage() -> str: @@ -141,8 +142,63 @@ def loads_toml_minimal(raw: str) -> dict: return data +def normalize_path_config_strings(raw: str) -> str: + normalized_lines: list[str] = [] + for line in raw.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + normalized_lines.append(line) + continue + + key_part, value_part = line.split("=", 1) + key = key_part.strip() + if key not in PATH_CONFIG_KEYS: + normalized_lines.append(line) + continue + + value = strip_inline_comment(value_part.strip()) + if len(value) < 2 or value[0] != '"' or value[-1] != '"' or "\\" not in value[1:-1]: + normalized_lines.append(line) + continue + + inner = value[1:-1] + has_lone_backslash = False + probe_idx = 0 + while probe_idx < len(inner): + if inner[probe_idx] != "\\": + probe_idx += 1 + continue + if probe_idx + 1 < len(inner) and inner[probe_idx + 1] == "\\": + probe_idx += 2 + continue + has_lone_backslash = True + break + if not has_lone_backslash: + normalized_lines.append(line) + continue + + escaped: list[str] = [] + idx = 0 + while idx < len(inner): + ch = inner[idx] + if ch != "\\": + escaped.append(ch) + idx += 1 + continue + if idx + 1 < len(inner) and inner[idx + 1] == "\\": + escaped.extend(["\\", "\\"]) + idx += 2 + continue + escaped.extend(["\\", "\\"]) + idx += 1 + normalized_lines.append(f'{key_part}= "{"".join(escaped)}"') + + suffix = "\n" if raw.endswith("\n") else "" + return "\n".join(normalized_lines) + suffix + + def load_config(path: Path) -> dict: - raw = path.read_text(encoding="utf-8") + raw = normalize_path_config_strings(path.read_text(encoding="utf-8")) if tomllib is not None: return tomllib.loads(raw) return loads_toml_minimal(raw) @@ -830,8 +886,6 @@ def update_agents_block(agents_md: Path, block_lines: list[str]) -> None: def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | None) -> None: agents_index = agents_root / "index.md" - if agents_index.exists(): - return lines = [ "# .agents(多语言)", "", @@ -859,7 +913,7 @@ def create_agents_index(agents_root: Path, langs: list[str], docs_prefix: str | f"- {docs_prefix or 'docs/standards/playbook/docs/'}", ] agents_index.write_text("\n".join(lines) + "\n", encoding="utf-8") - log("Created .agents/index.md") + log("Synced .agents/index.md") def rewrite_agents_docs_links(agents_dir: Path, docs_prefix: str) -> None: diff --git a/tests/cli/test_playbook_cli.py b/tests/cli/test_playbook_cli.py index bb605b0b..485da82c 100644 --- a/tests/cli/test_playbook_cli.py +++ b/tests/cli/test_playbook_cli.py @@ -101,6 +101,46 @@ langs = ["tsl"] self.assertEqual(result.returncode, 0) self.assertTrue(agents_index.is_file()) + def test_sync_standards_updates_agents_index_when_langs_expand(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + + first_config = root / "playbook-first.toml" + first_config.write_text( + f""" +[playbook] +project_root = "{tmp_dir}" + +[sync_standards] +langs = ["tsl"] +no_backup = true +""", + encoding="utf-8", + ) + + first_result = run_cli("-config", str(first_config)) + self.assertEqual(first_result.returncode, 0) + + second_config = root / "playbook-second.toml" + second_config.write_text( + f""" +[playbook] +project_root = "{tmp_dir}" + +[sync_standards] +langs = ["tsl", "cpp"] +no_backup = true +""", + encoding="utf-8", + ) + + second_result = run_cli("-config", str(second_config)) + self.assertEqual(second_result.returncode, 0) + + agents_index = (root / ".agents" / "index.md").read_text(encoding="utf-8") + self.assertIn("`.agents/tsl/index.md`", agents_index) + self.assertIn("`.agents/cpp/index.md`", agents_index) + def test_sync_standards_agents_block_has_blank_lines(self): with tempfile.TemporaryDirectory() as tmp_dir: config_body = f""" diff --git a/tests/test_superpowers_list_sync.py b/tests/test_superpowers_list_sync.py index 3dd723bd..e178f072 100644 --- a/tests/test_superpowers_list_sync.py +++ b/tests/test_superpowers_list_sync.py @@ -4,6 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SKILLS_MD = ROOT / "SKILLS.md" SOURCES_LIST = ROOT / "codex" / "skills" / ".sources" / "superpowers.list" +SOURCE_REF = "来源:`codex/skills/.sources/superpowers.list`(第三方来源清单)。" def read_sources_list() -> list[str]: @@ -14,28 +15,20 @@ def read_sources_list() -> list[str]: ] -def read_skills_md_list() -> list[str]: - lines = SKILLS_MD.read_text(encoding="utf-8").splitlines() - start = "" - end = "" - try: - start_idx = lines.index(start) + 1 - end_idx = lines.index(end) - except ValueError as exc: - raise AssertionError("superpowers markers missing in SKILLS.md") from exc - - items = [] - for line in lines[start_idx:end_idx]: - stripped = line.strip() - if not stripped.startswith("-"): - continue - items.append(stripped.lstrip("- ").strip()) - return items +def read_skills_md() -> str: + return SKILLS_MD.read_text(encoding="utf-8") class SuperpowersListSyncTests(unittest.TestCase): - def test_superpowers_list_matches_skills_md(self): - self.assertEqual(read_sources_list(), read_skills_md_list()) + def test_superpowers_section_routes_to_source_list(self): + self.assertTrue(read_sources_list()) + + text = read_skills_md() + + self.assertIn(SOURCE_REF, text) + self.assertEqual(text.count("Third-party Skills (superpowers)"), 1) + self.assertNotIn("", text) + self.assertNotIn("", text) if __name__ == "__main__": diff --git a/tests/test_toml_edge_cases.py b/tests/test_toml_edge_cases.py index a64e641f..d7c10471 100644 --- a/tests/test_toml_edge_cases.py +++ b/tests/test_toml_edge_cases.py @@ -1,4 +1,6 @@ +import tempfile import unittest +from pathlib import Path from scripts import playbook @@ -18,6 +20,44 @@ key = 1 with self.assertRaises(ValueError): playbook.loads_toml_minimal(raw) + def test_load_config_preserves_windows_users_path_in_basic_string(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text( + '[playbook]\nproject_root = "C:\\Users\\demo\\workspace"\n', + encoding="utf-8", + ) + + data = playbook.load_config(config_path) + + self.assertEqual(data["playbook"]["project_root"], r"C:\Users\demo\workspace") + + def test_load_config_preserves_windows_escape_like_segments_for_path_keys(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text( + '[playbook]\nproject_root = "C:\\tmp\\notes"\n\n' + '[install_skills]\nagents_home = "D:\\new\\tab"\n', + encoding="utf-8", + ) + + data = playbook.load_config(config_path) + + self.assertEqual(data["playbook"]["project_root"], r"C:\tmp\notes") + self.assertEqual(data["install_skills"]["agents_home"], r"D:\new\tab") + + def test_load_config_keeps_already_escaped_windows_path(self): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "playbook.toml" + config_path.write_text( + '[playbook]\nproject_root = "C:\\\\Users\\\\demo\\\\workspace"\n', + encoding="utf-8", + ) + + data = playbook.load_config(config_path) + + self.assertEqual(data["playbook"]["project_root"], r"C:\Users\demo\workspace") + if __name__ == "__main__": unittest.main()